add support for OGC API - Coverages (#110) (#516)

* add support for OGC API - Coverages

* fix coverage CRS ref

* fix ref to OACov schemas for testing

* move spectral testing to after_success

* update docs

* add mask param to rasterio provider
This commit is contained in:
Tom Kralidis
2020-08-21 09:52:17 -04:00
committed by GitHub
parent b98f8c2528
commit da824fba8f
38 changed files with 1824 additions and 322 deletions
+3 -3
View File
@@ -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
use_notice: true
+6 -1
View File
@@ -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
+1
View File
@@ -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
@@ -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
+4 -2
View File
@@ -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
+63 -12
View File
@@ -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
----------------------------------
+29 -1
View File
@@ -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
+538 -192
View File
File diff suppressed because it is too large Load Diff
+63
View File
@@ -217,6 +217,69 @@ def collection_items(collection_id, item_id=None):
return response
@APP.route('/collections/<collection_id>/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/<collection_id>/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/<collection_id>/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/<process_id>')
def processes(process_id=None):
+164 -85
View File
@@ -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'],
+2 -1
View File
@@ -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'
+53 -4
View File
@@ -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
+420
View File
@@ -0,0 +1,420 @@
# =================================================================
#
# Authors: Tom Kralidis <tomkralidis@gmail.com>
#
# 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
+63
View File
@@ -212,6 +212,69 @@ async def collection_items(request: Request, collection_id=None, item_id=None):
return response
@app.route('/collections/<collection_id>/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/<collection_id>/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/<collection_id>/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}')
+2 -2
View File
@@ -26,7 +26,7 @@
<div>
<meta itemprop="encodingFormat" content="text/html" />
<a title="Display Queryables" itemprop="contentURL" href="{{ config['server']['url'] }}/collections/{{ data['id'] }}/queryables">
Display Queryables of "{{ data['title'] }}</a>"</div>
Display Queryables of "{{ data['title'] }}"</a></div>
</li>
</ul>
<h3>View</h3>
@@ -35,7 +35,7 @@
<div itemprop="distribution" itemscope itemtype="https://schema.org/DataDownload">
<meta itemprop="encodingFormat" content="text/html" />
<a title="Browse Items" itemprop="contentURL" href="{{ config['server']['url'] }}/collections/{{ data['id'] }}/items">
Browse through the items of "{{ data['title'] }}</a>"</div>
Browse through the items of "{{ data['title'] }}"</a></div>
</li>
</ul>
{% endif %}
+43
View File
@@ -0,0 +1,43 @@
{% extends "base.html" %}
{% block title %}{{ super() }} {{ data['title'] }} {% endblock %}
{% block crumbs %}{{ super() }}
/ <a href="../../">Collections</a>
/ <a href="../../../collections/{{ data['id'] }}">{{ data['title'] }}</a>
{% endblock %}
{% block body %}
<section id="collection" itemscope itemtype="https://schema.org/Dataset">
<span itemprop="includedInDataCatalog" itemscope itemtype="https://schema.org/DataCatalog">
<meta itemprop="url" content="{{ config['server']['url'] }}/collections" />
<meta itemprop="name" content="{{ config['metadata']['identification']['title'] | striptags }}" />
<meta itemprop="description" content="{{ config['metadata']['identification']['description'] | striptags }}" />
</span>
<h1 itemprop="name">{{ data['title'] }}</h1>
<meta itemprop="url" content="{{ config['server']['url'] }}" />
<p itemprop="description">{{ data['description'] }}</p>
<h3>Coverage domain set</h3>
<h4>Axis labels</h4>
<ul>
{% for al in data['generalGrid']['axisLabels'] %}
<li>{{ al }}</li>
{% endfor %}
</ul>
<h4>Extent</h4>
<ul>
<li>minx: {{ data['generalGrid']['axis'][0]['lowerBound'] }}</li>
<li>miny: {{ data['generalGrid']['axis'][1]['lowerBound'] }}</li>
<li>maxx: {{ data['generalGrid']['axis'][0]['upperBound'] }}</li>
<li>maxy: {{ data['generalGrid']['axis'][1]['upperBound'] }}</li>
<li>Coordinate reference system: {{ data['generalGrid']['srsName'] }}</li>
</ul>
<h4>Size</h4>
<ul>
<li>width: {{ data['generalGrid']['gridLimits']['axis'][0]['upperBound'] }} </li>
<li>height: {{ data['generalGrid']['gridLimits']['axis'][1]['upperBound'] }} </li>
</ul>
<h4>Resolution</h4>
<ul>
<li>x: {{ data['generalGrid']['axis'][0]['resolution'] }} </li>
<li>y: {{ data['generalGrid']['axis'][1]['resolution'] }} </li>
</ul>
</section>
{% endblock %}
+25
View File
@@ -0,0 +1,25 @@
{% extends "base.html" %}
{% block title %}{{ super() }} {{ data['title'] }} {% endblock %}
{% block crumbs %}{{ super() }}
/ <a href="../../">Collections</a>
/ <a href="../../../collections/{{ data['id'] }}">{{ data['title'] }}</a>
{% endblock %}
{% block body %}
<section id="collection" itemscope itemtype="https://schema.org/Dataset">
<span itemprop="includedInDataCatalog" itemscope itemtype="https://schema.org/DataCatalog">
<meta itemprop="url" content="{{ config['server']['url'] }}/collections" />
<meta itemprop="name" content="{{ config['metadata']['identification']['title'] | striptags }}" />
<meta itemprop="description" content="{{ config['metadata']['identification']['description'] | striptags }}" />
</span>
<h1 itemprop="name">{{ data['title'] }}</h1>
<meta itemprop="url" content="{{ config['server']['url'] }}" />
<p itemprop="description">{{ data['description'] }}</p>
<h3>Coverage range type</h3>
<h4>Fields</h4>
<ul>
{% for field in data['field'] %}
<li>{{ field['id'] }}: {{ field['name'] }} ({{ field['definition'] }})</li>
{% endfor %}
</ul>
</section>
{% endblock %}
+11 -4
View File
@@ -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
+1
View File
@@ -3,4 +3,5 @@ Flask
python-dateutil
pytz
PyYAML
rasterio
unicodecsv
+9 -9
View File
@@ -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
+27
View File
@@ -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:
+106 -4
View File
@@ -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(
+1
View File
@@ -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': {
+1
View File
@@ -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'
}
+1
View File
@@ -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']
}
+1
View File
@@ -60,6 +60,7 @@ def fixture():
def config():
return {
'name': 'GeoJSON',
'type': 'feature',
'data': path,
'id_field': 'id'
}
+1
View File
@@ -40,6 +40,7 @@ mongocollection = 'testplaces'
def config():
return {
'name': 'MongoDB',
'type': 'feature',
'data': monogourl,
'collection': mongocollection
}
+1
View File
@@ -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
+1
View File
@@ -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
+3
View File
@@ -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':
+2
View File
@@ -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':
+1
View File
@@ -46,6 +46,7 @@ LOGGER = logging.getLogger(__name__)
def config_sqlite_4326():
return {
'name': 'OGR',
'type': 'feature',
'data': {
'source_type': 'SQLite',
'source':
+4
View File
@@ -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':
+1
View File
@@ -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',
+100
View File
@@ -0,0 +1,100 @@
# =================================================================
#
# Authors: Tom Kralidis <tomkralidis@gmail.com>
#
# 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)
+2
View File
@@ -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'
+3 -2
View File
@@ -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')