From 54732d26c9fb53a326b6649acf144643c666b778 Mon Sep 17 00:00:00 2001
From: Tom Kralidis
Date: Thu, 23 Apr 2020 08:59:02 -0400
Subject: [PATCH] implement queryables endpoint (#417)
* implement queryables endpoint
* fix tests now that CSV provider provides properties
* filter queryables if provider properties are set
---
docs/source/tour.rst | 10 ++++
pygeoapi/api.py | 80 +++++++++++++++++++++++++++
pygeoapi/flask_app.py | 28 +++++++++-
pygeoapi/openapi.py | 20 +++++++
pygeoapi/provider/csv_.py | 19 +++++++
pygeoapi/provider/geojson.py | 19 +++++++
pygeoapi/starlette_app.py | 23 ++++++++
pygeoapi/templates/collection.html | 9 +++
pygeoapi/templates/queryables.html | 30 ++++++++++
tests/test_api.py | 33 ++++++++++-
tests/test_csv__provider.py | 6 ++
tests/test_elasticsearch__provider.py | 7 +++
tests/test_geojson_provider.py | 6 ++
13 files changed, 287 insertions(+), 3 deletions(-)
create mode 100644 pygeoapi/templates/queryables.html
diff --git a/docs/source/tour.rst b/docs/source/tour.rst
index c2b14e5..4166db2 100644
--- a/docs/source/tour.rst
+++ b/docs/source/tour.rst
@@ -52,6 +52,16 @@ with related links (other related HTML pages, dataset download, etc.).
The 'View' section provides the default to start browsing the data.
+The 'Queryables' section provides a link to the dataset's properties.
+
+
+Collection queryables
+---------------------
+
+http://localhost:5000/collections/obs/queryables
+
+The queryables endpoint provides a list of queryable properties and their associated datatypes.
+
Collection items
----------------
diff --git a/pygeoapi/api.py b/pygeoapi/api.py
index 6b4028b..cb42ee3 100644
--- a/pygeoapi/api.py
+++ b/pygeoapi/api.py
@@ -448,6 +448,86 @@ class API(object):
return headers_, 200, json.dumps(fcm, default=json_serial)
+ @pre_process
+ @jsonldify
+ def get_collection_queryables(self, headers_, format_, dataset=None):
+ """
+ Provide collection queryables
+
+ :param headers_: copy of HEADERS object
+ :param format_: format of requests,
+ pre checked by pre_process decorator
+ :param dataset: name of collection
+
+ :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)
+
+ if any([dataset is None,
+ dataset not in self.config['datasets'].keys()]):
+
+ exception = {
+ 'code': 'InvalidParameterValue',
+ 'description': 'Invalid collection'
+ }
+ LOGGER.error(exception)
+ return headers_, 400, json.dumps(exception)
+
+ LOGGER.debug('Creating collection queryables')
+ LOGGER.debug('Loading provider')
+ try:
+ p = load_plugin('provider',
+ self.config['datasets'][dataset]['provider'])
+ except ProviderConnectionError:
+ exception = {
+ 'code': 'NoApplicableCode',
+ 'description': 'connection error (check logs)'
+ }
+ LOGGER.error(exception)
+ return headers_, 500, json.dumps(exception)
+ except ProviderQueryError:
+ exception = {
+ 'code': 'NoApplicableCode',
+ 'description': 'query error (check logs)'
+ }
+ LOGGER.error(exception)
+ return headers_, 500, json.dumps(exception)
+
+ queryables = {
+ 'queryables': []
+ }
+
+ for k, v in p.fields.items():
+ show_field = False
+ if p.properties:
+ if k in p.properties:
+ show_field = True
+ else:
+ show_field = True
+
+ if show_field:
+ queryables['queryables'].append({
+ 'queryable': k,
+ 'type': v['type']
+ })
+
+ if format_ == 'html': # render
+ queryables['title'] = self.config['datasets'][dataset]['title']
+ headers_['Content-Type'] = 'text/html'
+ content = render_j2_template(self.config, 'queryables.html',
+ queryables)
+
+ return headers_, 200, content
+
+ return headers_, 200, json.dumps(queryables, default=json_serial)
+
def get_collection_items(self, headers, args, dataset, pathinfo=None):
"""
Queries feature collection
diff --git a/pygeoapi/flask_app.py b/pygeoapi/flask_app.py
index ded2fb9..a8e5277 100644
--- a/pygeoapi/flask_app.py
+++ b/pygeoapi/flask_app.py
@@ -99,6 +99,7 @@ def root():
headers, status_code, content = api_.root(request.headers, request.args)
response = make_response(content, status_code)
+
if headers:
response.headers = headers
@@ -119,6 +120,7 @@ def openapi():
openapi)
response = make_response(content, status_code)
+
if headers:
response.headers = headers
@@ -137,6 +139,7 @@ def conformance():
request.args)
response = make_response(content, status_code)
+
if headers:
response.headers = headers
@@ -147,9 +150,10 @@ def conformance():
@APP.route('/collections/')
def describe_collections(name=None):
"""
- OGC open api collections access point
+ OGC open api collections access point
:param name: identifier of collection name
+
:returns: HTTP response
"""
@@ -157,6 +161,28 @@ def describe_collections(name=None):
request.headers, request.args, name)
response = make_response(content, status_code)
+
+ if headers:
+ response.headers = headers
+
+ return response
+
+
+@APP.route('/collections//queryables')
+def get_collection_queryables(name=None):
+ """
+ OGC open api collections querybles access point
+
+ :param name: identifier of collection name
+
+ :returns: HTTP response
+ """
+
+ headers, status_code, content = api_.get_collection_queryables(
+ request.headers, request.args, name)
+
+ response = make_response(content, status_code)
+
if headers:
response.headers = headers
diff --git a/pygeoapi/openapi.py b/pygeoapi/openapi.py
index e213d97..e05efef 100644
--- a/pygeoapi/openapi.py
+++ b/pygeoapi/openapi.py
@@ -338,6 +338,26 @@ def get_oas_30(cfg):
p = load_plugin('provider', cfg['datasets'][k]['provider'])
+ if p.fields:
+ queryables_path = '{}/queryables'.format(collection_name_path)
+
+ paths[queryables_path] = {
+ 'get': {
+ 'summary': 'Get {} queryables'.format(v['title']),
+ 'description': v['description'],
+ 'tags': [k],
+ '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
+ }
+ }
+ }
+
if p.time_field is not None:
paths[items_path]['get']['parameters'].append(
{'$ref': '{}#/components/parameters/datetime'.format(OPENAPI_YAML['oapif'])}) # noqa
diff --git a/pygeoapi/provider/csv_.py b/pygeoapi/provider/csv_.py
index 60f2848..206cebc 100644
--- a/pygeoapi/provider/csv_.py
+++ b/pygeoapi/provider/csv_.py
@@ -53,6 +53,25 @@ class CSVProvider(BaseProvider):
BaseProvider.__init__(self, provider_def)
self.geometry_x = provider_def['geometry']['x_field']
self.geometry_y = provider_def['geometry']['y_field']
+ self.fields = self.get_fields()
+
+ def get_fields(self):
+ """
+ Get provider field information (names, types)
+
+ :returns: dict of fields
+ """
+
+ LOGGER.debug('Treating all columns as string types')
+ with open(self.data) as ff:
+ LOGGER.debug('Serializing DictReader')
+ data_ = csv.DictReader(ff)
+ fields = {}
+ for f in data_.fieldnames:
+ fields[f] = {
+ 'type': 'string'
+ }
+ return fields
def _load(self, startindex=0, limit=10, resulttype='results',
identifier=None, bbox=[], datetime=None, properties=[]):
diff --git a/pygeoapi/provider/geojson.py b/pygeoapi/provider/geojson.py
index 05350de..3cc8f4e 100644
--- a/pygeoapi/provider/geojson.py
+++ b/pygeoapi/provider/geojson.py
@@ -65,6 +65,25 @@ class GeoJSONProvider(BaseProvider):
def __init__(self, provider_def):
"""initializer"""
BaseProvider.__init__(self, provider_def)
+ self.fields = self.get_fields()
+
+ def get_fields(self):
+ """
+ Get provider field information (names, types)
+
+ :returns: dict of fields
+ """
+
+ LOGGER.debug('Treating all columns as string types')
+ if os.path.exists(self.data):
+ with open(self.data) as src:
+ data = json.loads(src.read())
+ fields = {}
+ for f in data['features'][0]['properties'].keys():
+ fields[f] = {
+ 'type': 'string'
+ }
+ return fields
def _load(self):
"""Load and validate the source GeoJSON file
diff --git a/pygeoapi/starlette_app.py b/pygeoapi/starlette_app.py
index 60e4c9e..47c73b6 100644
--- a/pygeoapi/starlette_app.py
+++ b/pygeoapi/starlette_app.py
@@ -151,6 +151,29 @@ async def describe_collections(request: Request, name=None):
return response
+@app.route('/collections/{name}/queryables')
+@app.route('/collections/{name}/queryables/')
+async def get_collection_queryables(request: Request, name=None):
+ """
+ OGC open api collections queryables access point
+
+ :param name: identifier of collection name
+
+ :returns: Starlette HTTP Response
+ """
+
+ if 'name' in request.path_params:
+ name = request.path_params['name']
+ headers, status_code, content = api_.get_collection_queryables(
+ request.headers, request.query_params, name)
+
+ response = Response(content=content, status_code=status_code)
+ if headers:
+ response.headers.update(headers)
+
+ return response
+
+
@app.route('/collections/{feature_collection}/items')
@app.route('/collections/{feature_collection}/items/')
@app.route('/collections/{feature_collection}/items/{feature}')
diff --git a/pygeoapi/templates/collection.html b/pygeoapi/templates/collection.html
index ba105ef..485d788 100644
--- a/pygeoapi/templates/collection.html
+++ b/pygeoapi/templates/collection.html
@@ -19,6 +19,15 @@
{{ kw }}
{% endfor %}
+ Queryables
+
View
-
diff --git a/pygeoapi/templates/queryables.html b/pygeoapi/templates/queryables.html
new file mode 100644
index 0000000..b0ec7d7
--- /dev/null
+++ b/pygeoapi/templates/queryables.html
@@ -0,0 +1,30 @@
+{% extends "base.html" %}
+{% block title %}{{ super() }} {{ data['title'] }} {% endblock %}
+{% block crumbs %}{{ super() }}
+/ Collections
+/ {{ data['title'] }}
+/ Queryables
+{% endblock %}
+{% block body %}
+
+
+
+
+
+
+
{{ data['title'] }}
+
+ {{ data['description'] }}
+
+ {% for kw in data['keywords'] %}
+ {{ kw }}
+ {% endfor %}
+
+ Queryables
+
+ {% for queryable in data['queryables'] %}
+ - {{ queryable['queryable'] }} (
{{ queryable['type'] }})
+ {% endfor %}
+
+
+{% endblock %}
diff --git a/tests/test_api.py b/tests/test_api.py
index f860900..6373fa4 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -227,6 +227,35 @@ def test_describe_collections(config, api_):
assert rsp_headers['Content-Type'] == 'text/html'
+def test_get_collection_queryables(config, api_):
+ req_headers = make_req_headers()
+ rsp_headers, code, response = api_.get_collection_queryables(
+ req_headers, {}, 'notfound')
+ assert code == 400
+
+ req_headers = make_req_headers()
+ rsp_headers, code, response = api_.get_collection_queryables(
+ req_headers, {'f': 'html'}, 'obs')
+ assert rsp_headers['Content-Type'] == 'text/html'
+
+ rsp_headers, code, response = api_.get_collection_queryables(
+ req_headers, {'f': 'json'}, 'obs')
+ queryables = json.loads(response)
+
+ assert 'queryables' in queryables
+ assert len(queryables['queryables']) == 6
+
+ # test with provider filtered properties
+ api_.config['datasets']['obs']['provider']['properties'] = ['stn_id']
+
+ rsp_headers, code, response = api_.get_collection_queryables(
+ req_headers, {'f': 'json'}, 'obs')
+ queryables = json.loads(response)
+
+ assert 'queryables' in queryables
+ assert len(queryables['queryables']) == 1
+
+
def test_describe_collections_json_ld(config, api_):
req_headers = make_req_headers()
rsp_headers, code, response = api_.describe_collections(
@@ -387,7 +416,7 @@ def test_get_collection_items(config, api_):
rsp_headers, code, response = api_.get_collection_items(
req_headers, {
- 'sortby': 'stn_id',
+ 'sortby': 'bad-property',
'stn_id': '35'
}, 'obs')
@@ -406,7 +435,7 @@ def test_get_collection_items(config, api_):
req_headers, {'sortby': 'stn_id:A'}, 'obs')
features = json.loads(response)
# FIXME? this test errors out currently
- assert code == 400
+ assert code == 200
rsp_headers, code, response = api_.get_collection_items(
req_headers, {'f': 'csv'}, 'obs')
diff --git a/tests/test_csv__provider.py b/tests/test_csv__provider.py
index f3d7dd1..a2bcaa9 100644
--- a/tests/test_csv__provider.py
+++ b/tests/test_csv__provider.py
@@ -65,6 +65,12 @@ def config():
def test_query(fixture, config):
p = CSVProvider(config)
+
+ fields = p.get_fields()
+ assert len(fields) == 6
+ assert fields['value']['type'] == 'string'
+ assert fields['stn_id']['type'] == 'string'
+
results = p.query()
assert len(results['features']) == 5
assert results['numberMatched'] == 5
diff --git a/tests/test_elasticsearch__provider.py b/tests/test_elasticsearch__provider.py
index 9ad5746..c5722dd 100644
--- a/tests/test_elasticsearch__provider.py
+++ b/tests/test_elasticsearch__provider.py
@@ -44,6 +44,13 @@ def config():
def test_query(config):
p = ElasticsearchProvider(config)
+
+ fields = p.get_fields()
+ assert len(fields) == 37
+ assert fields['scalerank']['type'] == 'long'
+ assert fields['changed']['type'] == 'float'
+ assert fields['ls_name']['type'] == 'string'
+
results = p.query()
assert len(results['features']) == 10
assert results['numberMatched'] == 242
diff --git a/tests/test_geojson_provider.py b/tests/test_geojson_provider.py
index 0390bf4..aa77bf8 100644
--- a/tests/test_geojson_provider.py
+++ b/tests/test_geojson_provider.py
@@ -67,6 +67,12 @@ def config():
def test_query(fixture, config):
p = GeoJSONProvider(config)
+
+ fields = p.get_fields()
+ assert len(fields) == 2
+ assert fields['id']['type'] == 'string'
+ assert fields['name']['type'] == 'string'
+
results = p.query()
assert len(results['features']) == 1
assert results['numberMatched'] == 1