diff --git a/.travis.yml b/.travis.yml index 0a6c43b..a308da0 100644 --- a/.travis.yml +++ b/.travis.yml @@ -82,8 +82,6 @@ script: - pytest --cov=pygeoapi - make -C ./docs html - find . -type f -name "*.py" | xargs flake8 - # run linting openapi document through dockerized spectral cli - - docker run --rm -it -v $(pwd):/tmp stoplight/spectral lint "/tmp/pygeoapi-openapi.yml" # run docker image with cite configuration - docker run -d -p 5001:5001 --network host --add-host="localhost:127.0.0.1" --rm -it -v $(pwd)/tests/cite/ogcapi-features/cite.config.yml:/pygeoapi/local.config.yml --name pygeoapi-travis-master geopython/pygeoapi:latest run - docker ps | grep -wq 'pygeoapi-travis-master' @@ -92,6 +90,8 @@ script: - docker run --rm --name pygeoapi-travis-runtests-master geopython/pygeoapi:latest test after_success: + # run linting openapi document through dockerized spectral cli + - docker run --rm -it -v $(pwd):/tmp stoplight/spectral lint "/tmp/pygeoapi-openapi.yml" - python3 setup.py sdist bdist_wheel --universal - sudo debuild -b -uc -us @@ -99,4 +99,4 @@ notifications: irc: channels: - "irc.freenode.org#pygeoapi-activity" - use_notice: true \ No newline at end of file + use_notice: true diff --git a/docs/source/configuration.rst b/docs/source/configuration.rst index 83290b8..02be81f 100644 --- a/docs/source/configuration.rst +++ b/docs/source/configuration.rst @@ -158,12 +158,17 @@ default. # see pygeoapi.plugin for supported providers # for custom built plugins, use the import path (e.g. mypackage.provider.MyProvider) # see Plugins section for more information - - type: feature # underlying data geospatial type: (allowed values are: feature) + - type: feature # underlying data geospatial type: (allowed values are: feature, coverage) default: true # optional: if not specified, the first provider definition is considered the default name: CSV data: tests/data/obs.csv # required: the data filesystem path or URL, depending on plugin setup id_field: id # required for vector data, the field corresponding to the ID time_field: datetimestamp # optional field corresponding to the temporal propert of the dataset + format: # optional default format + name: GeoJSON # required: format name + mimetype: application/json # required: format mimetype + options: # optional options to pass to provider (i.e. GDAL creation) + option_name: option_value properties: # optional: only return the following properties, in order - stn_id - value diff --git a/docs/source/data-publishing/index.rst b/docs/source/data-publishing/index.rst index b6d6768..bd2f28a 100644 --- a/docs/source/data-publishing/index.rst +++ b/docs/source/data-publishing/index.rst @@ -19,5 +19,6 @@ return back data to the pygeoapi API framework in a plug and play fashion. :name: Data publishing ogcapi-features + ogcapi-coverages ogcapi-processes stac diff --git a/docs/source/data-publishing/ogcapi-coverages.rst b/docs/source/data-publishing/ogcapi-coverages.rst new file mode 100644 index 0000000..8480a5e --- /dev/null +++ b/docs/source/data-publishing/ogcapi-coverages.rst @@ -0,0 +1,68 @@ +.. _ogcapi-coverages: + +Publishing raster data to OGC API - Coverages +============================================= + +`OGC API - Coverages`_ provides geospatial data access functionality to raster data. + +To add raster data to pygeoapi, you can use the dataset example in :ref:`configuration` +as a baseline and modify accordingly. + +Providers +--------- + +pygeoapi core feature providers are listed below, along with a matrix of supported query +parameters. + +.. csv-table:: + :header: Provider, rangeSubset, subsets + :align: left + + rasterio,✔️,✔️,✔️, + + +Below are specific connection examples based on supported providers. + +Connection examples +------------------- + +rasterio +^^^^^^^^ + +The `rasterio`_ provider plugin reads and extracts any data that rasterio is +capable of handling. + +.. code-block:: yaml + + providers: + - type: coverage + name: rasterio + data: tests/data/CMC_glb_TMP_TGL_2_latlon.15x.15_2020081000_P000.grib2 + options: # optional creation options + DATA_ENCODING: COMPLEX_PACKING + format: + name: GRIB2 + mimetype: application/x-grib2 + +Data access examples +-------------------- + +- list all collections + - http://localhost:5000/collections +- overview of dataset + - http://localhost:5000/collections/foo +- coverage rangetype + - http://localhost:5000/collections/foo/coverage/rangetype +- coverage domainset + - http://localhost:5000/collections/foo/coverage/domainset +- coverage access via CoverageJSON (default) + - http://localhost:5000/collections/foo/coverage?f=json +- coverage access via native format (as defined in ``provider.format.name``) + - http://localhost:5000/collections/foo/coverage?f=GRIB2 +- coverage access with comma-separated rangeSubset + - http://localhost:5000/collections/foo/coverage?rangeSubset=1,3 +- coverage access with subsetting + - http://localhost:5000/collections/foo/coverage?subset=lat(10,20)&subset=long(10,20) + +.. _`OGC API - Coverages`: https://github.com/opengeospatial/ogc_api_coverages +.. _`rasterio`: https://rasterio.readthedocs.io diff --git a/docs/source/introduction.rst b/docs/source/introduction.rst index 157d9ae..2acf787 100644 --- a/docs/source/introduction.rst +++ b/docs/source/introduction.rst @@ -10,8 +10,8 @@ Features - out of the box modern OGC API server - certified OGC Compliant and Reference Implementation for OGC API - Features -- additionally implements OGC API - Processes and SpatioTemporal Asset Library -- out of the box data provider plugins for GDAL/OGR, Elasticsearch, PostgreSQL/PostGIS +- additionally implements OGC API - Coverages, OGC API - Processes and SpatioTemporal Asset Library +- out of the box data provider plugins for rasterio, GDAL/OGR, Elasticsearch, PostgreSQL/PostGIS - easy to use OpenAPI / Swagger documentation for developers - supports JSON, GeoJSON, HTML and CSV output - supports data filtering by spatial, temporal or attribute queries @@ -36,6 +36,7 @@ Standards are at the core of pygeoapi. Below is the project's standards support :widths: 20, 20 `OGC API - Features`_,Reference Implementation + `OGC API - Coverages`_,Implementing `OGC API - Processes`_,Implementing `SpatioTemporal Asset Catalog`_,Implementing @@ -43,5 +44,6 @@ Standards are at the core of pygeoapi. Below is the project's standards support .. _`pygeoapi`: https://pygeoapi.io .. _`OGC API`: https://ogcapi.ogc.org .. _`OGC API - Features`: https://www.ogc.org/standards/ogcapi-features +.. _`OGC API - Coverages`: https://github.com/opengeospatial/ogc_api_coverages .. _`OGC API - Processes`: https://github.com/opengeospatial/wps-rest-binding .. _`SpatioTemporal Asset Catalog`: https://stacspec.org diff --git a/docs/source/plugins.rst b/docs/source/plugins.rst index c09cb2e..cdbfd8f 100644 --- a/docs/source/plugins.rst +++ b/docs/source/plugins.rst @@ -38,22 +38,22 @@ pygeoapi for easier maintenance of software updates. updates and package management -Example: custom pygeoapi data provider --------------------------------------- +Example: custom pygeoapi vector data provider +--------------------------------------------- -Lets consider the steps for a data provider plugin (source code is located here: :ref:`data Provider`). +Lets consider the steps for a vector data provider plugin (source code is located here: :ref:`data Provider`). Python code ^^^^^^^^^^^ -The below template provides a minimal example (let's call the file ``mycooldata.py``: +The below template provides a minimal example (let's call the file ``mycoolvectordata.py``: .. code-block:: python from pygeoapi.provider.base import BaseProvider - class MyCoolDataProvider(BaseProvider): - """My cool data provider""" + class MyCoolVectorDataProvider(BaseProvider): + """My cool vector data provider""" def __init__(self, provider_def): """Inherit from parent class""" @@ -91,7 +91,7 @@ The below template provides a minimal example (let's call the file ``mycooldata. For brevity, the above code will always return the single feature of the dataset. In reality, the plugin -developer would connect to a data source with capabilities to run queries and return relevant a result set, +developer would connect to a data source with capabilities to run queries and return a relevant result set, as well as implement the ``get`` method accordingly. As long as the plugin implements the API contract of its base provider, all other functionality is left to the provider implementation. @@ -104,23 +104,23 @@ The following methods are options to connect the plugin to pygeoapi: **Option 1**: Update in core pygeoapi: -- copy ``mycooldata.py`` into ``pygeoapi/provider`` +- copy ``mycoolvectordata.py`` into ``pygeoapi/provider`` - update the plugin registry in ``pygeoapi/plugin.py:PLUGINS['provider']`` with the plugin's - shortname (say ``MyCoolData``) and dotted path to the class (i.e. ``pygeoapi.provider.mycooldata.MyCoolDataProvider``) + shortname (say ``MyCoolVectorData``) and dotted path to the class (i.e. ``pygeoapi.provider.mycoolvectordata.MyCoolVectorDataProvider``) - specify in your dataset provider configuration as follows: .. code-block:: yaml providers: - type: feature - name: MyCoolData + name: MyCoolVectorData data: /path/to/file id_field: stn_id **Option 2**: implement outside of pygeoapi and add to configuration (recommended) -- create a Python package of the ``mycooldata.py`` module (see `Cookiecutter`_ as an example) +- create a Python package of the ``mycoolvectordata.py`` module (see `Cookiecutter`_ as an example) - install your Python package onto your system (``python setup.py install``). At this point your new package should be in the ``PYTHONPATH`` of your pygeoapi installation - specify in your dataset provider configuration as follows: @@ -129,10 +129,61 @@ The following methods are options to connect the plugin to pygeoapi: providers: - type: feature - name: mycooldatapackage.mycooldata.MyCoolDataProvider + name: mycooldatapackage.mycoolvectordata.MyCoolVectorDataProvider data: /path/to/file id_field: stn_id +BEGIN + +Example: custom pygeoapi raster data provider +--------------------------------------------- + +Lets consider the steps for a raster data provider plugin (source code is located here: :ref:`data Provider`). + +Python code +^^^^^^^^^^^ + +The below template provides a minimal example (let's call the file ``mycoolrasterdata.py``: + +.. code-block:: python + + from pygeoapi.provider.base import BaseProvider + + class MyCoolRasterDataProvider(BaseProvider): + """My cool raster data provider""" + + def __init__(self, provider_def): + """Inherit from parent class""" + + BaseProvider.__init__(self, provider_def) + self.num_bands = 4 + self.axes = ['Lat', 'Long'] + + def get_coverage_domainset(self): + # return a CIS JSON DomainSet + + def get_coverage_rangetype(self): + # return a CIS JSON RangeType + + def query(self, bands=[], subsets={}, format_='json'): + # process bands and subsets parameters + # query/extract coverage data + if format_ == 'json': + # return a CoverageJSON representation + return {'type': 'Coverage', ...} # trimmed for brevity + else: + # return default (likely binary) representation + return bytes(112) + +For brevity, the above code will always JSON for metadata and binary or CoverageJSON for the data. In reality, the plugin +developer would connect to a data source with capabilities to run queries and return a relevant result set, +As long as the plugin implements the API contract of its base provider, all other functionality is left to the provider +implementation. + +Each base class documents the functions, arguments and return types required for implementation. + +END + Example: custom pygeoapi formatter ---------------------------------- diff --git a/pygeoapi-config.yml b/pygeoapi-config.yml index a6e5fa3..ba57286 100644 --- a/pygeoapi-config.yml +++ b/pygeoapi-config.yml @@ -145,12 +145,40 @@ resources: data: tests/data/ne_110m_lakes.geojson id_field: id + gdps-temperature: + type: collection + title: Global Deterministic Prediction System sample + description: Global Deterministic Prediction System sample + keywords: + - gdps + - global + extents: + spatial: + bbox: [-180,-90,180,90] + crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 + links: + - type: text/html + rel: canonical + title: information + href: https://eccc-msc.github.io/open-data/msc-data/nwp_gdps/readme_gdps_en + hreflang: en-CA + providers: + - type: coverage + name: rasterio + data: tests/data/CMC_glb_TMP_TGL_2_latlon.15x.15_2020081000_P000.grib2 + options: + DATA_ENCODING: COMPLEX_PACKING + format: + name: GRIB2 + mimetype: application/x-grib2 + test-data: type: stac-collection title: pygeoapi test data description: pygeoapi test data keywords: - - poi,portugal + - poi + - portugal links: - type: text/html rel: canonical diff --git a/pygeoapi/api.py b/pygeoapi/api.py index 328bac1..bb5e7ef 100644 --- a/pygeoapi/api.py +++ b/pygeoapi/api.py @@ -34,6 +34,7 @@ from datetime import datetime import json import logging import os +import re import urllib.parse from dateutil.parser import parse as dateparse @@ -46,10 +47,12 @@ from pygeoapi.log import setup_logger from pygeoapi.plugin import load_plugin, PLUGINS from pygeoapi.provider.base import ( ProviderGenericError, ProviderConnectionError, ProviderNotFoundError, - ProviderQueryError, ProviderItemNotFoundError) + ProviderInvalidQueryError, ProviderQueryError, ProviderItemNotFoundError, + ProviderTypeError) from pygeoapi.util import (dategetter, filter_dict_by_key_value, get_provider_by_type, get_provider_default, - json_serial, render_j2_template, TEMPLATES, to_json) + get_typed_value, render_j2_template, + TEMPLATES, to_json) LOGGER = logging.getLogger(__name__) @@ -66,9 +69,14 @@ CONFORMANCE = [ 'http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/core', 'http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/oas30', 'http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/html', - 'http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/geojson' + 'http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/geojson', + 'http://www.opengis.net/spec/ogcapi_coverages-1/1.0/conf/core', + 'http://www.opengis.net/spec/ogcapi-coverages-1/1.0/conf/oas30', + 'http://www.opengis.net/spec/ogcapi-coverages-1/1.0/conf/html' ] +OGC_RELTYPES_BASE = 'http://www.opengis.net/def/rel/ogc/1.0' + def pre_process(func): """ @@ -111,6 +119,11 @@ class API: if 'templates' not in self.config['server']: self.config['server']['templates'] = TEMPLATES + if 'pretty_print' not in self.config['server']: + self.config['server']['pretty_print'] = False + + self.pretty_print = self.config['server']['pretty_print'] + setup_logger(self.config['logging']) @pre_process @@ -132,7 +145,7 @@ class API: 'description': 'Invalid format' } LOGGER.error(exception) - return headers_, 400, json.dumps(exception) + return headers_, 400, to_json(exception, self.pretty_print) fcm = { 'links': [], @@ -202,9 +215,9 @@ class API: if format_ == 'jsonld': headers_['Content-Type'] = 'application/ld+json' - return headers_, 200, json.dumps(self.fcmld) + return headers_, 200, to_json(self.fcmld, self.pretty_print) - return headers_, 200, json.dumps(fcm) + return headers_, 200, to_json(fcm, self.pretty_print) @pre_process def openapi(self, headers_, format_, openapi): @@ -226,7 +239,7 @@ class API: 'description': 'Invalid format' } LOGGER.error(exception) - return headers_, 400, json.dumps(exception) + return headers_, 400, to_json(exception, self.pretty_print) path = '/'.join([self.config['server']['url'].rstrip('/'), 'openapi']) @@ -241,7 +254,7 @@ class API: headers_['Content-Type'] = \ 'application/vnd.oai.openapi+json;version=3.0' - return headers_, 200, json.dumps(openapi) + return headers_, 200, to_json(openapi, self.pretty_print) @pre_process def conformance(self, headers_, format_): @@ -261,7 +274,7 @@ class API: 'description': 'Invalid format' } LOGGER.error(exception) - return headers_, 400, json.dumps(exception) + return headers_, 400, to_json(exception, self.pretty_print) conformance = { 'conformsTo': CONFORMANCE @@ -273,7 +286,7 @@ class API: conformance) return headers_, 200, content - return headers_, 200, json.dumps(conformance) + return headers_, 200, to_json(conformance, self.pretty_print) @pre_process @jsonldify @@ -295,7 +308,7 @@ class API: 'description': 'Invalid format' } LOGGER.error(exception) - return headers_, 400, json.dumps(exception) + return headers_, 400, to_json(exception, self.pretty_print) fcm = { 'collections': [], @@ -311,16 +324,20 @@ class API: 'description': 'Invalid collection' } LOGGER.error(exception) - return headers_, 400, json.dumps(exception) + return headers_, 400, to_json(exception, self.pretty_print) LOGGER.debug('Creating collections') for k, v in collections.items(): - collection_data_type = get_provider_default( - v['providers'])['type'] + collection_data = get_provider_default(v['providers']) + collection_data_type = collection_data['type'] + + collection_data_format = None + + if 'format' in collection_data: + collection_data_format = collection_data['format'] collection = {'links': []} collection['id'] = k - collection['itemType'] = collection_data_type.capitalize() collection['title'] = v['title'] collection['description'] = v['description'] collection['keywords'] = v['keywords'] @@ -386,6 +403,7 @@ class API: }) if collection_data_type == 'feature': + collection['itemType'] = collection_data_type.capitalize() LOGGER.debug('Adding feature based links') collection['links'].append({ 'type': 'application/json', @@ -422,6 +440,75 @@ class API: 'href': '{}/collections/{}/items?f=html'.format( self.config['server']['url'], k) }) + elif collection_data_type == 'coverage': + LOGGER.debug('Adding coverage based links') + collection['links'].append({ + 'type': 'application/json', + 'rel': 'collection', + 'title': 'Detailed Coverage metadata in JSON', + 'href': '{}/collections/{}?f=json'.format( + self.config['server']['url'], k) + }) + collection['links'].append({ + 'type': 'text/html', + 'rel': 'collection', + 'title': 'Detailed Coverage metadata in HTML', + 'href': '{}/collections/{}?f=html'.format( + self.config['server']['url'], k) + }) + coverage_url = '{}/collections/{}/coverage'.format( + self.config['server']['url'], k) + + collection['links'].append({ + 'type': 'application/json', + 'rel': '{}/coverage-domainset'.format(OGC_RELTYPES_BASE), + 'title': 'Coverage domain set of collection in JSON', + 'href': '{}/domainset?f=json'.format(coverage_url) + }) + collection['links'].append({ + 'type': 'text/html', + 'rel': '{}/coverage-domainset'.format(OGC_RELTYPES_BASE), + 'title': 'Coverage domain set of collection in HTML', + 'href': '{}/domainset?f=html'.format(coverage_url) + }) + collection['links'].append({ + 'type': 'application/json', + 'rel': '{}/coverage-rangetype'.format(OGC_RELTYPES_BASE), + 'title': 'Coverage range type of collection in JSON', + 'href': '{}/rangetype?f=json'.format(coverage_url) + }) + collection['links'].append({ + 'type': 'text/html', + 'rel': '{}/coverage-rangetype'.format(OGC_RELTYPES_BASE), + 'title': 'Coverage range type of collection in HTML', + 'href': '{}/rangetype?f=html'.format(coverage_url) + }) + collection['links'].append({ + 'type': 'application/prs.coverage+json', + 'rel': '{}/coverage'.format(OGC_RELTYPES_BASE), + 'title': 'Coverage data', + 'href': '{}/collections/{}/coverage?f=json'.format( + self.config['server']['url'], k) + }) + if collection_data_format is not None: + collection['links'].append({ + 'type': collection_data_format['mimetype'], + 'rel': '{}/coverage'.format(OGC_RELTYPES_BASE), + 'title': 'Coverage data as {}'.format( + collection_data_format['name']), + 'href': '{}/collections/{}/coverage?f={}'.format( + self.config['server']['url'], k, + collection_data_format['name']) + }) + if dataset is not None: + LOGGER.debug('Creating extended coverage metadata') + p = load_plugin('provider', get_provider_by_type( + self.config['resources'][dataset]['providers'], + 'coverage')) + + collection['crs'] = [p.crs] + collection['domainset'] = p.get_coverage_domainset() + collection['rangetype'] = p.get_coverage_rangetype() if dataset is not None and k == dataset: fcm = collection @@ -478,9 +565,9 @@ class API: ) ) headers_['Content-Type'] = 'application/ld+json' - return headers_, 200, json.dumps(jsonld) + return headers_, 200, to_json(jsonld, self.pretty_print) - return headers_, 200, json.dumps(fcm, default=json_serial) + return headers_, 200, to_json(fcm, self.pretty_print) @pre_process @jsonldify @@ -502,7 +589,7 @@ class API: 'description': 'Invalid format' } LOGGER.error(exception) - return headers_, 400, json.dumps(exception) + return headers_, 400, to_json(exception, self.pretty_print) if any([dataset is None, dataset not in self.config['resources'].keys()]): @@ -512,7 +599,7 @@ class API: 'description': 'Invalid collection' } LOGGER.error(exception) - return headers_, 400, json.dumps(exception) + return headers_, 400, to_json(exception, self.pretty_print) LOGGER.debug('Creating collection queryables') LOGGER.debug('Loading provider') @@ -525,14 +612,14 @@ class API: 'description': 'connection error (check logs)' } LOGGER.error(exception) - return headers_, 500, json.dumps(exception) + return headers_, 500, to_json(exception, self.pretty_print) except ProviderQueryError: exception = { 'code': 'NoApplicableCode', 'description': 'query error (check logs)' } LOGGER.error(exception) - return headers_, 500, json.dumps(exception) + return headers_, 500, to_json(exception, self.pretty_print) queryables = { 'queryables': [] @@ -560,7 +647,7 @@ class API: return headers_, 200, content - return headers_, 200, json.dumps(queryables, default=json_serial) + return headers_, 200, to_json(queryables, self.pretty_print) def get_collection_items(self, headers, args, dataset, pathinfo=None): """ @@ -591,7 +678,7 @@ class API: 'description': 'Invalid collection' } LOGGER.error(exception) - return headers_, 400, json.dumps(exception, default=json_serial) + return headers_, 400, to_json(exception, self.pretty_print) format_ = check_format(args, headers) @@ -601,7 +688,7 @@ class API: 'description': 'Invalid format' } LOGGER.error(exception) - return headers_, 400, json.dumps(exception) + return headers_, 400, to_json(exception, self.pretty_print) LOGGER.debug('Processing query parameters') @@ -615,7 +702,7 @@ class API: 'or zero' } LOGGER.error(exception) - return headers_, 400, json.dumps(exception) + return headers_, 400, to_json(exception, self.pretty_print) except (TypeError) as err: LOGGER.warning(err) startindex = 0 @@ -626,7 +713,7 @@ class API: 'description': 'startindex value should be an integer' } LOGGER.error(exception) - return headers_, 400, json.dumps(exception) + return headers_, 400, to_json(exception, self.pretty_print) LOGGER.debug('Processing limit parameter') try: @@ -639,7 +726,7 @@ class API: 'description': 'limit value should be strictly positive' } LOGGER.error(exception) - return headers_, 400, json.dumps(exception) + return headers_, 400, to_json(exception, self.pretty_print) except TypeError as err: LOGGER.warning(err) limit = int(self.config['server']['limit']) @@ -650,7 +737,7 @@ class API: 'description': 'limit value should be an integer' } LOGGER.error(exception) - return headers_, 400, json.dumps(exception) + return headers_, 400, to_json(exception, self.pretty_print) resulttype = args.get('resulttype') or 'results' @@ -663,7 +750,7 @@ class API: 'description': 'bbox values should be minx,miny,maxx,maxy' } LOGGER.error(exception) - return headers_, 400, json.dumps(exception) + return headers_, 400, to_json(exception, self.pretty_print) except AttributeError: bbox = [] try: @@ -674,7 +761,7 @@ class API: 'description': 'bbox values must be numbers' } LOGGER.error(exception) - return headers_, 400, json.dumps(exception) + return headers_, 400, to_json(exception, self.pretty_print) LOGGER.debug('Processing datetime parameter') # TODO: pass datetime to query as a `datetime` object @@ -737,26 +824,33 @@ class API: 'description': 'datetime parameter out of range' } LOGGER.error(exception) - return headers_, 400, json.dumps(exception) + return headers_, 400, to_json(exception, self.pretty_print) LOGGER.debug('Loading provider') try: p = load_plugin('provider', get_provider_by_type( collections[dataset]['providers'], 'feature')) + except ProviderTypeError: + exception = { + 'code': 'NoApplicableCode', + 'description': 'invalid provider type' + } + LOGGER.error(exception) + return headers_, 400, to_json(exception, self.pretty_print) except ProviderConnectionError: exception = { 'code': 'NoApplicableCode', 'description': 'connection error (check logs)' } LOGGER.error(exception) - return headers_, 500, json.dumps(exception) + return headers_, 500, to_json(exception, self.pretty_print) except ProviderQueryError: exception = { 'code': 'NoApplicableCode', 'description': 'query error (check logs)' } LOGGER.error(exception) - return headers_, 500, json.dumps(exception) + return headers_, 500, to_json(exception, self.pretty_print) LOGGER.debug('processing property parameters') for k, v in args.items(): @@ -766,7 +860,7 @@ class API: 'description': 'unknown query parameter' } LOGGER.error(exception) - return headers_, 400, json.dumps(exception) + return headers_, 400, to_json(exception, self.pretty_print) elif k not in reserved_fieldnames and k in p.fields.keys(): LOGGER.debug('Add property filter {}={}'.format(k, v)) properties.append((k, v)) @@ -786,7 +880,8 @@ class API: 'description': 'sort order should be A or D' } LOGGER.error(exception) - return headers_, 400, json.dumps(exception) + return headers_, 400, to_json(exception, + self.pretty_print) sortby.append({'property': prop, 'order': order}) else: sortby.append({'property': s, 'order': 'A'}) @@ -797,7 +892,7 @@ class API: 'description': 'bad sort property' } LOGGER.error(exception) - return headers_, 400, json.dumps(exception) + return headers_, 400, to_json(exception, self.pretty_print) else: sortby = [] @@ -818,21 +913,21 @@ class API: 'description': 'connection error (check logs)' } LOGGER.error(err) - return headers_, 500, json.dumps(exception) + return headers_, 500, to_json(exception, self.pretty_print) except ProviderQueryError as err: exception = { 'code': 'NoApplicableCode', 'description': 'query error (check logs)' } LOGGER.error(err) - return headers_, 500, json.dumps(exception) + return headers_, 500, to_json(exception, self.pretty_print) except ProviderGenericError as err: exception = { 'code': 'NoApplicableCode', 'description': 'generic error (check logs)' } LOGGER.error(err) - return headers_, 500, json.dumps(exception) + return headers_, 500, to_json(exception, self.pretty_print) serialized_query_params = '' for k, v in args.items(): @@ -945,7 +1040,7 @@ class API: content = geojson2geojsonld(self.config, content, dataset) return headers_, 200, content - return headers_, 200, json.dumps(content, default=json_serial) + return headers_, 200, to_json(content, self.pretty_print) @pre_process def get_collection_item(self, headers_, format_, dataset, identifier): @@ -967,7 +1062,7 @@ class API: 'description': 'Invalid format' } LOGGER.error(exception) - return headers_, 400, json.dumps(exception) + return headers_, 400, to_json(exception, self.pretty_print) LOGGER.debug('Processing query parameters') @@ -980,12 +1075,19 @@ class API: 'description': 'Invalid collection' } LOGGER.error(exception) - return headers_, 400, json.dumps(exception) + return headers_, 400, to_json(exception, self.pretty_print) LOGGER.debug('Loading provider') - p = load_plugin('provider', get_provider_by_type( - collections[dataset]['providers'], 'feature')) - + try: + p = load_plugin('provider', get_provider_by_type( + collections[dataset]['providers'], 'feature')) + except ProviderTypeError: + exception = { + 'code': 'NoApplicableCode', + 'description': 'invalid provider type' + } + LOGGER.error(exception) + return headers_, 400, to_json(exception, self.pretty_print) try: LOGGER.debug('Fetching id {}'.format(identifier)) content = p.get(identifier) @@ -995,28 +1097,28 @@ class API: 'description': 'connection error (check logs)' } LOGGER.error(err) - return headers_, 500, json.dumps(exception) + return headers_, 500, to_json(exception, self.pretty_print) except ProviderItemNotFoundError: exception = { 'code': 'NotFound', 'description': 'identifier not found' } LOGGER.error(exception) - return headers_, 404, json.dumps(exception) + return headers_, 404, to_json(exception, self.pretty_print) except ProviderQueryError as err: exception = { 'code': 'NoApplicableCode', 'description': 'query error (check logs)' } LOGGER.error(err) - return headers_, 500, json.dumps(exception) + return headers_, 500, to_json(exception, self.pretty_print) except ProviderGenericError as err: exception = { 'code': 'NoApplicableCode', 'description': 'generic error (check logs)' } LOGGER.error(err) - return headers_, 500, json.dumps(exception) + return headers_, 500, to_json(exception, self.pretty_print) if content is None: exception = { @@ -1024,7 +1126,7 @@ class API: 'description': 'identifier not found' } LOGGER.error(exception) - return headers_, 404, json.dumps(exception) + return headers_, 404, to_json(exception, self.pretty_print) content['links'] = [{ 'rel': 'self' if not format_ or format_ == 'json' else 'alternate', @@ -1077,7 +1179,384 @@ class API: ) return headers_, 200, content - return headers_, 200, json.dumps(content, default=json_serial) + return headers_, 200, to_json(content, self.pretty_print) + + @jsonldify + def get_collection_coverage(self, headers_, args, dataset, + pathinfo=None): + """ + Returns a subset of a collection coverage + + :param headers: dict of HTTP headers + :param args: dict of HTTP request parameters + :param dataset: dataset name + :param pathinfo: path location + + :returns: tuple of headers, status code, content + """ + + query_args = {} + format_ = 'json' + + LOGGER.debug('Processing query parameters') + + subsets = {} + + LOGGER.debug('Loading provider') + try: + collection_def = get_provider_by_type( + self.config['resources'][dataset]['providers'], 'coverage') + + p = load_plugin('provider', collection_def) + except ProviderTypeError: + exception = { + 'code': 'NoApplicableCode', + 'description': 'invalid provider type' + } + LOGGER.error(exception) + return ({'Content-type': 'application/json'}, 400, + to_json(exception, self.pretty_print)) + except ProviderConnectionError: + exception = { + 'code': 'NoApplicableCode', + 'description': 'connection error (check logs)' + } + LOGGER.error(exception) + return headers_, 500, to_json(exception, self.pretty_print) + + if 'f' in args: + query_args['format_'] = format_ = args['f'] + if 'rangeSubset' in args: + LOGGER.debug('Processing rangeSubset parameter') + query_args['bands'] = list( + filter(None, args['rangeSubset'].split(','))) + LOGGER.debug('Bands: {}'.format(query_args['bands'])) + + for a in query_args['bands']: + if int(a) > p.num_bands: + exception = { + 'code': 'InvalidParameterValue', + 'description': 'Invalid bands specified' + } + LOGGER.error(exception) + return ({'Content-type': 'application/json'}, 400, + to_json(exception, self.pretty_print)) + + if 'subset' in args: + LOGGER.debug('Processing subset parameters') + for s in args.getlist('subset'): + try: + m = re.search(r'(.*)\((.*),(.*)\)', s) + subset_name = m.group(1) + if subset_name not in p.axes: + exception = { + 'code': 'InvalidParameterValue', + 'description': 'Invalid axis name' + } + LOGGER.error(exception) + return ({'Content-type': 'application/json'}, 400, + to_json(exception, self.pretty_print)) + + subsets[subset_name] = list(map( + get_typed_value, m.group(2, 3))) + except AttributeError: + exception = { + 'code': 'InvalidParameterValue', + 'description': 'subset should be like "axis(min,max)"' + } + LOGGER.error(exception) + return headers_, 400, to_json(exception, self.pretty_print) + + query_args['subsets'] = subsets + LOGGER.debug('Subsets: {}'.format(query_args['subsets'])) + + LOGGER.debug('Querying coverage') + try: + data = p.query(**query_args) + except ProviderInvalidQueryError as err: + exception = { + 'code': 'NoApplicableCode', + 'description': 'query error: {}'.format(err), + } + LOGGER.error(exception) + return ({'Content-type': 'application/json'}, + 400, to_json(exception, self.pretty_print)) + except ProviderQueryError: + exception = { + 'code': 'NoApplicableCode', + 'description': 'query error (check logs)' + } + LOGGER.error(exception) + return ({'Content-type': 'application/json'}, + 500, to_json(exception, self.pretty_print)) + + mt = collection_def['format']['name'] + + if format_ == mt: + return ({'Content-type': mt}, 200, data) + elif format_ == 'json': + return ({'Content-type': 'application/prs.coverage+json'}, + 200, to_json(data, self.pretty_print)) + else: + exception = { + 'code': 'InvalidParameterValue', + 'description': 'invalid format parameter' + } + LOGGER.error(exception) + return ({'Content-type': 'application/json'}, + 400, to_json(exception, self.pretty_print)) + + @jsonldify + def get_collection_coverage_domainset(self, headers_, args, dataset, + pathinfo=None): + """ + Returns a collection coverage domainset + + :param headers: dict of HTTP headers + :param args: dict of HTTP request parameters + :param dataset: dataset name + :param pathinfo: path location + + :returns: tuple of headers, status code, content + """ + + format_ = check_format(args, headers_) + if format_ is None: + format_ = 'json' + + LOGGER.debug('Loading provider') + try: + collection_def = get_provider_by_type( + self.config['resources'][dataset]['providers'], 'coverage') + + p = load_plugin('provider', collection_def) + + data = p.get_coverage_domainset() + except ProviderTypeError: + exception = { + 'code': 'NoApplicableCode', + 'description': 'invalid provider type' + } + LOGGER.error(exception) + return ({'Content-type': 'application/json'}, 400, + to_json(exception, self.pretty_print)) + except ProviderConnectionError: + exception = { + 'code': 'NoApplicableCode', + 'description': 'connection error (check logs)' + } + LOGGER.error(exception) + return headers_, 500, to_json(exception, self.pretty_print) + + if format_ == 'json': + return ({'Content-type': 'application/json'}, + 200, to_json(data, self.pretty_print)) + elif format_ == 'html': + data['id'] = dataset + data['title'] = self.config['resources'][dataset]['title'] + content = render_j2_template(self.config, 'domainset.html', + data) + return {'Content-type': 'text/html'}, 200, content + else: + exception = { + 'code': 'InvalidParameterValue', + 'description': 'invalid format parameter' + } + LOGGER.error(exception) + return ({'Content-type': 'application/json'}, + 400, to_json(exception, self.pretty_print)) + + @jsonldify + def get_collection_coverage_rangetype(self, headers_, args, dataset, + pathinfo=None): + """ + Returns a collection coverage rangetype + + :param headers: dict of HTTP headers + :param args: dict of HTTP request parameters + :param dataset: dataset name + :param pathinfo: path location + + :returns: tuple of headers, status code, content + """ + + format_ = check_format(args, headers_) + if format_ is None: + format_ = 'json' + + LOGGER.debug('Loading provider') + try: + collection_def = get_provider_by_type( + self.config['resources'][dataset]['providers'], 'coverage') + + p = load_plugin('provider', collection_def) + + data = p.get_coverage_rangetype() + except ProviderTypeError: + exception = { + 'code': 'NoApplicableCode', + 'description': 'invalid provider type' + } + LOGGER.error(exception) + return ({'Content-type': 'application/json'}, 400, + to_json(exception, self.pretty_print)) + except ProviderConnectionError: + exception = { + 'code': 'NoApplicableCode', + 'description': 'connection error (check logs)' + } + LOGGER.error(exception) + return headers_, 500, to_json(exception, self.pretty_print) + + if format_ == 'json': + return ({'Content-type': 'application/json'}, + 200, to_json(data, self.pretty_print)) + elif format_ == 'html': + data['id'] = dataset + data['title'] = self.config['resources'][dataset]['title'] + content = render_j2_template(self.config, 'rangetype.html', + data) + return {'Content-type': 'text/html'}, 200, content + else: + exception = { + 'code': 'InvalidParameterValue', + 'description': 'invalid format parameter' + } + LOGGER.error(exception) + return ({'Content-type': 'application/json'}, + 400, to_json(exception, self.pretty_print)) + + @pre_process + @jsonldify + def describe_processes(self, headers_, format_, process=None): + """ + Provide processes metadata + + :param headers: dict of HTTP headers + :param args: dict of HTTP request parameters + :param process: name of process + + :returns: tuple of headers, status code, content + """ + + if format_ is not None and format_ not in FORMATS: + exception = { + 'code': 'InvalidParameterValue', + 'description': 'Invalid format' + } + LOGGER.error(exception) + return headers_, 400, to_json(exception, self.pretty_print) + + processes_config = filter_dict_by_key_value(self.config['resources'], + 'type', 'process') + + if processes_config: + if process is not None: + if process not in processes_config.keys(): + exception = { + 'code': 'NotFound', + 'description': 'identifier not found' + } + LOGGER.error(exception) + return headers_, 404, to_json(exception, self.pretty_print) + + p = load_plugin('process', + processes_config[process]['processor']) + p.metadata['jobControlOptions'] = ['sync-execute'] + p.metadata['outputTransmission'] = ['value'] + response = p.metadata + else: + processes = [] + for k, v in processes_config.items(): + p = load_plugin('process', + processes_config[k]['processor']) + p.metadata['jobControlOptions'] = ['sync-execute'] + p.metadata['outputTransmission'] = ['value'] + processes.append(p.metadata) + response = { + 'processes': processes + } + else: + processes = [] + response = {'processes': processes} + + if format_ == 'html': # render + headers_['Content-Type'] = 'text/html' + if process is not None: + response = render_j2_template(self.config, 'process.html', + p.metadata) + else: + response = render_j2_template(self.config, 'processes.html', + {'processes': processes}) + + return headers_, 200, response + + return headers_, 200, to_json(response, self.pretty_print) + + def execute_process(self, headers, args, data, process): + """ + Execute process + + :param headers: dict of HTTP headers + :param args: dict of HTTP request parameters + :param data: process data + :param process: name of process + + :returns: tuple of headers, status code, content + """ + + headers_ = HEADERS.copy() + + data_dict = {} + response = {} + + if not data: + exception = { + 'code': 'MissingParameterValue', + 'description': 'missing request data' + } + LOGGER.error(exception) + return headers_, 400, to_json(exception, self.pretty_print) + + processes = filter_dict_by_key_value(self.config['resources'], + 'type', 'process') + + if process not in processes: + exception = { + 'code': 'NotFound', + 'description': 'identifier not found' + } + LOGGER.error(exception) + return headers_, 404, to_json(exception, self.pretty_print) + + p = load_plugin('process', + processes[process]['processor']) + + data_ = json.loads(data) + for input_ in data_['inputs']: + data_dict[input_['id']] = input_['value'] + + try: + outputs = p.execute(data_dict) + m = p.metadata + if 'response' in args and args['response'] == 'raw': + headers_['Content-Type'] = \ + m['outputs'][0]['output']['formats'][0]['mimeType'] + if 'json' in headers_['Content-Type']: + response = to_json(outputs) + else: + response = outputs + else: + response['outputs'] = outputs + response = to_json(response) + return headers_, 200, response + except Exception as err: + exception = { + 'code': 'InvalidParameterValue', + 'description': str(err) + } + LOGGER.error(exception) + return headers_, 400, to_json(exception, self.pretty_print) @pre_process @jsonldify @@ -1089,7 +1568,7 @@ class API: 'description': 'Invalid format' } LOGGER.error(exception) - return headers_, 400, json.dumps(exception) + return headers_, 400, to_json(exception, self.pretty_print) id_ = 'pygeoapi-stac' stac_version = '0.6.2' @@ -1129,7 +1608,7 @@ class API: content) return headers_, 200, content - return headers_, 200, json.dumps(content, default=json_serial) + return headers_, 200, to_json(content, self.pretty_print) @pre_process @jsonldify @@ -1141,7 +1620,7 @@ class API: 'description': 'Invalid format' } LOGGER.error(exception) - return headers_, 400, json.dumps(exception) + return headers_, 400, to_json(exception, self.pretty_print) LOGGER.debug('Path: {}'.format(path)) dir_tokens = path.split('/') @@ -1157,7 +1636,7 @@ class API: 'description': 'collection not found' } LOGGER.error(exception) - return headers_, 404, json.dumps(exception) + return headers_, 404, to_json(exception, self.pretty_print) LOGGER.debug('Loading provider') try: @@ -1170,7 +1649,7 @@ class API: 'description': 'connection error (check logs)' } LOGGER.error(exception) - return headers_, 500, json.dumps(exception) + return headers_, 500, to_json(exception, self.pretty_print) id_ = '{}-stac'.format(dataset) stac_version = '0.6.2' @@ -1195,14 +1674,14 @@ class API: 'code': 'NotFound', 'description': 'resource not found' } - return headers_, 404, json.dumps(exception) + return headers_, 404, to_json(exception, self.pretty_print) except Exception as err: LOGGER.error(err) exception = { 'code': 'NoApplicableCode', 'description': 'data query error' } - return headers_, 500, json.dumps(exception) + return headers_, 500, to_json(exception, self.pretty_print) if isinstance(stac_data, dict): content.update(stac_data) @@ -1222,145 +1701,12 @@ class API: return headers_, 200, content - return headers_, 200, json.dumps(content, default=json_serial) + return headers_, 200, to_json(content, self.pretty_print) else: # send back file headers_.pop('Content-Type', None) return headers_, 200, stac_data - @pre_process - @jsonldify - def describe_processes(self, headers_, format_, process=None): - """ - Provide processes metadata - - :param headers: dict of HTTP headers - :param args: dict of HTTP request parameters - :param process: name of process - - :returns: tuple of headers, status code, content - """ - - if format_ is not None and format_ not in FORMATS: - exception = { - 'code': 'InvalidParameterValue', - 'description': 'Invalid format' - } - LOGGER.error(exception) - return headers_, 400, json.dumps(exception) - - processes_config = filter_dict_by_key_value(self.config['resources'], - 'type', 'process') - - if processes_config: - if process is not None: - if process not in processes_config.keys(): - exception = { - 'code': 'NotFound', - 'description': 'identifier not found' - } - LOGGER.error(exception) - return headers_, 404, json.dumps(exception) - - p = load_plugin('process', - processes_config[process]['processor']) - p.metadata['jobControlOptions'] = ['sync-execute'] - p.metadata['outputTransmission'] = ['value'] - response = p.metadata - else: - processes = [] - for k, v in processes_config.items(): - p = load_plugin('process', - processes_config[k]['processor']) - p.metadata['itemType'] = 'process' - p.metadata['jobControlOptions'] = ['sync-execute'] - p.metadata['outputTransmission'] = ['value'] - processes.append(p.metadata) - response = { - 'processes': processes - } - else: - processes = [] - response = {'processes': processes} - - if format_ == 'html': # render - headers_['Content-Type'] = 'text/html' - if process is not None: - response = render_j2_template(self.config, 'process.html', - p.metadata) - else: - response = render_j2_template(self.config, 'processes.html', - {'processes': processes}) - - return headers_, 200, response - - return headers_, 200, json.dumps(response) - - def execute_process(self, headers, args, data, process): - """ - Execute process - - :param headers: dict of HTTP headers - :param args: dict of HTTP request parameters - :param data: process data - :param process: name of process - - :returns: tuple of headers, status code, content - """ - - headers_ = HEADERS.copy() - - data_dict = {} - response = {} - - if not data: - exception = { - 'code': 'MissingParameterValue', - 'description': 'missing request data' - } - LOGGER.error(exception) - return headers_, 400, json.dumps(exception) - - processes = filter_dict_by_key_value(self.config['resources'], - 'type', 'process') - - if process not in processes: - exception = { - 'code': 'NotFound', - 'description': 'identifier not found' - } - LOGGER.error(exception) - return headers_, 404, json.dumps(exception) - - p = load_plugin('process', - processes[process]['processor']) - - data_ = json.loads(data) - for input_ in data_['inputs']: - data_dict[input_['id']] = input_['value'] - - try: - outputs = p.execute(data_dict) - m = p.metadata - if 'response' in args and args['response'] == 'raw': - headers_['Content-Type'] = \ - m['outputs'][0]['output']['formats'][0]['mimeType'] - if 'json' in headers_['Content-Type']: - response = to_json(outputs) - else: - response = outputs - else: - response['outputs'] = outputs - response = to_json(response) - return headers_, 200, response - except Exception as err: - exception = { - 'code': 'InvalidParameterValue', - 'description': str(err) - } - LOGGER.error(exception) - return headers_, 400, json.dumps(exception) - def check_format(args, headers): """ diff --git a/pygeoapi/flask_app.py b/pygeoapi/flask_app.py index cc2ef06..c766db2 100644 --- a/pygeoapi/flask_app.py +++ b/pygeoapi/flask_app.py @@ -217,6 +217,69 @@ def collection_items(collection_id, item_id=None): return response +@APP.route('/collections//coverage') +def collection_coverage(collection_id): + """ + OGC API - Coverages coverage endpoint + + :param collection_id: collection identifier + + :returns: HTTP response + """ + + headers, status_code, content = api_.get_collection_coverage( + request.headers, request.args, collection_id) + + response = make_response(content, status_code) + + if headers: + response.headers = headers + + return response + + +@APP.route('/collections//coverage/domainset') +def collection_coverage_domainset(collection_id): + """ + OGC API - Coverages coverage domainset endpoint + + :param collection_id: collection identifier + + :returns: HTTP response + """ + + headers, status_code, content = api_.get_collection_coverage_domainset( + request.headers, request.args, collection_id) + + response = make_response(content, status_code) + + if headers: + response.headers = headers + + return response + + +@APP.route('/collections//coverage/rangetype') +def collection_coverage_rangetype(collection_id): + """ + OGC API - Coverages coverage rangetype endpoint + + :param collection_id: collection identifier + + :returns: HTTP response + """ + + headers, status_code, content = api_.get_collection_coverage_rangetype( + request.headers, request.args, collection_id) + + response = make_response(content, status_code) + + if headers: + response.headers = headers + + return response + + @APP.route('/processes') @APP.route('/processes/') def processes(process_id=None): diff --git a/pygeoapi/openapi.py b/pygeoapi/openapi.py index 8ce6d81..48b42f6 100644 --- a/pygeoapi/openapi.py +++ b/pygeoapi/openapi.py @@ -35,6 +35,7 @@ import click import yaml from pygeoapi.plugin import load_plugin +from pygeoapi.provider.base import ProviderTypeError from pygeoapi.util import (filter_dict_by_key_value, get_provider_by_type, yaml_load) @@ -42,7 +43,9 @@ LOGGER = logging.getLogger(__name__) OPENAPI_YAML = { 'oapif': 'http://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml', # noqa - 'oapip': 'https://raw.githubusercontent.com/opengeospatial/wps-rest-binding/master/core/openapi' # noqa + 'oapip': 'https://raw.githubusercontent.com/opengeospatial/wps-rest-binding/master/core/openapi', # noqa +# 'oacov': 'https://raw.githubusercontent.com/opengeospatial/OGC-API-Sprint-August-2020/master/docs/Draft_Spring_Guide_for_OGC_API_Coverages/openapi' # noqa + 'oacov': 'https://raw.githubusercontent.com/tomkralidis/ogcapi-coverages-1/fix-cis/yaml-unresolved' # noqa } @@ -384,47 +387,28 @@ def get_oas_30(cfg): } } - items_path = '{}/items'.format(collection_name_path) + LOGGER.debug('setting up feature endpoints') + try: + p = load_plugin('provider', get_provider_by_type( + collections[k]['providers'], 'feature')) - paths[items_path] = { - 'get': { - 'summary': 'Get {} items'.format(v['title']), - 'description': v['description'], - 'tags': [k], - 'operationId': 'get{}Features'.format(k.capitalize()), - 'parameters': [ - items_f, - {'$ref': '{}#/components/parameters/bbox'.format(OPENAPI_YAML['oapif'])}, # noqa - {'$ref': '{}#/components/parameters/limit'.format(OPENAPI_YAML['oapif'])}, # noqa - {'$ref': '#/components/parameters/sortby'}, - {'$ref': '#/components/parameters/startindex'} - ], - 'responses': { - '200': {'$ref': '{}#/components/responses/Features'.format(OPENAPI_YAML['oapif'])}, # noqa - '400': {'$ref': '{}#/components/responses/InvalidParameter'.format(OPENAPI_YAML['oapif'])}, # noqa - '404': {'$ref': '{}#/components/responses/NotFound'.format(OPENAPI_YAML['oapif'])}, # noqa - '500': {'$ref': '{}#/components/responses/ServerError'.format(OPENAPI_YAML['oapif'])} # noqa - } - } - } + items_path = '{}/items'.format(collection_name_path) - p = load_plugin('provider', get_provider_by_type( - collections[k]['providers'], 'feature')) - - if p.fields: - queryables_path = '{}/queryables'.format(collection_name_path) - - paths[queryables_path] = { + paths[items_path] = { 'get': { - 'summary': 'Get {} queryables'.format(v['title']), + 'summary': 'Get {} items'.format(v['title']), 'description': v['description'], 'tags': [k], - 'operationId': 'get{}Queryables'.format(k.capitalize()), + 'operationId': 'get{}Features'.format(k.capitalize()), 'parameters': [ items_f, + {'$ref': '{}#/components/parameters/bbox'.format(OPENAPI_YAML['oapif'])}, # noqa + {'$ref': '{}#/components/parameters/limit'.format(OPENAPI_YAML['oapif'])}, # noqa + {'$ref': '#/components/parameters/sortby'}, + {'$ref': '#/components/parameters/startindex'} ], 'responses': { - '200': {'$ref': '#/components/responses/Queryables'}, + '200': {'$ref': '{}#/components/responses/Features'.format(OPENAPI_YAML['oapif'])}, # noqa '400': {'$ref': '{}#/components/responses/InvalidParameter'.format(OPENAPI_YAML['oapif'])}, # noqa '404': {'$ref': '{}#/components/responses/NotFound'.format(OPENAPI_YAML['oapif'])}, # noqa '500': {'$ref': '{}#/components/responses/ServerError'.format(OPENAPI_YAML['oapif'])} # noqa @@ -432,64 +416,159 @@ def get_oas_30(cfg): } } - if p.time_field is not None: - paths[items_path]['get']['parameters'].append( - {'$ref': '{}#/components/parameters/datetime'.format(OPENAPI_YAML['oapif'])}) # noqa + if p.fields: + queryables_path = '{}/queryables'.format(collection_name_path) - for field, type in p.fields.items(): - - if p.properties and field not in p.properties: - LOGGER.debug('Provider specified not to advertise property') - continue - - if type == 'date': - schema = { - 'type': 'string', - 'format': 'date' - } - elif type == 'float': - schema = { - 'type': 'number', - 'format': 'float' - } - elif type == 'long': - schema = { - 'type': 'integer', - 'format': 'int64' - } - else: - schema = { - 'type': type + paths[queryables_path] = { + 'get': { + 'summary': 'Get {} queryables'.format(v['title']), + 'description': v['description'], + 'tags': [k], + 'operationId': 'get{}Queryables'.format( + k.capitalize()), + 'parameters': [ + items_f, + ], + 'responses': { + '200': {'$ref': '#/components/responses/Queryables'}, # noqa + '400': {'$ref': '{}#/components/responses/InvalidParameter'.format(OPENAPI_YAML['oapif'])}, # noqa + '404': {'$ref': '{}#/components/responses/NotFound'.format(OPENAPI_YAML['oapif'])}, # noqa + '500': {'$ref': '{}#/components/responses/ServerError'.format(OPENAPI_YAML['oapif'])} # noqa + } + } } - path_ = '{}/items'.format(collection_name_path) - paths['{}'.format(path_)]['get']['parameters'].append({ - 'name': field, - 'in': 'query', - 'required': False, - 'schema': schema, - 'style': 'form', - 'explode': False - }) + if p.time_field is not None: + paths[items_path]['get']['parameters'].append( + {'$ref': '{}#/components/parameters/datetime'.format(OPENAPI_YAML['oapif'])}) # noqa - paths['{}/items/{{featureId}}'.format(collection_name_path)] = { - 'get': { - 'summary': 'Get {} item by id'.format(v['title']), - 'description': v['description'], - 'tags': [k], - 'operationId': 'get{}Feature'.format(k.capitalize()), - 'parameters': [ - {'$ref': '{}#/components/parameters/featureId'.format(OPENAPI_YAML['oapif'])}, # noqa - {'$ref': '#/components/parameters/f'} - ], - 'responses': { - '200': {'$ref': '{}#/components/responses/Feature'.format(OPENAPI_YAML['oapif'])}, # noqa - '400': {'$ref': '{}#/components/responses/InvalidParameter'.format(OPENAPI_YAML['oapif'])}, # noqa - '404': {'$ref': '{}#/components/responses/NotFound'.format(OPENAPI_YAML['oapif'])}, # noqa - '500': {'$ref': '{}#/components/responses/ServerError'.format(OPENAPI_YAML['oapif'])} # noqa + for field, type in p.fields.items(): + + if p.properties and field not in p.properties: + LOGGER.debug('Provider specified not to advertise property') # noqa + continue + + if type == 'date': + schema = { + 'type': 'string', + 'format': 'date' + } + elif type == 'float': + schema = { + 'type': 'number', + 'format': 'float' + } + elif type == 'long': + schema = { + 'type': 'integer', + 'format': 'int64' + } + else: + schema = { + 'type': type + } + + path_ = '{}/items'.format(collection_name_path) + paths['{}'.format(path_)]['get']['parameters'].append({ + 'name': field, + 'in': 'query', + 'required': False, + 'schema': schema, + 'style': 'form', + 'explode': False + }) + + paths['{}/items/{{featureId}}'.format(collection_name_path)] = { + 'get': { + 'summary': 'Get {} item by id'.format(v['title']), + 'description': v['description'], + 'tags': [k], + 'operationId': 'get{}Feature'.format(k.capitalize()), + 'parameters': [ + {'$ref': '{}#/components/parameters/featureId'.format(OPENAPI_YAML['oapif'])}, # noqa + {'$ref': '#/components/parameters/f'} + ], + 'responses': { + '200': {'$ref': '{}#/components/responses/Feature'.format(OPENAPI_YAML['oapif'])}, # noqa + '400': {'$ref': '{}#/components/responses/InvalidParameter'.format(OPENAPI_YAML['oapif'])}, # noqa + '404': {'$ref': '{}#/components/responses/NotFound'.format(OPENAPI_YAML['oapif'])}, # noqa + '500': {'$ref': '{}#/components/responses/ServerError'.format(OPENAPI_YAML['oapif'])} # noqa + } } } - } + except ProviderTypeError: + LOGGER.debug('collection is not feature based') + + LOGGER.debug('setting up coverage endpoints') + try: + load_plugin('provider', get_provider_by_type( + collections[k]['providers'], 'coverage')) + + coverage_path = '{}/coverage'.format(collection_name_path) + + paths[coverage_path] = { + 'get': { + 'summary': 'Get {} coverage'.format(v['title']), + 'description': v['description'], + 'tags': [k], + 'operationId': 'get{}Coverage'.format(k.capitalize()), + 'parameters': [ + items_f, + ], + 'responses': { + '200': {'$ref': '{}#/components/responses/Features'.format(OPENAPI_YAML['oapif'])}, # noqa + '400': {'$ref': '{}#/components/responses/InvalidParameter'.format(OPENAPI_YAML['oapif'])}, # noqa + '404': {'$ref': '{}#/components/responses/NotFound'.format(OPENAPI_YAML['oapif'])}, # noqa + '500': {'$ref': '{}#/components/responses/ServerError'.format(OPENAPI_YAML['oapif'])} # noqa + } + } + } + + coverage_domainset_path = '{}/coverage/domainset'.format( + collection_name_path) + + paths[coverage_domainset_path] = { + 'get': { + 'summary': 'Get {} coverage domain set'.format(v['title']), + 'description': v['description'], + 'tags': [k], + 'operationId': 'get{}CoverageDomainSet'.format( + k.capitalize()), + 'parameters': [ + items_f, + ], + 'responses': { + '200': {'$ref': '{}/schemas/cis_1.1/domainSet.yaml'.format(OPENAPI_YAML['oacov'])}, # noqa + '400': {'$ref': '{}#/components/responses/InvalidParameter'.format(OPENAPI_YAML['oapif'])}, # noqa + '404': {'$ref': '{}#/components/responses/NotFound'.format(OPENAPI_YAML['oapif'])}, # noqa + '500': {'$ref': '{}#/components/responses/ServerError'.format(OPENAPI_YAML['oapif'])} # noqa + } + } + } + + coverage_rangetype_path = '{}/coverage/rangetype'.format( + collection_name_path) + + paths[coverage_rangetype_path] = { + 'get': { + 'summary': 'Get {} coverage range type'.format(v['title']), + 'description': v['description'], + 'tags': [k], + 'operationId': 'get{}CoverageRangeType'.format( + k.capitalize()), + 'parameters': [ + items_f, + ], + 'responses': { + '200': {'$ref': '{}/schemas/cis_1.1/rangeType.yaml'.format(OPENAPI_YAML['oacov'])}, # noqa + '400': {'$ref': '{}#/components/responses/InvalidParameter'.format(OPENAPI_YAML['oapif'])}, # noqa + '404': {'$ref': '{}#/components/responses/NotFound'.format(OPENAPI_YAML['oapif'])}, # noqa + '500': {'$ref': '{}#/components/responses/ServerError'.format(OPENAPI_YAML['oapif'])} # noqa + } + } + } + except ProviderTypeError: + LOGGER.debug('collection is not coverage based') LOGGER.debug('setting up STAC') stac_collections = filter_dict_by_key_value(cfg['resources'], diff --git a/pygeoapi/plugin.py b/pygeoapi/plugin.py index 999eca3..94983ef 100644 --- a/pygeoapi/plugin.py +++ b/pygeoapi/plugin.py @@ -44,7 +44,8 @@ PLUGINS = { 'PostgreSQL': 'pygeoapi.provider.postgresql.PostgreSQLProvider', 'SQLiteGPKG': 'pygeoapi.provider.sqlite.SQLiteGPKGProvider', 'MongoDB': 'pygeoapi.provider.mongo.MongoProvider', - 'FileSystem': 'pygeoapi.provider.filesystem.FileSystemProvider' + 'FileSystem': 'pygeoapi.provider.filesystem.FileSystemProvider', + 'rasterio': 'pygeoapi.provider.rasterio_.RasterioProvider' }, 'formatter': { 'CSV': 'pygeoapi.formatter.csv_.CSVFormatter' diff --git a/pygeoapi/provider/base.py b/pygeoapi/provider/base.py index 6a5de38..7e4a3a3 100644 --- a/pygeoapi/provider/base.py +++ b/pygeoapi/provider/base.py @@ -44,14 +44,25 @@ class BaseProvider: :returns: pygeoapi.providers.base.BaseProvider """ - self.name = provider_def['name'] - self.data = provider_def['data'] + try: + self.name = provider_def['name'] + self.type = provider_def['type'] + self.data = provider_def['data'] + except KeyError: + raise RuntimeError('name/type/data are required') + + self.options = provider_def.get('options', None) self.id_field = provider_def.get('id_field', None) self.time_field = provider_def.get('time_field') self.properties = provider_def.get('properties', []) self.file_types = provider_def.get('file_types', []) self.fields = {} + # for coverage providers + self.axes = [] + self.crs = None + self.num_bands = None + def get_fields(self): """ Get provider field information (names, types) @@ -74,11 +85,21 @@ class BaseProvider: raise NotImplementedError() + def get_metadata(self): + """ + Provide data/file metadata + + :returns: `dict` of metadata construct (format + determined by provider/standard) + """ + + raise NotImplementedError() + def query(self): """ query the provider - :returns: dict of 0..n GeoJSON features + :returns: dict of 0..n GeoJSON features or coverage data """ raise NotImplementedError() @@ -108,6 +129,24 @@ class BaseProvider: raise NotImplementedError() + def get_coverage_domainset(self): + """ + Provide coverage domainset + + :returns: CIS JSON object of domainset metadata + """ + + raise NotImplementedError() + + def get_coverage_rangetype(self): + """ + Provide coverage rangetype + + :returns: CIS JSON object of rangetype metadata + """ + + raise NotImplementedError() + def delete(self, identifier): """Deletes an existing feature @@ -130,13 +169,23 @@ class ProviderConnectionError(ProviderGenericError): pass +class ProviderTypeError(ProviderGenericError): + """provider type error""" + pass + + +class ProviderInvalidQueryError(ProviderGenericError): + """provider invalid query error""" + pass + + class ProviderQueryError(ProviderGenericError): """provider query error""" pass class ProviderItemNotFoundError(ProviderGenericError): - """provider query error""" + """provider item not found query error""" pass diff --git a/pygeoapi/provider/rasterio_.py b/pygeoapi/provider/rasterio_.py new file mode 100644 index 0000000..3542e4c --- /dev/null +++ b/pygeoapi/provider/rasterio_.py @@ -0,0 +1,420 @@ +# ================================================================= +# +# Authors: Tom Kralidis +# +# Copyright (c) 2020 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 io +import logging + +import rasterio +from rasterio.io import MemoryFile +import rasterio.mask + +from pygeoapi.provider.base import (BaseProvider, ProviderConnectionError, + ProviderQueryError) + +LOGGER = logging.getLogger(__name__) + + +class RasterioProvider(BaseProvider): + """Rasterio Provider""" + + def __init__(self, provider_def): + """ + Initialize object + + :param provider_def: provider definition + + :returns: pygeoapi.providers.rasterio_.RasterioProvider + """ + + BaseProvider.__init__(self, provider_def) + + try: + self._data = rasterio.open(self.data) + self._coverage_properties = self._get_coverage_properties() + self.axes = self._coverage_properties['axes'] + self.crs = self._coverage_properties['bbox_crs'] + self.num_bands = self._coverage_properties['num_bands'] + except Exception as err: + LOGGER.warning(err) + raise ProviderConnectionError(err) + + def get_coverage_domainset(self): + """ + Provide coverage domainset + + :returns: CIS JSON object of domainset metadata + """ + + domainset = { + 'type': 'DomainSetType', + 'generalGrid': { + 'type': 'GeneralGridCoverageType', + 'srsName': self._coverage_properties['bbox_crs'], + 'axisLabels': [ + self._coverage_properties['x_axis_label'], + self._coverage_properties['y_axis_label'] + ], + 'axis': [{ + 'type': 'RegularAxisType', + 'axisLabel': self._coverage_properties['x_axis_label'], + 'lowerBound': self._coverage_properties['bbox'][0], + 'upperBound': self._coverage_properties['bbox'][2], + 'uomLabel': self._coverage_properties['bbox_units'], + 'resolution': self._coverage_properties['resx'] + }, { + 'type': 'RegularAxisType', + 'axisLabel': self._coverage_properties['y_axis_label'], + 'lowerBound': self._coverage_properties['bbox'][1], + 'upperBound': self._coverage_properties['bbox'][3], + 'uomLabel': self._coverage_properties['bbox_units'], + 'resolution': self._coverage_properties['resy'] + }], + 'gridLimits': { + 'type': 'GridLimitsType', + 'srsName': 'http://www.opengis.net/def/crs/OGC/0/Index2D', + 'axisLabels': ['i', 'j'], + 'axis': [{ + 'type': 'IndexAxisType', + 'axisLabel': 'i', + 'lowerBound': 0, + 'upperBound': self._coverage_properties['width'] + }, { + 'type': 'IndexAxisType', + 'axisLabel': 'j', + 'lowerBound': 0, + 'upperBound': self._coverage_properties['height'] + }] + } + }, + '_meta': { + 'tags': self._coverage_properties['tags'] + } + } + + return domainset + + def get_coverage_rangetype(self): + """ + Provide coverage rangetype + + :returns: CIS JSON object of rangetype metadata + """ + + rangetype = { + 'type': 'DataRecordType', + 'field': [] + } + + for i, dtype, nodataval in zip(self._data.indexes, self._data.dtypes, + self._data.nodatavals): + LOGGER.debug('Determing rangetype for band {}'.format(i)) + + name, units = None, None + if self._data.units[i-1] is None: + parameter = _get_parameter_metadata( + self._data.profile['driver'], self._data.tags(i)) + name = parameter['description'] + units = parameter['unit_label'] + + rangetype['field'].append({ + 'id': i, + 'type': 'QuantityType', + 'name': name, + 'definition': dtype, + 'nodata': nodataval, + 'uom': { + 'id': 'http://www.opengis.net/def/uom/UCUM/{}'.format( + units), + 'type': 'UnitReference', + 'code': units + }, + '_meta': { + 'tags': self._data.tags(i) + } + }) + + return rangetype + + def query(self, bands=[], subsets={}, format_='json'): + """ + Extract data from collection collection + + :param bands: list of bands (int) + :param subsets: dict of subset names with lists of ranges + + :returns: coverage data as dict of CoverageJSON or native format + """ + + LOGGER.debug('Bands: {}, subsets: {}'.format(bands, subsets)) + + args = { + 'indexes': None + } + shapes = [] + + if not bands and not subsets and format_ != 'json': + LOGGER.debug('No parameters specified, returning native file') + with io.open(self.data, 'rb') as fh: + return fh.read() + + if (self._coverage_properties['x_axis_label'] in subsets and + self._coverage_properties['y_axis_label'] in subsets): + LOGGER.debug('Creating spatial subset') + + x = self._coverage_properties['x_axis_label'] + y = self._coverage_properties['y_axis_label'] + + shapes = [{ + 'type': 'Polygon', + 'coordinates': [[ + [subsets[x][0], subsets[y][0]], + [subsets[x][0], subsets[y][1]], + [subsets[x][1], subsets[y][1]], + [subsets[x][1], subsets[y][0]], + [subsets[x][0], subsets[y][0]] + ]] + }] + + if bands: + LOGGER.debug('Selecting bands') + args['indexes'] = list(map(int, bands)) + + with rasterio.open(self.data) as _data: + LOGGER.debug('Creating output coverage metadata') + out_meta = _data.meta + + if self.options is not None: + LOGGER.debug('Adding dataset options') + for key, value in self.options.items(): + out_meta[key] = value + + if shapes: # spatial subset + LOGGER.debug('Clipping data with bbox') + out_image, out_transform = rasterio.mask.mask( + _data, + filled=False, + shapes=shapes, + crop=True, + indexes=args['indexes']) + + out_meta.update({"driver": "GRIB", + "height": out_image.shape[1], + "width": out_image.shape[2], + "transform": out_transform}) + else: # no spatial subset + LOGGER.debug('Creating data in memory with band selection') + out_image = _data.read(indexes=args['indexes']) + + if shapes: + out_meta['bbox'] = [ + subsets[x][0], subsets[y][0], + subsets[x][1], subsets[y][1] + ] + else: + out_meta['bbox'] = [ + _data.bounds.left, + _data.bounds.bottom, + _data.bounds.right, + _data.bounds.top + ] + + out_meta['units'] = _data.units + + LOGGER.debug('Serializing data in memory') + with MemoryFile() as memfile: + with memfile.open(**out_meta) as dest: + dest.write(out_image) + + if format_ == 'json': + LOGGER.debug('Creating output in CoverageJSON') + out_meta['bands'] = args['indexes'] + return self.gen_covjson(out_meta, out_image) + + else: # return data in native format + LOGGER.debug('Returning data in native format') + return memfile.read() + + def gen_covjson(self, metadata, data): + """ + Generate coverage as CoverageJSON representation + + :param metadata: coverage metadata + :param data: rasterio DatasetReader object + + :returns: dict of CoverageJSON representation + """ + + LOGGER.debug('Creating CoverageJSON domain') + minx, miny, maxx, maxy = metadata['bbox'] + + cj = { + 'type': 'Coverage', + 'domain': { + 'type': 'Domain', + 'domainType': 'Grid', + 'axes': { + 'x': { + 'start': minx, + 'stop': maxx, + 'num': metadata['width'] + }, + 'y': { + 'start': maxy, + 'stop': miny, + 'num': metadata['height'] + } + }, + 'referencing': [{ + 'coordinates': ['x', 'y'], + 'system': { + 'type': self._coverage_properties['crs_type'], + 'id': self._coverage_properties['bbox_crs'] + } + }] + }, + 'parameters': {}, + 'ranges': {} + } + + if metadata['bands'] is None: # all bands + bands_select = range(1, len(self._data.dtypes) + 1) + else: + bands_select = metadata['bands'] + + LOGGER.debug('bands selected: {}'.format(bands_select)) + for bs in bands_select: + pm = _get_parameter_metadata( + self._data.profile['driver'], self._data.tags(bs)) + + parameter = { + 'type': 'Parameter', + 'description': pm['description'], + 'unit': { + 'symbol': pm['unit_label'] + }, + 'observedProperty': { + 'id': pm['observed_property_id'], + 'label': { + 'en': pm['observed_property_name'] + } + } + } + + cj['parameters'][pm['id']] = parameter + + try: + for key in cj['parameters'].keys(): + cj['ranges'][key] = { + 'type': 'NdArray', + # 'dataType': metadata.dtypes[0], + 'dataType': 'float', + 'axisNames': ['y', 'x'], + 'shape': [metadata['height'], metadata['width']], + } + # TODO: deal with multi-band value output + cj['ranges'][key]['values'] = data.flatten().tolist() + except IndexError as err: + LOGGER.warning(err) + raise ProviderQueryError('Invalid query parameter') + + return cj + + def _get_coverage_properties(self): + """ + Helper function to normalize coverage properties + + :returns: `dict` of coverage properties + """ + + properties = { + 'bbox': [ + self._data.bounds.left, + self._data.bounds.bottom, + self._data.bounds.right, + self._data.bounds.top + ], + 'bbox_crs': 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', + 'crs_type': 'GeographicCRS', + 'bbox_units': 'deg', + 'x_axis_label': 'Long', + 'y_axis_label': 'Lat', + 'width': self._data.width, + 'height': self._data.height, + 'resx': self._data.res[0], + 'resy': self._data.res[1], + 'num_bands': self._data.count, + 'tags': self._data.tags() + } + + if self._data.crs is not None: + if self._data.crs.is_projected: + properties['bbox_crs'] = '{}/{}'.format( + 'http://www.opengis.net/def/crs/OGC/1.3/', + self._data.crs.to_epsg()) + + properties['x_axis_label'] = 'x' + properties['y_axis_label'] = 'y' + properties['bbox_units'] = self._data.crs.linear_units + properties['crs_type'] = 'ProjectedCRS' + + properties['axes'] = [ + properties['x_axis_label'], properties['y_axis_label'] + ] + + return properties + + +def _get_parameter_metadata(driver, band): + """ + Helper function to derive parameter name and units + + :param driver: rasterio/GDAL driver name + :param band: int of band number + + :returns: dict of parameter metadata + """ + + parameter = { + 'id': None, + 'description': None, + 'unit_label': None, + 'unit_symbol': None, + 'observed_property_id': None, + 'observed_property_name': None + } + + if driver == 'GRIB': + parameter['id'] = band['GRIB_ELEMENT'] + parameter['description'] = band['GRIB_COMMENT'] + parameter['unit_label'] = band['GRIB_UNIT'] + parameter['unit_symbol'] = band['GRIB_UNIT'] + parameter['observed_property_id'] = band['GRIB_SHORT_NAME'] + parameter['observed_property_name'] = band['GRIB_COMMENT'] + + return parameter diff --git a/pygeoapi/starlette_app.py b/pygeoapi/starlette_app.py index f3ce3b6..e410eb4 100644 --- a/pygeoapi/starlette_app.py +++ b/pygeoapi/starlette_app.py @@ -212,6 +212,69 @@ async def collection_items(request: Request, collection_id=None, item_id=None): return response +@app.route('/collections//coverage') +def collection_coverage(request: Request, collection_id): + """ + OGC API - Coverages coverage endpoint + + :param collection_id: collection identifier + + :returns: Starlette HTTP Response + """ + + headers, status_code, content = api_.get_collection_coverage( + request.headers, request.query_params, collection_id) + + response = Response(content=content, status_code=status_code) + + if headers: + response.headers.update(headers) + + return response + + +@app.route('/collections//coverage/domainset') +def collection_coverage_domainset(request: Request, collection_id): + """ + OGC API - Coverages coverage domainset endpoint + + :param collection_id: collection identifier + + :returns: Starlette HTTP Response + """ + + headers, status_code, content = api_.get_collection_coverage_domainset( + request.headers, request.query_params, collection_id) + + response = Response(content=content, status_code=status_code) + + if headers: + response.headers.update(headers) + + return response + + +@app.route('/collections//coverage/rangetype') +def collection_coverage_rangetype(request: Request, collection_id): + """ + OGC API - Coverages coverage rangetype endpoint + + :param collection_id: collection identifier + + :returns: Starlette HTTP Response + """ + + headers, status_code, content = api_.get_collection_coverage_rangetype( + request.headers, request.query_params, collection_id) + + response = Response(content=content, status_code=status_code) + + if headers: + response.headers.update(headers) + + return response + + @app.route('/processes') @app.route('/processes/') @app.route('/processes/{process_id}') diff --git a/pygeoapi/templates/collection.html b/pygeoapi/templates/collection.html index d744d7f..5c5f553 100644 --- a/pygeoapi/templates/collection.html +++ b/pygeoapi/templates/collection.html @@ -26,7 +26,7 @@ + Display Queryables of "{{ data['title'] }}"

