Add cql-json support for ES (#723)

Fix starlette event loop


Fix starlette event loop


Fix starlette event loop


Fix starlette event loop


Fix provider regression


Make method public


Make method public


Move function to the helpers utility


Add the CQL lifecycle for development


Add CQL docs


Fix flake8


Isolate import for starlette codepath
This commit is contained in:
Francesco Bartoli
2021-07-22 03:00:14 +02:00
committed by GitHub
parent 0f38c764d6
commit bb4cd0bf69
18 changed files with 1431 additions and 35 deletions
+286 -17
View File
@@ -66,7 +66,7 @@ from pygeoapi.provider.base import (
from pygeoapi.provider.tile import (ProviderTileNotFoundError,
ProviderTileQueryError,
ProviderTilesetIdNotFoundError)
from pygeoapi.models.cql import CQLModel
from pygeoapi.util import (dategetter, DATETIME_FORMAT,
filter_dict_by_key_value, get_provider_by_type,
get_provider_default, get_typed_value, JobStatus,
@@ -219,7 +219,10 @@ class APIRequest:
self._args = self._get_params(request)
# Get path info
self._path_info = request.headers.environ['PATH_INFO'].strip('/')
if hasattr(request, 'scope'):
self._path_info = request.scope['path'].strip('/')
elif hasattr(request.headers, 'environ'):
self._path_info = request.headers.environ['PATH_INFO'].strip('/')
# Extract locale from params or headers
self._raw_locale, self._locale = self._get_locale(request.headers,
@@ -228,6 +231,9 @@ class APIRequest:
# Determine format
self._format = self._get_format(request.headers)
# Get received headers
self._headers = self.get_request_headers(request.headers)
@classmethod
def with_data(cls, request, supported_locales) -> 'APIRequest':
"""
@@ -250,11 +256,17 @@ class APIRequest:
# Set data from Flask request
api_req._data = request.data
elif hasattr(request, 'body'):
# Set data from Starlette request after async coroutine completion
# TODO: this now blocks, but once Flask v2 with async support
# has been implemented, with_data() can become async too
loop = asyncio.get_event_loop()
api_req._data = loop.run_until_complete(request.body())
try:
import nest_asyncio
nest_asyncio.apply()
# Set data from Starlette request after async
# coroutine completion
# TODO: this now blocks, but once Flask v2 with async support
# has been implemented, with_data() can become async too
loop = asyncio.get_event_loop()
api_req._data = loop.run_until_complete(request.body())
except ModuleNotFoundError:
LOGGER.error("Module nest-asyncio not found")
return api_req
@staticmethod
@@ -399,6 +411,17 @@ class APIRequest:
return self._format
@property
def headers(self) -> dict:
"""
Returns the dictionary of the headers from
the request.
:returns: Request headers dictionary
"""
return self._headers
def get_linkrel(self, format_: str) -> str:
"""
Returns the hyperlink relationship (rel) attribute value for
@@ -477,6 +500,19 @@ class APIRequest:
headers['Content-Type'] = FORMAT_TYPES[self._format]
return headers
def get_request_headers(self, headers) -> dict:
"""
Obtains and returns a dictionary with Request object headers.
This method adds the headers of the original request and
makes them available to the API object.
:returns: A header dict
"""
headers_ = {item[0]: item[1] for item in headers.items()}
return headers_
class API:
"""API object"""
@@ -1104,13 +1140,12 @@ class API:
@pre_process
def get_collection_items(
self, request: Union[APIRequest, Any],
dataset, pathinfo=None) -> Tuple[dict, int, str]:
dataset) -> Tuple[dict, int, str]:
"""
Queries collection
:param request: A request object
:param dataset: dataset name
:param pathinfo: path location
:returns: tuple of headers, status code, content
"""
@@ -1393,14 +1428,9 @@ class API:
if request.format == F_HTML: # render
# For constructing proper URIs to items
if pathinfo:
path_info = '/'.join([
self.config['server']['url'].rstrip('/'),
pathinfo.strip('/')])
else:
path_info = '/'.join([
self.config['server']['url'].rstrip('/'),
request.path_info])
path_info = '/'.join([
self.config['server']['url'].rstrip('/'),
request.path_info])
content['items_path'] = path_info
content['dataset_path'] = '/'.join(path_info.split('/')[:-1])
@@ -1451,6 +1481,245 @@ class API:
return headers, 200, to_json(content, self.pretty_print)
@pre_process
def post_collection_items(
self, request: Union[APIRequest, Any],
dataset) -> Tuple[dict, int, str]:
"""
Queries collection or filter an item
:param request: A request object
:param dataset: dataset name
:returns: tuple of headers, status code, content
"""
request_headers = request.headers
if not request.is_valid(PLUGINS['formatter'].keys()):
return self.get_format_exception(request)
# Set Content-Language to system locale until provider locale
# has been determined
headers = request.get_response_headers(SYSTEM_LOCALE)
properties = []
reserved_fieldnames = ['bbox', 'f', 'limit', 'startindex',
'resulttype', 'datetime', 'sortby',
'properties', 'skipGeometry', 'q',
'filter-lang']
collections = filter_dict_by_key_value(self.config['resources'],
'type', 'collection')
if dataset not in collections.keys():
msg = 'Invalid collection'
return self.get_exception(
400, headers, request.format, 'InvalidParameterValue', msg)
LOGGER.debug('Processing query parameters')
LOGGER.debug('Processing startindex parameter')
try:
startindex = int(request.params.get('startindex'))
if startindex < 0:
msg = 'startindex value should be positive or zero'
return self.get_exception(
400, headers, request.format, 'InvalidParameterValue', msg)
except TypeError as err:
LOGGER.warning(err)
startindex = 0
except ValueError:
msg = 'startindex value should be an integer'
return self.get_exception(
400, headers, request.format, 'InvalidParameterValue', msg)
LOGGER.debug('Processing limit parameter')
try:
limit = int(request.params.get('limit'))
# TODO: We should do more validation, against the min and max
# allowed by the server configuration
if limit <= 0:
msg = 'limit value should be strictly positive'
return self.get_exception(
400, headers, request.format, 'InvalidParameterValue', msg)
except TypeError as err:
LOGGER.warning(err)
limit = int(self.config['server']['limit'])
except ValueError:
msg = 'limit value should be an integer'
return self.get_exception(
400, headers, request.format, 'InvalidParameterValue', msg)
resulttype = request.params.get('resulttype') or 'results'
LOGGER.debug('Processing bbox parameter')
bbox = request.params.get('bbox')
if bbox is None:
bbox = []
else:
try:
bbox = validate_bbox(bbox)
except ValueError as err:
msg = str(err)
return self.get_exception(
400, headers, request.format, 'InvalidParameterValue', msg)
LOGGER.debug('Processing datetime parameter')
datetime_ = request.params.get('datetime')
try:
datetime_ = validate_datetime(collections[dataset]['extents'],
datetime_)
except ValueError as err:
msg = str(err)
return self.get_exception(
400, headers, request.format, 'InvalidParameterValue', msg)
LOGGER.debug('processing q parameter')
val = request.params.get('q')
q = None
if val is not None:
q = val
LOGGER.debug('Loading provider')
try:
p = load_plugin('provider', get_provider_by_type(
collections[dataset]['providers'], 'feature'))
except ProviderTypeError:
try:
p = load_plugin('provider', get_provider_by_type(
collections[dataset]['providers'], 'record'))
except ProviderTypeError:
msg = 'Invalid provider type'
return self.get_exception(
400, headers, request.format, 'NoApplicableCode', msg)
except ProviderConnectionError:
msg = 'connection error (check logs)'
return self.get_exception(
500, headers, request.format, 'NoApplicableCode', msg)
except ProviderQueryError:
msg = 'query error (check logs)'
return self.get_exception(
500, headers, request.format, 'NoApplicableCode', msg)
LOGGER.debug('processing property parameters')
for k, v in request.params.items():
if k not in reserved_fieldnames and k not in p.fields.keys():
msg = 'unknown query parameter: {}'.format(k)
return self.get_exception(
400, headers, request.format, 'InvalidParameterValue', msg)
elif k not in reserved_fieldnames and k in p.fields.keys():
LOGGER.debug('Add property filter {}={}'.format(k, v))
properties.append((k, v))
LOGGER.debug('processing sort parameter')
val = request.params.get('sortby')
if val is not None:
sortby = []
sorts = val.split(',')
for s in sorts:
prop = s
order = '+'
if s[0] in ['+', '-']:
order = s[0]
prop = s[1:]
if prop not in p.fields.keys():
msg = 'bad sort property'
return self.get_exception(
400, headers, request.format,
'InvalidParameterValue', msg)
sortby.append({'property': prop, 'order': order})
else:
sortby = []
LOGGER.debug('processing properties parameter')
val = request.params.get('properties')
if val is not None:
select_properties = val.split(',')
properties_to_check = set(p.properties) | set(p.fields.keys())
if (len(list(set(select_properties) -
set(properties_to_check))) > 0):
msg = 'unknown properties specified'
return self.get_exception(
400, headers, request.format, 'InvalidParameterValue', msg)
else:
select_properties = []
LOGGER.debug('processing skipGeometry parameter')
val = request.params.get('skipGeometry')
if val is not None:
skip_geometry = str2bool(val)
else:
skip_geometry = False
LOGGER.debug('Processing filter-lang parameter')
filter_lang = request.params.get('filter-lang')
if filter_lang == 'cql-json': # @TODO add check from the configuration
val = filter_lang
else:
msg = 'Invalid filter language'
return self.get_exception(
400, headers, request.format, 'InvalidParameterValue', msg)
LOGGER.debug('Querying provider')
LOGGER.debug('startindex: {}'.format(startindex))
LOGGER.debug('limit: {}'.format(limit))
LOGGER.debug('resulttype: {}'.format(resulttype))
LOGGER.debug('sortby: {}'.format(sortby))
LOGGER.debug('bbox: {}'.format(bbox))
LOGGER.debug('datetime: {}'.format(datetime_))
LOGGER.debug('properties: {}'.format(select_properties))
LOGGER.debug('skipGeometry: {}'.format(skip_geometry))
LOGGER.debug('q: {}'.format(q))
LOGGER.debug('filter-lang: {}'.format(filter_lang))
LOGGER.debug('Processing headers')
LOGGER.debug('Processing request content-type header')
if (request_headers.get(
'Content-Type') or request_headers.get(
'content-type')) != 'application/query-cql-json':
msg = ('Invalid body content-type')
return self.get_exception(
400, headers, request.format, 'InvalidHeaderValue', msg)
LOGGER.debug('Processing body')
if not request.data:
msg = 'missing request data'
return self.get_exception(
400, headers, request.format, 'MissingParameterValue', msg)
try:
# Parse bytes data, if applicable
data = request.data.decode()
LOGGER.debug(data)
# @TODO validation function
filter_ = None
if val:
filter_ = CQLModel.parse_raw(data)
content = p.query(startindex=startindex, limit=limit,
resulttype=resulttype, bbox=bbox,
datetime_=datetime_, properties=properties,
sortby=sortby,
select_properties=select_properties,
skip_geometry=skip_geometry,
q=q,
filterq=filter_)
except (UnicodeDecodeError, AttributeError):
pass
return headers, 200, to_json(content, self.pretty_print)
@pre_process
def get_collection_item(self, request: Union[APIRequest, Any],
dataset, identifier) -> Tuple[dict, int, str]:
+11 -4
View File
@@ -175,7 +175,7 @@ def collection_queryables(collection_id=None):
return get_response(api_.get_collection_queryables(request, collection_id))
@BLUEPRINT.route('/collections/<collection_id>/items')
@BLUEPRINT.route('/collections/<collection_id>/items', methods=['GET', 'POST'])
@BLUEPRINT.route('/collections/<collection_id>/items/<item_id>')
def collection_items(collection_id, item_id=None):
"""
@@ -187,9 +187,16 @@ def collection_items(collection_id, item_id=None):
:returns: HTTP response
"""
if item_id is None:
return get_response(api_.get_collection_items(request, collection_id))
return get_response(
api_.get_collection_item(request, collection_id, item_id))
if request.method == 'GET': # list items
return get_response(
api_.get_collection_items(request, collection_id))
elif request.method == 'POST': # filter items
return get_response(
api_.post_collection_items(request, collection_id))
else:
return get_response(
api_.get_collection_item(request, collection_id, item_id))
@BLUEPRINT.route('/collections/<collection_id>/coverage')
+30
View File
@@ -0,0 +1,30 @@
# =================================================================
#
# Authors: Francesco Bartoli <xbartolone@gmail.com>
#
# Copyright (c) 2021 Francesco Bartoli
#
# 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.
#
# =================================================================
"""Interface module containing the models for the openapi/json schema"""
+527
View File
@@ -0,0 +1,527 @@
# ****************************** -*-
# flake8: noqa
# generated by datamodel-codegen:
# filename: cql-schema.json
# timestamp: 2021-03-13T21:05:20+00:00
# =================================================================
#
# Authors: Francesco Bartoli <xbartolone@gmail.com>
#
# Copyright (c) 2021 Francesco Bartoli
#
# 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.
#
# =================================================================
from datetime import date, datetime
from enum import Enum
from typing import Any, List, Optional, Union
from pydantic import BaseModel, Field
class CQLModel(BaseModel):
__root__: 'Union[\n ComparisonPredicate,\n SpatialPredicate,\n TemporalPredicate,\n AndExpression\n ]'
class AndExpression(BaseModel):
and_: 'List[ComparisonPredicate]' = Field(..., alias='and')
class NotExpression(BaseModel):
not_: 'List[Any]' = Field(..., alias='not')
class OrExpression(BaseModel):
or_: 'List[Any]' = Field(..., alias='or')
class PropertyRef(BaseModel):
property: 'Optional[str]' = None
class ScalarLiteral(BaseModel):
__root__: 'Union[str, float, bool]'
class Bbox(BaseModel):
__root__: 'List[float]'
class LineStringType(Enum):
LineString = 'LineString'
class LinestringCoordinate(BaseModel):
__root__: 'List[Any]'
class Linestring(BaseModel):
type: 'LineStringType'
coordinates: 'List[LinestringCoordinate]' = Field(...)
bbox: 'Optional[List[float]]' = Field(None)
class MultiLineStringType(Enum):
MultiLineString = 'MultiLineString'
class MultilineStringCoordinate(BaseModel):
__root__: 'List[Any]'
class Multilinestring(BaseModel):
type: 'MultiLineStringType'
coordinates: 'List[List[MultilineStringCoordinate]]'
bbox: 'Optional[List[float]]' = Field(None)
class MultiPointType(Enum):
MultiPoint = 'MultiPoint'
class Multipoint(BaseModel):
type: 'MultiPointType'
coordinates: 'List[List[float]]'
bbox: 'Optional[List[float]]' = Field(None)
class MultiPolygonType(Enum):
MultiPolygon = 'MultiPolygon'
class MultipolygonCoordinateItem(BaseModel):
__root__: 'List[Any]'
class Multipolygon(BaseModel):
type: 'MultiPolygonType'
coordinates: 'List[List[List[MultipolygonCoordinateItem]]]'
bbox: 'Optional[List[float]]' = Field(None)
class PointType(Enum):
Point = 'Point'
class Point(BaseModel):
type: 'PointType'
coordinates: 'List[float]' = Field(...)
bbox: 'Optional[List[float]]' = Field(None)
class PolygonType(Enum):
Polygon = 'Polygon'
class PolygonCoordinatesItem(BaseModel):
__root__: 'List[Any]'
class Polygon(BaseModel):
type: 'PolygonType'
coordinates: 'List[List[PolygonCoordinatesItem]]'
bbox: 'Optional[List[float]]' = Field(None)
class TimeString(BaseModel):
__root__: 'Union[date, datetime]'
class EnvelopeLiteral(BaseModel):
bbox: 'Bbox'
class GeometryLiteral(BaseModel):
__root__: 'Union[\n Point, Linestring, Polygon, Multipoint, Multilinestring, Multipolygon\n ]'
class TypedTimeString(BaseModel):
datetime: 'TimeString'
class PeriodString(BaseModel):
__root__: 'List[Union[TimeString, str]]' = Field(...)
class SpatialLiteral(BaseModel):
__root__: 'Union[GeometryLiteral, EnvelopeLiteral]'
class TemporalLiteral(BaseModel):
__root__: 'Union[TimeString, PeriodString]'
class TypedPeriodString(BaseModel):
datetime: 'PeriodString'
class TypedTemporalLiteral(BaseModel):
__root__: 'Union[TypedTimeString, TypedPeriodString]'
class ArrayPredicate(BaseModel):
__root__: 'Union[\n AequalsExpression,\n AcontainsExpression,\n AcontainedByExpression,\n AoverlapsExpression,\n ]'
class ComparisonPredicate(BaseModel):
__root__: 'Union[\n BinaryComparisonPredicate,\n IsLikePredicate,\n IsBetweenPredicate,\n IsInListPredicate,\n IsNullPredicate,\n ]'
class SpatialPredicate(BaseModel):
__root__: 'Union[\n IntersectsExpression,\n EqualsExpression,\n DisjointExpression,\n TouchesExpression,\n WithinExpression,\n OverlapsExpression,\n CrossesExpression,\n ContainsExpression,\n ]'
class TemporalPredicate(BaseModel):
__root__: 'Union[\n BeforeExpression,\n AfterExpression,\n MeetsExpression,\n MetbyExpression,\n ToverlapsExpression,\n OverlappedbyExpression,\n BeginsExpression,\n BegunbyExpression,\n DuringExpression,\n TcontainsExpression,\n EndsExpression,\n EndedbyExpression,\n TequalsExpression,\n AnyinteractsExpression,\n ]'
class AcontainedByExpression(BaseModel):
acontainedBy: 'ArrayExpression'
class AcontainsExpression(BaseModel):
acontains: 'ArrayExpression'
class AequalsExpression(BaseModel):
aequals: 'ArrayExpression'
class AfterExpression(BaseModel):
after: 'TemporalOperands'
class AnyinteractsExpression(BaseModel):
anyinteracts: 'TemporalOperands'
class AoverlapsExpression(BaseModel):
aoverlaps: 'ArrayExpression'
class BeforeExpression(BaseModel):
before: 'TemporalOperands'
class BeginsExpression(BaseModel):
begins: 'TemporalOperands'
class BegunbyExpression(BaseModel):
begunby: 'TemporalOperands'
class BinaryComparisonPredicate(BaseModel):
__root__: 'Union[\n EqExpression, LtExpression, GtExpression, LteExpression, GteExpression\n ]'
class ContainsExpression(BaseModel):
contains: 'SpatialOperands'
class CrossesExpression(BaseModel):
crosses: 'SpatialOperands'
class DisjointExpression(BaseModel):
disjoint: 'SpatialOperands'
class DuringExpression(BaseModel):
during: 'TemporalOperands'
class EndedbyExpression(BaseModel):
endedby: 'TemporalOperands'
class EndsExpression(BaseModel):
ends: 'TemporalOperands'
class EqualsExpression(BaseModel):
equals: 'SpatialOperands'
class IntersectsExpression(BaseModel):
intersects: 'SpatialOperands'
class Between(BaseModel):
value: 'ValueExpression'
lower: 'ScalarExpression' = Field(None)
upper: 'ScalarExpression' = Field(None)
class IsBetweenPredicate(BaseModel):
between: 'Between'
class In(BaseModel):
value: 'ValueExpression'
list: 'List[ValueExpression]'
nocase: 'Optional[bool]' = True
class IsInListPredicate(BaseModel):
in_: 'In' = Field(..., alias='in')
class IsLikePredicate(BaseModel):
like: 'ScalarOperands'
wildcard: 'Optional[str]' = '%'
singleChar: 'Optional[str]' = '.'
escapeChar: 'Optional[str]' = '\\'
nocase: 'Optional[bool]' = True
class IsNullPredicate(BaseModel):
isNull: 'ScalarExpression'
class MeetsExpression(BaseModel):
meets: 'TemporalOperands'
class MetbyExpression(BaseModel):
metby: 'TemporalOperands'
class OverlappedbyExpression(BaseModel):
overlappedby: 'TemporalOperands'
class OverlapsExpression(BaseModel):
overlaps: 'SpatialOperands'
class TcontainsExpression(BaseModel):
tcontains: 'TemporalOperands'
class TequalsExpression(BaseModel):
tequals: 'TemporalOperands'
class TouchesExpression(BaseModel):
touches: 'SpatialOperands'
class ToverlapsExpression(BaseModel):
toverlaps: 'TemporalOperands'
class WithinExpression(BaseModel):
within: 'SpatialOperands'
class ArrayExpression(BaseModel):
__root__: 'List[Union[PropertyRef, FunctionRef, ArrayLiteral]]' = Field(
... # , max_items=2, min_items=2
)
class EqExpression(BaseModel):
eq: 'ScalarOperands'
class GtExpression(BaseModel):
gt: 'ScalarOperands'
class GteExpression(BaseModel):
gte: 'ScalarOperands'
class LtExpression(BaseModel):
lt: 'ScalarOperands'
class LteExpression(BaseModel):
lte: 'ScalarOperands'
class ScalarExpression(BaseModel):
__root__: 'Union[ScalarLiteral, PropertyRef,\n FunctionRef, ArithmeticExpression]'
class ScalarOperands(BaseModel):
__root__: 'List[ScalarExpression]' = Field(...)
class SpatialOperands(BaseModel):
__root__: 'List[GeomExpression]' = Field(...)
class TemporalOperands(BaseModel):
__root__: 'List[TemporalExpression]' = Field(...)
# , max_items=2, min_items=2)
class ValueExpression(BaseModel):
__root__: 'Union[ScalarExpression, SpatialLiteral, TypedTemporalLiteral]'
class ArithmeticExpression(BaseModel):
__root__: 'Union[AddExpression, SubExpression, MulExpression, DivExpression]'
class ArrayLiteral(BaseModel):
__root__: 'List[\n Union[\n ScalarLiteral,\n SpatialLiteral,\n TypedTemporalLiteral,\n PropertyRef,\n FunctionRef,\n ArithmeticExpression,\n ArrayLiteral,\n ]\n ]'
class FunctionRef(BaseModel):
function: 'Function'
class GeomExpression(BaseModel):
__root__: 'Union[SpatialLiteral, PropertyRef, FunctionRef]'
class TemporalExpression(BaseModel):
__root__: 'Union[TemporalLiteral, PropertyRef, FunctionRef]'
class AddExpression(BaseModel):
_: 'ArithmeticOperands' = Field(..., alias='+')
class DivExpression(BaseModel):
_: 'Optional[ArithmeticOperands]' = Field(None, alias='/')
class Function(BaseModel):
name: 'str'
arguments: 'Optional[\n List[\n Union[\n ScalarLiteral,\n SpatialLiteral,\n TypedTemporalLiteral,\n PropertyRef,\n FunctionRef,\n ArithmeticExpression,\n ArrayLiteral,\n ]\n ]\n ]' = None
class MulExpression(BaseModel):
_: 'ArithmeticOperands' = Field(..., alias='*')
class SubExpression(BaseModel):
_: 'ArithmeticOperands' = Field(..., alias='-')
class ArithmeticOperands(BaseModel):
__root__: 'List[\n Union[ArithmeticExpression, PropertyRef, FunctionRef, float]\n ]' = Field(...)
CQLModel.update_forward_refs()
AndExpression.update_forward_refs()
ArrayPredicate.update_forward_refs()
ComparisonPredicate.update_forward_refs()
SpatialPredicate.update_forward_refs()
TemporalPredicate.update_forward_refs()
AcontainedByExpression.update_forward_refs()
AcontainsExpression.update_forward_refs()
AequalsExpression.update_forward_refs()
AfterExpression.update_forward_refs()
AnyinteractsExpression.update_forward_refs()
AoverlapsExpression.update_forward_refs()
BeforeExpression.update_forward_refs()
BeginsExpression.update_forward_refs()
BegunbyExpression.update_forward_refs()
BinaryComparisonPredicate.update_forward_refs()
ContainsExpression.update_forward_refs()
CrossesExpression.update_forward_refs()
DisjointExpression.update_forward_refs()
DuringExpression.update_forward_refs()
EndedbyExpression.update_forward_refs()
EndsExpression.update_forward_refs()
EqualsExpression.update_forward_refs()
IntersectsExpression.update_forward_refs()
Between.update_forward_refs()
In.update_forward_refs()
IsBetweenPredicate.update_forward_refs()
IsLikePredicate.update_forward_refs()
IsNullPredicate.update_forward_refs()
ValueExpression.update_forward_refs()
MeetsExpression.update_forward_refs()
MetbyExpression.update_forward_refs()
OverlappedbyExpression.update_forward_refs()
OverlapsExpression.update_forward_refs()
TcontainsExpression.update_forward_refs()
TequalsExpression.update_forward_refs()
TouchesExpression.update_forward_refs()
ToverlapsExpression.update_forward_refs()
WithinExpression.update_forward_refs()
ArrayExpression.update_forward_refs()
EqExpression.update_forward_refs()
GtExpression.update_forward_refs()
GteExpression.update_forward_refs()
LtExpression.update_forward_refs()
LteExpression.update_forward_refs()
ScalarExpression.update_forward_refs()
ScalarOperands.update_forward_refs()
SpatialOperands.update_forward_refs()
TemporalOperands.update_forward_refs()
ArithmeticExpression.update_forward_refs()
ArrayLiteral.update_forward_refs()
ScalarLiteral.update_forward_refs()
PropertyRef.update_forward_refs()
FunctionRef.update_forward_refs()
AddExpression.update_forward_refs()
DivExpression.update_forward_refs()
MulExpression.update_forward_refs()
SubExpression.update_forward_refs()
def get_next_node(obj):
logical_op = None
if obj.__repr_name__() == 'AndExpression':
next_node = obj.and_
logical_op = 'and'
elif obj.__repr_name__() == 'OrExpression':
next_node = obj.or_
logical_op = 'or'
elif obj.__repr_name__() == 'NotExpression':
next_node = obj.not_
logical_op = 'not'
elif obj.__repr_name__() == 'ComparisonPredicate':
next_node = obj.__root__
elif obj.__repr_name__() == 'SpatialPredicate':
next_node = obj.__root__
elif obj.__repr_name__() == 'TemporalPredicate':
next_node = obj.__root__
elif obj.__repr_name__() == 'IsBetweenPredicate':
next_node = obj.between
elif obj.__repr_name__() == 'Between':
next_node = obj.value
elif obj.__repr_name__() == 'ValueExpression':
next_node = obj.__root__ or obj.lower or obj.upper
elif obj.__repr_name__() == 'ScalarExpression':
next_node = obj.__root__
elif obj.__repr_name__() == 'ScalarLiteral':
next_node = obj.__root__
elif obj.__repr_name__() == 'PropertyRef':
next_node = obj.property
elif obj.__repr_name__() == 'BinaryComparisonPredicate':
next_node = obj.__root__
elif obj.__repr_name__() == 'EqExpression':
next_node = obj.eq
logical_op = 'eq'
else:
raise ValueError("Object not valid")
return (logical_op, next_node)
+196 -1
View File
@@ -3,6 +3,7 @@
# Authors: Tom Kralidis <tomkralidis@gmail.com>
#
# Copyright (c) 2021 Tom Kralidis
# Copyright (c) 2021 Francesco Bartoli
#
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation
@@ -27,6 +28,7 @@
#
# =================================================================
from typing import Dict
from collections import OrderedDict
import json
import logging
@@ -34,10 +36,14 @@ from urllib.parse import urlparse
from elasticsearch import Elasticsearch, exceptions, helpers
from elasticsearch.client.indices import IndicesClient
from elasticsearch_dsl import Search, Q
from pygeoapi.provider.base import (BaseProvider, ProviderConnectionError,
ProviderQueryError,
ProviderItemNotFoundError)
from pygeoapi.models.cql import CQLModel, get_next_node
from pygeoapi.util import get_envelope
LOGGER = logging.getLogger(__name__)
@@ -144,7 +150,8 @@ class ElasticsearchProvider(BaseProvider):
def query(self, startindex=0, limit=10, resulttype='results',
bbox=[], datetime_=None, properties=[], sortby=[],
select_properties=[], skip_geometry=False, q=None, **kwargs):
select_properties=[], skip_geometry=False, q=None,
filterq=None, **kwargs):
"""
query Elasticsearch index
@@ -158,6 +165,7 @@ class ElasticsearchProvider(BaseProvider):
:param select_properties: list of property names
:param skip_geometry: bool of whether to skip geometry (default False)
:param q: full-text search term(s)
:param filterq: filter object
:returns: dict of 0..n GeoJSON features
"""
@@ -291,6 +299,9 @@ class ElasticsearchProvider(BaseProvider):
query['_source'] = {'excludes': ['geometry']}
try:
LOGGER.debug('querying Elasticsearch')
if filterq:
LOGGER.debug('adding cql object: {}'.format(filterq.json()))
query = update_query(input_query=query, cql=filterq)
LOGGER.debug(json.dumps(query, indent=4))
LOGGER.debug('Setting ES paging zero-based')
@@ -495,3 +506,187 @@ class ElasticsearchCatalogueProvider(ElasticsearchProvider):
def __repr__(self):
return '<ElasticsearchCatalogueProvider> {}'.format(self.data)
class ESQueryBuilder:
def __init__(self):
self._operation = None
self.must_value = {}
self.should_value = {}
self.mustnot_value = {}
self.filter_value = {}
def must(self, must_value):
self.must_value = must_value
return self
def should(self, should_value):
self.should_value = should_value
return self
def must_not(self, mustnot_value):
self.mustnot_value = mustnot_value
return self
def filter(self, filter_value):
self.filter_value = filter_value
return self
@property
def operation(self):
return self._operation
@operation.setter
def operation(self, value):
self._operation = value
def build(self):
if self.must_value:
must_clause = self.must_value or {}
if self.should_value:
should_clause = self.should_value or {}
if self.mustnot_value:
mustnot_clause = self.mustnot_value or {}
if self.filter_value:
filter_clause = self.filter_value or {}
else:
filter_clause = {}
# to figure out how to deal with logical operations
# return match_clause & range_clause
clauses = must_clause or should_clause or mustnot_clause
filters = filter_clause
if self.operation == 'and':
res = Q(
'bool',
must=[clause for clause in clauses],
filter=[filter for filter in filters])
elif self.operation == 'or':
res = Q(
'bool',
should=[clause for clause in clauses],
filter=[filter for filter in filters])
elif self.operation == 'not':
res = Q(
'bool',
must_not=[clause for clause in clauses],
filter=[filter for filter in filters])
else:
if filters:
res = Q(
'bool',
must=[clauses],
filter=[filters])
else:
res = Q(
'bool',
must=[clauses])
return res
def _build_query(q, cql):
# this would be handled by the AST with the traverse of CQL model
op, node = get_next_node(cql.__root__)
q.operation = op
if isinstance(node, list):
query_list = []
for elem in node:
op, next_node = get_next_node(elem)
if not getattr(next_node, 'between', 0) == 0:
property = next_node.between.value.__root__.__root__.property
lower = next_node.between.lower.__root__.__root__
upper = next_node.between.upper.__root__.__root__
query_list.append(Q(
{
'range':
{
f'{property}': {
'gte': lower, 'lte': upper
}
}
}
))
if not getattr(next_node, '__root__', 0) == 0:
scalars = tuple(next_node.__root__.eq.__root__)
property = scalars[0].__root__.property
value = scalars[1].__root__.__root__
query_list.append(Q(
{'match': {f'{property}': f'{value}'}}
))
q.must(query_list)
elif not getattr(node, 'between', 0) == 0:
property = node.between.value.__root__.__root__.property
lower = None
if not getattr(node.between.lower,
'__root__', 0) == 0:
lower = node.between.lower.__root__.__root__
upper = None
if not getattr(node.between.upper,
'__root__', 0) == 0:
upper = node.between.upper.__root__.__root__
query = Q(
{
'range':
{
f'{property}': {
'gte': lower, 'lte': upper
}
}
}
)
q.must(query)
elif not getattr(node, '__root__', 0) == 0:
next_op, next_node = get_next_node(node)
if not getattr(next_node, 'eq', 0) == 0:
scalars = tuple(next_node.eq.__root__)
property = scalars[0].__root__.property
value = scalars[1].__root__.__root__
query = Q(
{'match': {f'{property}': f'{value}'}}
)
q.must(query)
elif not getattr(node, 'intersects', 0) == 0:
property = node.intersects.__root__[0].__root__.property
if property == 'geometry':
geom_type = node.intersects.__root__[
1].__root__.__root__.__root__.type
if geom_type.value == 'Polygon':
coordinates = node.intersects.__root__[
1].__root__.__root__.__root__.coordinates
coords_list = [
poly_coords.__root__ for poly_coords in coordinates[0]
]
filter_ = Q(
{
'geo_shape': {
'geometry': {
'shape': {
'type': 'envelope',
'coordinates': get_envelope(
coords_list)
},
'relation': 'intersects'
}
}
}
)
query_all = Q(
{'match_all': {}}
)
q.must(query_all)
q.filter(filter_)
return q.build()
def update_query(input_query: Dict, cql: CQLModel):
s = Search.from_dict(input_query)
query = ESQueryBuilder()
output_query = _build_query(query, cql)
s = s.query(output_query)
LOGGER.debug('Enhanced query: {}'.format(
json.dumps(s.to_dict())
))
return s.to_dict()
+10 -4
View File
@@ -220,8 +220,8 @@ async def get_collection_items_tiles(request: Request, name=None,
request, name, tileMatrixSetId, tile_matrix, tileRow, tileCol))
@app.route('/collections/{collection_id}/items')
@app.route('/collections/{collection_id}/items/')
@app.route('/collections/{collection_id}/items', methods=['GET', 'POST'])
@app.route('/collections/{collection_id}/items/', methods=['GET', 'POST'])
@app.route('/collections/{collection_id}/items/{item_id}')
@app.route('/collections/{collection_id}/items/{item_id}/')
async def collection_items(request: Request, collection_id=None, item_id=None):
@@ -239,8 +239,14 @@ async def collection_items(request: Request, collection_id=None, item_id=None):
if 'item_id' in request.path_params:
item_id = request.path_params['item_id']
if item_id is None:
return get_response(api_.get_collection_items(
request, collection_id, pathinfo=request.scope['path']))
if request.method == 'GET': # list items
return get_response(
api_.get_collection_items(
request, collection_id))
elif request.method == 'POST': # filter items
return get_response(
api_.post_collection_items(
request, collection_id))
else:
return get_response(api_.get_collection_item(
request, collection_id, item_id))
+19
View File
@@ -30,6 +30,7 @@
"""Generic util functions used in the code"""
import base64
from typing import List
from datetime import date, datetime, time
from decimal import Decimal
from enum import Enum
@@ -41,6 +42,7 @@ import os
import re
from urllib.request import urlopen
from urllib.parse import urlparse
from shapely.geometry import Polygon
import dateutil.parser
# from babel.support import Translations
@@ -511,3 +513,20 @@ def url_join(*parts):
"""
return '/'.join([p.strip().strip('/') for p in parts])
def get_envelope(coords_list: List[List[float]]):
"""
helper function to get the envelope for a given coordinates
list through the Shapely API.
:param coords_list: list of coordinates
:returns: list of the envelope's coordinates
"""
coords = [tuple(item) for item in coords_list]
polygon = Polygon(coords)
bounds = polygon.bounds
return [[bounds[0], bounds[3]],
[bounds[2], bounds[1]]]