diff --git a/terracotta/drivers/base.py b/terracotta/drivers/base.py index 37cd738b..188635db 100644 --- a/terracotta/drivers/base.py +++ b/terracotta/drivers/base.py @@ -81,6 +81,10 @@ def get_keys(self) -> OrderedDict: """ pass + @abstractmethod + def get_valid_values(self, where: Mapping[str, Union[str, List[str]]]) -> Dict[str, List[str]]: + pass + @abstractmethod def get_datasets(self, where: Mapping[str, Union[str, List[str]]] = None, page: int = 0, limit: int = None) -> Dict[Tuple[str, ...], Any]: diff --git a/terracotta/drivers/mysql.py b/terracotta/drivers/mysql.py index 18ff9ecd..211b9cc5 100644 --- a/terracotta/drivers/mysql.py +++ b/terracotta/drivers/mysql.py @@ -332,6 +332,35 @@ def _get_keys(self) -> OrderedDict: return out + @requires_connection + @convert_exceptions('Could not retrieve valid key values') + def get_valid_values(self, where: Mapping[str, Union[str, List[str]]]) -> Dict[str, List[str]]: + cursor = self._cursor + + if not all(key in self.key_names for key in where.keys()): + raise exceptions.InvalidKeyError('Encountered unrecognized keys in where clause') + + conditions = [] + values = [] + for key, value in where.items(): + if isinstance(value, str): + value = [value] + values.extend(value) + conditions.append(' OR '.join([f'{key}=%s'] * len(value))) + where_fragment = ' AND '.join([f'({condition})' for condition in conditions]) + where_fragment = ' WHERE ' + where_fragment if where_fragment else '' + + valid_values = {key: [val] if isinstance(val, str) else val for key, val in where.items()} + + for key in set(self.key_names) - set(where.keys()): + cursor.execute( + f'SELECT DISTINCT {key} FROM datasets {where_fragment}', + values + ) + valid_values[key] = list([row[key] for row in cursor.fetchall()]) + + return valid_values + @trace('get_datasets') @requires_connection @convert_exceptions('Could not retrieve datasets') diff --git a/terracotta/drivers/sqlite.py b/terracotta/drivers/sqlite.py index 0cfd68cc..9253e821 100644 --- a/terracotta/drivers/sqlite.py +++ b/terracotta/drivers/sqlite.py @@ -230,6 +230,35 @@ def get_keys(self) -> OrderedDict: out[row['key']] = row['description'] return out + @requires_connection + @convert_exceptions('Could not retrieve valid key values') + def get_valid_values(self, where: Mapping[str, Union[str, List[str]]]) -> Dict[str, List[str]]: + conn = self._connection + + if not all(key in self.key_names for key in where.keys()): + raise exceptions.InvalidKeyError('Encountered unrecognized keys in where clause') + + conditions = [] + values = [] + for key, value in where.items(): + if isinstance(value, str): + value = [value] + values.extend(value) + conditions.append(' OR '.join([f'{key}=?'] * len(value))) + where_fragment = ' AND '.join([f'({condition})' for condition in conditions]) + where_fragment = ' WHERE ' + where_fragment if where_fragment else '' + + valid_values = {key: [val] if isinstance(val, str) else val for key, val in where.items()} + + for key in set(self.key_names) - set(where.keys()): + rows = conn.execute( + f'SELECT DISTINCT {key} FROM datasets {where_fragment}', + values + ) + valid_values[key] = list([row[key] for row in rows]) + + return valid_values + @trace('get_datasets') @requires_connection @convert_exceptions('Could not retrieve datasets') diff --git a/terracotta/handlers/valid_values.py b/terracotta/handlers/valid_values.py new file mode 100644 index 00000000..3807580e --- /dev/null +++ b/terracotta/handlers/valid_values.py @@ -0,0 +1,21 @@ +"""handlers/valid_values.py + +Handle /valid_values API endpoint. +""" + +from typing import Dict, Mapping, List, Union + +from terracotta import get_settings, get_driver +from terracotta.profile import trace + + +@trace('valid_values_handler') +def valid_values(some_keys: Mapping[str, Union[str, List[str]]] = None) -> Dict[str, List[str]]: + """List all available valid values""" + settings = get_settings() + driver = get_driver(settings.DRIVER_PATH, provider=settings.DRIVER_PROVIDER) + + with driver.connect(): + valid_values = driver.get_valid_values(some_keys or {}) + + return valid_values diff --git a/terracotta/server/flask_api.py b/terracotta/server/flask_api.py index 94311258..6c4195c3 100644 --- a/terracotta/server/flask_api.py +++ b/terracotta/server/flask_api.py @@ -69,6 +69,7 @@ def create_app(debug: bool = False, profile: bool = False) -> Flask: from terracotta import get_settings import terracotta.server.datasets import terracotta.server.keys + import terracotta.server.valid_values import terracotta.server.colormap import terracotta.server.metadata import terracotta.server.rgb @@ -97,6 +98,7 @@ def create_app(debug: bool = False, profile: bool = False) -> Flask: with new_app.test_request_context(): SPEC.path(view=terracotta.server.datasets.get_datasets) SPEC.path(view=terracotta.server.keys.get_keys) + SPEC.path(view=terracotta.server.valid_values.get_valid_values) SPEC.path(view=terracotta.server.colormap.get_colormap) SPEC.path(view=terracotta.server.metadata.get_metadata) SPEC.path(view=terracotta.server.rgb.get_rgb) diff --git a/terracotta/server/valid_values.py b/terracotta/server/valid_values.py new file mode 100644 index 00000000..c33442b5 --- /dev/null +++ b/terracotta/server/valid_values.py @@ -0,0 +1,63 @@ +"""server/valid_values.py + +Flask route to handle /valid_values calls. +""" + +from typing import Any, Dict, List, Union +from flask import request, jsonify, Response +from marshmallow import Schema, fields, INCLUDE, post_load +import re + +from terracotta.server.flask_api import METADATA_API + + +class KeyValueOptionSchema(Schema): + class Meta: + unknown = INCLUDE + + # placeholder values to document keys + key1 = fields.String(example='value1', description='Value of key1', dump_only=True) + key2 = fields.String(example='value2', description='Value of key2', dump_only=True) + + @post_load + def list_items(self, data: Dict[str, Any], **kwargs: Any) -> Dict[str, Union[str, List[str]]]: + # Create lists of values supplied as stringified lists + for key, value in data.items(): + if isinstance(value, str) and re.match(r'^\[.*\]$', value): + data[key] = value[1:-1].split(',') + return data + + +@METADATA_API.route('/valid_values', methods=['GET']) +def get_valid_values() -> Response: + """Get all valid values combinations (possibly when given a value for some keys) + --- + get: + summary: /datasets + description: + Get keys of all available datasets that match given key constraint. + Constraints may be combined freely. Returns all known datasets if no query parameters + are given. + parameters: + - in: query + schema: DatasetOptionSchema + responses: + 200: + description: All available key combinations + schema: + type: array + items: DatasetSchema + 400: + description: Query parameters contain unrecognized keys + """ + from terracotta.handlers.valid_values import valid_values + option_schema = KeyValueOptionSchema() + options = option_schema.load(request.args) + + keys = options or None + + payload = { + 'valid_values': valid_values(keys) + } + + return jsonify(payload)