View

@@ -35,7 +35,7 @@ + Browse through the items of "{{ data['title'] }}" {% endif %} diff --git a/pygeoapi/templates/domainset.html b/pygeoapi/templates/domainset.html new file mode 100644 index 0000000..85b50da --- /dev/null +++ b/pygeoapi/templates/domainset.html @@ -0,0 +1,43 @@ +{% extends "base.html" %} +{% block title %}{{ super() }} {{ data['title'] }} {% endblock %} +{% block crumbs %}{{ super() }} +/ Collections +/ {{ data['title'] }} +{% endblock %} +{% block body %} +
+ + + + + +

{{ data['title'] }}

+ +

{{ data['description'] }}

+

Coverage domain set

+

Axis labels

+
    + {% for al in data['generalGrid']['axisLabels'] %} +
  • {{ al }}
  • + {% endfor %} +
+

Extent

+
    +
  • minx: {{ data['generalGrid']['axis'][0]['lowerBound'] }}
  • +
  • miny: {{ data['generalGrid']['axis'][1]['lowerBound'] }}
  • +
  • maxx: {{ data['generalGrid']['axis'][0]['upperBound'] }}
  • +
  • maxy: {{ data['generalGrid']['axis'][1]['upperBound'] }}
  • +
  • Coordinate reference system: {{ data['generalGrid']['srsName'] }}
  • +
+

Size

+
    +
  • width: {{ data['generalGrid']['gridLimits']['axis'][0]['upperBound'] }}
  • +
  • height: {{ data['generalGrid']['gridLimits']['axis'][1]['upperBound'] }}
  • +
+

Resolution

+
    +
  • x: {{ data['generalGrid']['axis'][0]['resolution'] }}
  • +
  • y: {{ data['generalGrid']['axis'][1]['resolution'] }}
  • +
+
+{% endblock %} diff --git a/pygeoapi/templates/rangetype.html b/pygeoapi/templates/rangetype.html new file mode 100644 index 0000000..0387c15 --- /dev/null +++ b/pygeoapi/templates/rangetype.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} +{% block title %}{{ super() }} {{ data['title'] }} {% endblock %} +{% block crumbs %}{{ super() }} +/ Collections +/ {{ data['title'] }} +{% endblock %} +{% block body %} +
+ + + + + +

{{ data['title'] }}

+ +

{{ data['description'] }}

+

Coverage range type

+

Fields

+
    + {% for field in data['field'] %} +
  • {{ field['id'] }}: {{ field['name'] }} ({{ field['definition'] }})
  • + {% endfor %} +
+
+{% endblock %} diff --git a/pygeoapi/util.py b/pygeoapi/util.py index 8bbcf28..8419f9c 100644 --- a/pygeoapi/util.py +++ b/pygeoapi/util.py @@ -43,6 +43,7 @@ from jinja2 import Environment, FileSystemLoader import yaml from pygeoapi import __version__ +from pygeoapi.provider.base import ProviderTypeError LOGGER = logging.getLogger(__name__) @@ -142,16 +143,22 @@ def str2bool(value): return value2 -def to_json(dict_): +def to_json(dict_, pretty=False): """ Serialize dict to json :param dict_: `dict` of JSON representation + :param pretty: `bool` of whether to prettify JSON (default is `False`) :returns: JSON string representation """ - return json.dumps(dict_, default=json_serial) + if pretty: + indent = 4 + else: + indent = None + + return json.dumps(dict_, default=json_serial, indent=indent) def get_path_basename(urlpath): @@ -302,8 +309,8 @@ def get_provider_by_type(providers, provider_type): try: p = (next(d for i, d in enumerate(providers) if d['type'] == provider_type)) - except StopIteration: - raise RuntimeError('Cannot find provider type') + except (RuntimeError, StopIteration): + raise ProviderTypeError('Invalid provider type requested') return p diff --git a/requirements.txt b/requirements.txt index 9e247b7..c6b3836 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,5 @@ Flask python-dateutil pytz PyYAML +rasterio unicodecsv diff --git a/tests/data/CMC_glb_TMP_TGL_2_latlon.15x.15_2020081000_P000.grib2 b/tests/data/CMC_glb_TMP_TGL_2_latlon.15x.15_2020081000_P000.grib2 new file mode 100644 index 0000000..c3b2265 Binary files /dev/null and b/tests/data/CMC_glb_TMP_TGL_2_latlon.15x.15_2020081000_P000.grib2 differ diff --git a/tests/data/README.md b/tests/data/README.md index 84949a0..e6b779c 100644 --- a/tests/data/README.md +++ b/tests/data/README.md @@ -7,23 +7,23 @@ This directory provides test data to demonstrate functionality. ### `ne_110m_lakes.geojson` - source: Natural Earth Lakes + Reservoirs -- URL: [http://www.naturalearthdata.com/downloads/110m-physical-vectors/110mlakes-reservoirs/](http://www.naturalearthdata.com/downloads/110m-physical-vectors/110mlakes-reservoirs/) +- URL: [https:/naturalearthdata.com/downloads/110m-physical-vectors/110mlakes-reservoirs/](https://naturalearthdata.com/downloads/110m-physical-vectors/110mlakes-reservoirs/) - Shapefile converted to GeoJSON -- Made with Natural Earth. Free vector and raster map data @ [naturalearthdata.com](http://naturalearthdata.com) +- Made with Natural Earth. Free vector and raster map data @ [naturalearthdata.com](https://naturalearthdata.com) ### `ne_110m_admin_0_countries.sqlite` - source: Natural Earth Admin 0 - Countries -- URL: [http://www.naturalearthdata.com/downloads/110m-cultural-vectors/110m-admin-0-countries/](http://www.naturalearthdata.com/downloads/110m-cultural-vectors/110m-admin-0-countries/) +- URL: [https://naturalearthdata.com/downloads/110m-cultural-vectors/110m-admin-0-countries/](https://naturalearthdata.com/downloads/110m-cultural-vectors/110m-admin-0-countries/) - Shapefile converted to SQLite -- Made with Natural Earth. Free vector and raster map data @ [naturalearthdata.com](http://naturalearthdata.com) +- Made with Natural Earth. Free vector and raster map data @ [naturalearthdata.com](https://naturalearthdata.com) ### `ne_110m_populated_places_simple.geojson` - source: Natural Earth Populated Places -- URL: [http://www.naturalearthdata.com/downloads/110m-cultural-vectors/110m-populated-places/](http://www.naturalearthdata.com/downloads/110m-cultural-vectors/110m-populated-places/) +- URL: [https://naturalearthdata.com/downloads/110m-cultural-vectors/110m-populated-places/](https://naturalearthdata.com/downloads/110m-cultural-vectors/110m-populated-places/) - Shapefile converted to GeoJSON -- Made with Natural Earth. Free vector and raster map data @ [naturalearthdata.com](http://naturalearthdata.com) +- Made with Natural Earth. Free vector and raster map data @ [naturalearthdata.com](https://naturalearthdata.com) ### `obs.csv` @@ -35,8 +35,8 @@ This directory provides test data to demonstrate functionality. ### `poi_portugal.gpkg` - source: OpenStreetMap - Natural GIS -- URL: [http://www.naturalgis.pt/cgi-bin/opendata/mapserv?service=WFS&request=GetCapabilities](http://www.naturalgis.pt/cgi-bin/opendata/mapserv?service=WFS&request=GetCapabilities) -- Data obtained from WFS instance of NaturalGIS company (http://www.naturalgis.pt/en/) and converted to geopackage +- URL: [https://naturalgis.pt/cgi-bin/opendata/mapserv?service=WFS&request=GetCapabilities](https://www.naturalgis.pt/cgi-bin/opendata/mapserv?service=WFS&request=GetCapabilities) +- Data obtained from WFS instance of NaturalGIS company (https://naturalgis.pt/en/) and converted to geopackage - Upstream data from OpenStreetMap extract for Portugal ### `hotosm_bdi_waterways.sql.gz` @@ -51,4 +51,4 @@ This directory provides test data to demonstrate functionality. - source: [Meteorological Service of Canada Datamrt](https://eccc-msc.github.io/open-data/msc-datamart/readme_en) - URL: https://dd.weather.gc.ca/model_gem_global/15km/grib2/lat_lon/00/000 -- License: https://eccc-msc.github.io/open-data/licence/readme_en/ +- License: https://eccc-msc.github.io/open-data/licence/readme_en diff --git a/tests/pygeoapi-test-config.yml b/tests/pygeoapi-test-config.yml index 83bd407..352c35d 100644 --- a/tests/pygeoapi-test-config.yml +++ b/tests/pygeoapi-test-config.yml @@ -127,6 +127,33 @@ resources: x_field: long y_field: lat + gdps-temperature: + type: collection + title: Global Deterministic Prediction System sample + description: Global Deterministic Prediction System sample + keywords: + - gdps + - global + extents: + spatial: + bbox: [-180,-90,180,90] + crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 + links: + - type: text/html + rel: canonical + title: information + href: https://eccc-msc.github.io/open-data/msc-data/nwp_gdps/readme_gdps_en + hreflang: en-CA + providers: + - type: coverage + name: rasterio + data: tests/data/CMC_glb_TMP_TGL_2_latlon.15x.15_2020081000_P000.grib2 + options: + DATA_ENCODING: COMPLEX_PACKING + format: + name: GRIB2 + mimetype: application/x-grib2 + hello-world: type: process processor: diff --git a/tests/test_api.py b/tests/test_api.py index 816e511..8a5f937 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -31,13 +31,14 @@ import json import os import logging +from pyld import jsonld import pytest - from werkzeug.test import create_environ from werkzeug.wrappers import Request +from werkzeug.datastructures import ImmutableMultiDict + from pygeoapi.api import API, check_format from pygeoapi.util import yaml_load -from pyld import jsonld LOGGER = logging.getLogger(__name__) @@ -169,7 +170,7 @@ def test_conformance(config, api_): assert isinstance(root, dict) assert 'conformsTo' in root - assert len(root['conformsTo']) == 4 + assert len(root['conformsTo']) == 7 rsp_headers, code, response = api_.conformance(req_headers, {'f': 'foo'}) assert code == 400 @@ -193,7 +194,7 @@ def test_describe_collections(config, api_): collections = json.loads(response) assert len(collections) == 2 - assert len(collections['collections']) == 1 + assert len(collections['collections']) == 2 assert len(collections['links']) == 3 rsp_headers, code, response = api_.describe_collections( @@ -227,6 +228,13 @@ def test_describe_collections(config, api_): req_headers, {'f': 'html'}, 'obs') assert rsp_headers['Content-Type'] == 'text/html' + rsp_headers, code, response = api_.describe_collections( + req_headers, {}, 'gdps-temperature') + collection = json.loads(response) + + assert collection['id'] == 'gdps-temperature' + assert len(collection['links']) == 12 + def test_get_collection_queryables(config, api_): req_headers = make_req_headers() @@ -533,6 +541,11 @@ def test_get_collection_item(config, api_): assert code == 400 + rsp_headers, code, response = api_.get_collection_item( + req_headers, {'f': 'json'}, 'gdps-temperature', '371') + + assert code == 400 + rsp_headers, code, response = api_.get_collection_item( req_headers, {}, 'foo', '371') @@ -580,6 +593,95 @@ def test_get_collection_item_json_ld(config, api_): 'https://schema.org/identifier'][0]['@value'] == '35' +def test_get_coverage_domainset(config, api_): + req_headers = make_req_headers() + rsp_headers, code, response = api_.get_collection_coverage_domainset( + req_headers, {}, 'obs') + + assert code == 400 + + rsp_headers, code, response = api_.get_collection_coverage_domainset( + req_headers, {}, 'gdps-temperature') + + domainset = json.loads(response) + + assert domainset['type'] == 'DomainSetType' + assert domainset['generalGrid']['axisLabels'] == ['Long', 'Lat'] + assert domainset['generalGrid']['gridLimits']['axisLabels'] == ['i', 'j'] + assert domainset['generalGrid']['gridLimits']['axis'][0]['upperBound'] == 2400 # noqa + assert domainset['generalGrid']['gridLimits']['axis'][1]['upperBound'] == 1201 # noqa + + +def test_get_collection_coverage_rangetype(config, api_): + req_headers = make_req_headers() + rsp_headers, code, response = api_.get_collection_coverage_rangetype( + req_headers, {}, 'obs') + + assert code == 400 + + rsp_headers, code, response = api_.get_collection_coverage_rangetype( + req_headers, {}, 'gdps-temperature') + + rangetype = json.loads(response) + + assert rangetype['type'] == 'DataRecordType' + assert len(rangetype['field']) == 1 + assert rangetype['field'][0]['id'] == 1 + assert rangetype['field'][0]['name'] == 'Temperature [C]' + assert rangetype['field'][0]['uom']['code'] == '[C]' + + +def test_get_collection_coverage(config, api_): + req_headers = make_req_headers() + rsp_headers, code, response = api_.get_collection_coverage( + req_headers, {}, 'obs') + + assert code == 400 + + rsp_headers, code, response = api_.get_collection_coverage( + req_headers, {'rangeSubset': '12'}, 'gdps-temperature') + + assert code == 400 + + rsp_headers, code, response = api_.get_collection_coverage( + req_headers, + ImmutableMultiDict([('subset', 'bad_axis(10,20)')]), + 'gdps-temperature') + + assert code == 400 + + rsp_headers, code, response = api_.get_collection_coverage( + req_headers, {'f': 'blah'}, 'gdps-temperature') + + assert code == 400 + + rsp_headers, code, response = api_.get_collection_coverage( + req_headers, + ImmutableMultiDict([ + ('subset', 'Lat(5,10)'), ('subset', 'Long(5,10)')]), + 'gdps-temperature') + + assert code == 200 + content = json.loads(response) + + assert content['domain']['axes']['x']['num'] == 35 + assert content['domain']['axes']['y']['num'] == 35 + assert 'TMP' in content['parameters'] + assert 'TMP' in content['ranges'] + assert content['ranges']['TMP']['axisNames'] == ['y', 'x'] + + rsp_headers, code, response = api_.get_collection_coverage( + req_headers, + ImmutableMultiDict([ + ('subset', 'Lat(5,10)'), ('subset', 'Long(5,10)'), + ('f', 'GRIB2') + ]), + 'gdps-temperature') + + assert code == 200 + assert isinstance(response, bytes) + + def test_describe_processes(config, api_): req_headers = make_req_headers() rsp_headers, code, response = api_.describe_processes( diff --git a/tests/test_csv__provider.py b/tests/test_csv__provider.py index b209de6..e7538da 100644 --- a/tests/test_csv__provider.py +++ b/tests/test_csv__provider.py @@ -51,6 +51,7 @@ path = get_test_file_path('data/obs.csv') def config(): return { 'name': 'CSV', + 'type': 'feature', 'data': path, 'id_field': 'id', 'geometry': { diff --git a/tests/test_elasticsearch__provider.py b/tests/test_elasticsearch__provider.py index 35ac49b..52fd88b 100644 --- a/tests/test_elasticsearch__provider.py +++ b/tests/test_elasticsearch__provider.py @@ -37,6 +37,7 @@ from pygeoapi.provider.elasticsearch_ import ElasticsearchProvider def config(): return { 'name': 'Elasticsearch', + 'type': 'feature', 'data': 'http://localhost:9200/ne_110m_populated_places_simple', # noqa 'id_field': 'geonameid' } diff --git a/tests/test_filesystem_provider.py b/tests/test_filesystem_provider.py index e0195bc..add178f 100644 --- a/tests/test_filesystem_provider.py +++ b/tests/test_filesystem_provider.py @@ -39,6 +39,7 @@ THISDIR = os.path.dirname(os.path.realpath(__file__)) def config(): return { 'name': 'FileSystem', + 'type': 'stac', 'data': os.path.join(THISDIR, 'data'), 'file_types': ['.gpkg'] } diff --git a/tests/test_geojson_provider.py b/tests/test_geojson_provider.py index ec1e635..fb01fa3 100644 --- a/tests/test_geojson_provider.py +++ b/tests/test_geojson_provider.py @@ -60,6 +60,7 @@ def fixture(): def config(): return { 'name': 'GeoJSON', + 'type': 'feature', 'data': path, 'id_field': 'id' } diff --git a/tests/test_mongo_provider.py b/tests/test_mongo_provider.py index 2b09687..e0ee674 100644 --- a/tests/test_mongo_provider.py +++ b/tests/test_mongo_provider.py @@ -40,6 +40,7 @@ mongocollection = 'testplaces' def config(): return { 'name': 'MongoDB', + 'type': 'feature', 'data': monogourl, 'collection': mongocollection } diff --git a/tests/test_ogr_csv_provider.py b/tests/test_ogr_csv_provider.py index 665a8a5..77fbab5 100644 --- a/tests/test_ogr_csv_provider.py +++ b/tests/test_ogr_csv_provider.py @@ -46,6 +46,7 @@ LOGGER = logging.getLogger(__name__) def config_vsicurl_csv(): return { 'name': 'OGR', + 'type': 'feature', 'data': { 'source_type': 'CSV', 'source': '/vsicurl/https://raw.githubusercontent.com/pcm-dpc/COVID-19/master/dati-regioni/dpc-covid19-ita-regioni.csv', # noqa diff --git a/tests/test_ogr_esrijson_provider.py b/tests/test_ogr_esrijson_provider.py index fe538af..d780b3d 100644 --- a/tests/test_ogr_esrijson_provider.py +++ b/tests/test_ogr_esrijson_provider.py @@ -47,6 +47,7 @@ LOGGER = logging.getLogger(__name__) def config_ArcGIS_ESRIJSON(): return { 'name': 'OGR', + 'type': 'feature', 'data': { 'source_type': 'ESRIJSON', 'source': 'https://sampleserver6.arcgisonline.com/arcgis/rest/services/CommunityAddressing/FeatureServer/0/query?where=objectid+%3D+objectid&outfields=*&orderByFields=objectid+ASC&f=json', # noqa diff --git a/tests/test_ogr_gpkg_provider.py b/tests/test_ogr_gpkg_provider.py index 9a45500..7f46e60 100644 --- a/tests/test_ogr_gpkg_provider.py +++ b/tests/test_ogr_gpkg_provider.py @@ -44,6 +44,7 @@ LOGGER = logging.getLogger(__name__) def config_poi_portugal(): return { 'name': 'OGR', + 'type': 'feature', 'data': { 'source_type': 'GPKG', 'source': './tests/data/poi_portugal.gpkg', @@ -97,6 +98,7 @@ def test_get_not_existing_feature_raise_exception( def config_gpkg_4326(): return { 'name': 'OGR', + 'type': 'feature', 'data': { 'source_type': 'GPKG', 'source': @@ -117,6 +119,7 @@ def config_gpkg_4326(): def config_gpkg_28992(): return { 'name': 'OGR', + 'type': 'feature', 'data': { 'source_type': 'GPKG', 'source': diff --git a/tests/test_ogr_shapefile_provider.py b/tests/test_ogr_shapefile_provider.py index 4fd3a44..9274c82 100644 --- a/tests/test_ogr_shapefile_provider.py +++ b/tests/test_ogr_shapefile_provider.py @@ -47,6 +47,7 @@ LOGGER = logging.getLogger(__name__) def config_shapefile_4326(): return { 'name': 'OGR', + 'type': 'feature', 'data': { 'source_type': 'ESRI Shapefile', 'source': @@ -65,6 +66,7 @@ def config_shapefile_4326(): def config_shapefile_28992(): return { 'name': 'OGR', + 'type': 'feature', 'data': { 'source_type': 'ESRI Shapefile', 'source': diff --git a/tests/test_ogr_sqlite_provider.py b/tests/test_ogr_sqlite_provider.py index d63285e..cadabc8 100644 --- a/tests/test_ogr_sqlite_provider.py +++ b/tests/test_ogr_sqlite_provider.py @@ -46,6 +46,7 @@ LOGGER = logging.getLogger(__name__) def config_sqlite_4326(): return { 'name': 'OGR', + 'type': 'feature', 'data': { 'source_type': 'SQLite', 'source': diff --git a/tests/test_ogr_wfs_provider.py b/tests/test_ogr_wfs_provider.py index b6a234d..0a79f27 100644 --- a/tests/test_ogr_wfs_provider.py +++ b/tests/test_ogr_wfs_provider.py @@ -47,6 +47,7 @@ LOGGER = logging.getLogger(__name__) def config_MapServer_WFS(): return { 'name': 'OGR', + 'type': 'feature', 'data': { 'source_type': 'WFS', 'source': 'WFS:http://geodata.nationaalgeoregister.nl/rdinfo/wfs?', @@ -75,6 +76,7 @@ def config_MapServer_WFS(): def config_GeoServer_WFS(): return { 'name': 'OGR', + 'type': 'feature', 'data': { 'source_type': 'WFS', 'source': @@ -105,6 +107,7 @@ def config_GeoServer_WFS(): def config_geosol_gs_WFS(): return { 'name': 'OGR', + 'type': 'feature', 'data': { 'source_type': 'WFS', 'source': @@ -134,6 +137,7 @@ def config_geosol_gs_WFS(): def config_geonode_gs_WFS(): return { 'name': 'OGR', + 'type': 'feature', 'data': { 'source_type': 'WFS', 'source': diff --git a/tests/test_postgresql_provider.py b/tests/test_postgresql_provider.py index 88e3b1e..192a2a3 100644 --- a/tests/test_postgresql_provider.py +++ b/tests/test_postgresql_provider.py @@ -41,6 +41,7 @@ from pygeoapi.provider.postgresql import PostgreSQLProvider def config(): return { 'name': 'PostgreSQL', + 'type': 'feature', 'data': {'host': '127.0.0.1', 'dbname': 'test', 'user': 'postgres', diff --git a/tests/test_rasterio_provider.py b/tests/test_rasterio_provider.py new file mode 100644 index 0000000..1037ad7 --- /dev/null +++ b/tests/test_rasterio_provider.py @@ -0,0 +1,100 @@ +# ================================================================= +# +# Authors: Tom Kralidis +# +# Copyright (c) 2020 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 os +import pytest + +from pygeoapi.provider.rasterio_ import RasterioProvider + + +def get_test_file_path(filename): + """helper function to open test file safely""" + + if os.path.isfile(filename): + return filename + else: + return 'tests/{}'.format(filename) + + +path = get_test_file_path( + 'tests/data/CMC_glb_TMP_TGL_2_latlon.15x.15_2020081000_P000.grib2') + + +@pytest.fixture() +def config(): + return { + 'name': 'rasterio', + 'type': 'coverage', + 'data': path, + 'options': { + 'DATA_ENCODING': 'COMPLEX_PACKING' + }, + 'format': { + 'name': 'GRIB2', + 'mimetype': 'application/x-grib2' + } + } + + +def test_provider(config): + p = RasterioProvider(config) + + assert p.num_bands == 1 + assert len(p.axes) == 2 + assert p.axes == ['Long', 'Lat'] + + +def test_domainset(config): + p = RasterioProvider(config) + domainset = p.get_coverage_domainset() + + assert isinstance(domainset, dict) + assert domainset['generalGrid']['axisLabels'] == ['Long', 'Lat'] + assert domainset['generalGrid']['gridLimits']['axisLabels'] == ['i', 'j'] + assert domainset['generalGrid']['gridLimits']['axis'][0]['upperBound'] == 2400 # noqa + assert domainset['generalGrid']['gridLimits']['axis'][1]['upperBound'] == 1201 # noqa + + +def test_rangetype(config): + p = RasterioProvider(config) + rangetype = p.get_coverage_rangetype() + + assert isinstance(rangetype, dict) + assert len(rangetype['field']) == 1 + assert rangetype['field'][0]['name'] == 'Temperature [C]' + + +def test_query(config): + p = RasterioProvider(config) + + data = p.query() + assert isinstance(data, dict) + + data = p.query(format_='GRIB2') + assert isinstance(data, bytes) diff --git a/tests/test_sqlite_geopackage_provider.py b/tests/test_sqlite_geopackage_provider.py index 76320a0..a65c448 100644 --- a/tests/test_sqlite_geopackage_provider.py +++ b/tests/test_sqlite_geopackage_provider.py @@ -43,6 +43,7 @@ from pygeoapi.provider.sqlite import SQLiteGPKGProvider def config_sqlite(): return { 'name': 'SQLiteGPKG', + 'type': 'feature', 'data': './tests/data/ne_110m_admin_0_countries.sqlite', 'id_field': 'ogc_fid', 'table': 'ne_110m_admin_0_countries' @@ -53,6 +54,7 @@ def config_sqlite(): def config_geopackage(): return { 'name': 'SQLiteGPKG', + 'type': 'feature', 'data': './tests/data/poi_portugal.gpkg', 'id_field': 'osm_id', 'table': 'poi_portugal' diff --git a/tests/test_util.py b/tests/test_util.py index 4e99668..671ccc9 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -34,6 +34,7 @@ import os import pytest from pygeoapi import util +from pygeoapi.provider.base import ProviderTypeError def get_test_file_path(filename): @@ -123,7 +124,7 @@ def test_filter_dict_by_key_value(): collections = util.filter_dict_by_key_value(d['resources'], 'type', 'collection') - assert len(collections) == 1 + assert len(collections) == 2 notfound = util.filter_dict_by_key_value(d['resources'], 'type', 'foo') @@ -142,7 +143,7 @@ def test_get_provider_by_type(): assert p['type'] == 'feature' assert p['name'] == 'CSV' - with pytest.raises(RuntimeError): + with pytest.raises(ProviderTypeError): p = util.get_provider_by_type(d['resources']['obs']['providers'], 'something-else')