Files
pygeoapi/pygeoapi/api.py
T
2019-05-06 14:50:44 +00:00

735 lines
24 KiB
Python

# =================================================================
#
# Authors: Tom Kralidis <tomkralidis@gmail.com>
#
# Copyright (c) 2019 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.
#
# =================================================================
from datetime import datetime
import json
import logging
import os
from jinja2 import Environment, FileSystemLoader
from pygeoapi import __version__
from pygeoapi.log import setup_logger
from pygeoapi.plugin import load_plugin, PLUGINS
from pygeoapi.provider.base import ProviderConnectionError, ProviderQueryError
LOGGER = logging.getLogger(__name__)
TEMPLATES = '{}{}templates'.format(os.path.dirname(
os.path.realpath(__file__)), os.sep)
HEADERS = {
'Content-Type': 'application/json',
'X-Powered-By': 'pygeoapi {}'.format(__version__)
}
FORMATS = ['json', 'html']
class API(object):
"""API object"""
def __init__(self, config):
"""
constructor
:param config: configuration dict
:returns: `pygeoapi.API` instance
"""
self.config = config
self.config['server']['url'] = self.config['server']['url'].rstrip('/')
if 'templates' not in self.config['server']:
self.config['server']['templates'] = TEMPLATES
setup_logger(self.config['logging'])
def root(self, headers, args):
"""
Provide API
:param headers: dict of HTTP headers
:param args: dict of HTTP request parameters
:returns: tuple of headers, status code, content
"""
headers_ = HEADERS.copy()
format_ = check_format(args, headers)
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)
fcm = {
'links': [],
}
LOGGER.debug('Creating links')
fcm['links'] = [{
'rel': 'self',
'type': 'application/json',
'title': 'This document as JSON',
'href': self.config['server']['url']
}, {
'rel': 'self',
'type': 'text/html',
'title': 'This document as HTML',
'href': '{}/?f=html'.format(self.config['server']['url']),
'hreflang': self.config['server']['language']
}, {
'rel': 'self',
'type': 'application/openapi+json;version=3.0',
'title': 'The OpenAPI definition as JSON',
'href': '{}/api'.format(self.config['server']['url'])
}, {
'rel': 'self',
'type': 'text/html',
'title': 'The OpenAPI definition as HTML',
'href': '{}/api?f=html'.format(self.config['server']['url']),
'hreflang': self.config['server']['language']
}
]
if format_ == 'html': # render
for link in fcm['links']:
if 'json' in link['type']:
link['href'] = ''.join((link['href'], '?f=json'))
headers_['Content-Type'] = 'text/html'
content = _render_j2_template(self.config, 'root.html', fcm)
return headers_, 200, content
return headers_, 200, json.dumps(fcm)
def api(self, headers, args, request_path, openapi):
"""
Provide OpenAPI document
:param headers: dict of HTTP headers
:param args: dict of HTTP request parameters
:param openapi: dict of OpenAPI definition
:returns: tuple of headers, status code, content
"""
headers_ = HEADERS.copy()
format_ = check_format(args, headers)
if format_ == 'html':
data = {
'openapi-document-path': request_path
}
headers_['Content-Type'] = 'text/html'
content = _render_j2_template(self.config, 'api.html', data)
return headers_, 200, content
headers_['Content-Type'] = 'application/openapi+json;version=3.0'
return headers_, 200, json.dumps(openapi)
def api_conformance(self, headers, args):
"""
Provide conformance definition
:param headers: dict of HTTP headers
:param args: dict of HTTP request parameters
:returns: tuple of headers, status code, content
"""
headers_ = HEADERS.copy()
format_ = check_format(args, headers)
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)
conformance = {
'conformsTo': [
'http://www.opengis.net/spec/wfs-1/3.0/req/core',
'http://www.opengis.net/spec/wfs-1/3.0/req/oas30',
'http://www.opengis.net/spec/wfs-1/3.0/req/html',
'http://www.opengis.net/spec/wfs-1/3.0/req/geojson'
]
}
if format_ == 'html': # render
headers_['Content-Type'] = 'text/html'
content = _render_j2_template(self.config, 'conformance.html',
conformance)
return headers_, 200, content
return headers_, 200, json.dumps(conformance)
def describe_collections(self, headers, args, dataset=None):
"""
Provide feature collection metadata
:param headers: dict of HTTP headers
:param args: dict of HTTP request parameters
:param dataset: name of collection
:returns: tuple of headers, status code, content
"""
headers_ = HEADERS.copy()
format_ = check_format(args, headers)
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)
fcm = {
'collections': []
}
if all([dataset is not None,
dataset not in self.config['datasets'].keys()]):
exception = {
'code': 'InvalidParameterValue',
'description': 'Invalid feature collection'
}
LOGGER.error(exception)
return headers_, 400, json.dumps(exception)
LOGGER.debug('Creating collections')
for k, v in self.config['datasets'].items():
collection = {'links': [], 'crs': []}
collection['name'] = k
collection['title'] = v['title']
collection['description'] = v['description']
for crs in v['crs']:
collection['crs'].append(
'http://www.opengis.net/def/crs/OGC/1.3/{}'.format(crs))
collection['extent'] = v['extents']['spatial']['bbox']
for link in v['links']:
lnk = {
'type': link['type'],
'rel': link['rel'],
'title': link['title'],
'href': link['href']
}
if 'hreflang' in link:
lnk['hreflang'] = link['hreflang']
collection['links'].append(lnk)
if dataset is not None and k == dataset:
fcm = collection
break
fcm['collections'].append(collection)
if format_ == 'html': # render
headers_['Content-Type'] = 'text/html'
if dataset is not None:
content = _render_j2_template(self.config, 'collection.html',
fcm)
else:
content = _render_j2_template(self.config, 'collections.html',
fcm)
return headers_, 200, content
return headers_, 200, json.dumps(fcm)
def get_features(self, headers, args, dataset):
"""
Queries feature collection
:param headers: dict of HTTP headers
:param args: dict of HTTP request parameters
:param dataset: dataset name
:returns: tuple of headers, status code, content
"""
headers_ = HEADERS.copy()
properties = []
reserved_fieldnames = ['bbox', 'f', 'limit', 'startindex',
'resulttype', 'time']
formats = FORMATS
formats.extend(f.lower() for f in PLUGINS['formatter'].keys())
if dataset not in self.config['datasets'].keys():
exception = {
'code': 'InvalidParameterValue',
'description': 'Invalid feature collection'
}
LOGGER.error(exception)
return headers_, 400, json.dumps(exception)
format_ = check_format(args, headers)
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)
LOGGER.debug('Processing query parameters')
try:
startindex = int(args.get('startindex'))
except TypeError:
startindex = 0
try:
limit = int(args.get('limit'))
except TypeError:
limit = self.config['server']['limit']
resulttype = args.get('resulttype') or 'results'
try:
bbox = args.get('bbox').split(',')
if len(bbox) != 4:
exception = {
'code': 'InvalidParameterValue',
'description': 'bbox values should be minx,miny,maxx,maxy'
}
LOGGER.error(exception)
return headers_, 400, json.dumps(exception)
except AttributeError:
bbox = []
time = args.get('time')
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)
LOGGER.debug('processing property parameters')
for k, v in args.items():
if k not in reserved_fieldnames and k in p.fields.keys():
properties.append((k, v))
LOGGER.debug('processing sort parameter')
val = args.get('sortby')
if val is not None:
sortby = []
sorts = val.split(',')
for s in sorts:
if ':' in s:
prop, order = s.split(':')
if order not in ['A', 'D']:
exception = {
'code': 'InvalidParameterValue',
'description': 'sort order should be A or D'
}
LOGGER.error(exception)
return headers_, 400, json.dumps(exception)
sortby.append({'property': prop, 'order': order})
else:
sortby.append({'property': s, 'order': 'A'})
for s in sortby:
if s['property'] not in p.fields.keys():
exception = {
'code': 'InvalidParameterValue',
'description': 'bad sort property'
}
LOGGER.error(exception)
return headers_, 400, json.dumps(exception)
else:
sortby = []
LOGGER.debug('Querying provider')
LOGGER.debug('startindex: {}'.format(startindex))
LOGGER.debug('limit: {}'.format(limit))
LOGGER.debug('resulttype: {}'.format(resulttype))
LOGGER.debug('sortby: {}'.format(sortby))
try:
content = p.query(startindex=int(startindex), limit=int(limit),
resulttype=resulttype, bbox=bbox, time=time,
properties=properties, sortby=sortby)
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)
prev = startindex - self.config['server']['limit']
if prev < 0:
prev = 0
next_ = startindex + self.config['server']['limit']
content['links'] = [{
'type': 'application/json',
'rel': 'self',
'title': 'Collection items',
'href': '{}collections/{}/items'.format(
self.config['server']['url'], dataset)
}, {
'type': 'application/json',
'rel': 'prev',
'title': 'items (prev)',
'href': '{}/collections/{}/items/?startindex={}'.format(
self.config['server']['url'], dataset, prev)
}, {
'type': 'application/json',
'rel': 'next',
'title': 'items (next)',
'href': '{}/collections/{}/items/?startindex={}'.format(
self.config['server']['url'], dataset, next_)
}, {
'type': 'application/json',
'title': self.config['datasets'][dataset]['title'],
'rel': 'collection',
'href': '{}/collections/{}'.format(
self.config['server']['url'], dataset)
}
]
content['timeStamp'] = datetime.utcnow().isoformat()
if format_ == 'html': # render
headers_['Content-Type'] = 'text/html'
# For constructing proper URIs to Items
path_info = headers.environ['PATH_INFO']
if path_info.endswith('/'):
path_info = path_info[:-1]
content['items_path'] = path_info
content['dataset_path'] = '/'.join(path_info.split('/')[:-1])
content['collections_path'] = '/'.join(path_info.split('/')[:-2])
content['startindex'] = startindex
content = _render_j2_template(self.config, 'items.html',
content)
return headers_, 200, content
elif format_ == 'csv': # render
formatter = load_plugin('formatter', {'name': 'CSV', 'geom': True})
content = formatter.write(
data=content,
options={
'provider_def':
self.config['datasets'][dataset]['provider']
}
)
headers_['Content-Type'] = '{}; charset={}'.format(
formatter.mimetype, self.config['server']['encoding'])
cd = 'attachment; filename="{}.csv"'.format(dataset)
headers_['Content-Disposition'] = cd
return headers_, 200, content
return headers_, 200, json.dumps(content)
def get_feature(self, headers, args, dataset, identifier):
"""
Get a single feature
:param headers: dict of HTTP headers
:param args: dict of HTTP request parameters
:param dataset: dataset name
:param identifier: feature identifier
:returns: tuple of headers, status code, content
"""
headers_ = HEADERS.copy()
format_ = check_format(args, headers)
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)
LOGGER.debug('Processing query parameters')
if dataset not in self.config['datasets'].keys():
exception = {
'code': 'InvalidParameterValue',
'description': 'Invalid feature collection'
}
LOGGER.error(exception)
return headers_, 400, json.dumps(exception)
LOGGER.debug('Loading provider')
p = load_plugin('provider',
self.config['datasets'][dataset]['provider'])
LOGGER.debug('Fetching id {}'.format(identifier))
content = p.get(identifier)
if content is None:
exception = {
'code': 'NotFound',
'description': 'identifier not found'
}
LOGGER.error(exception)
return headers_, 404, json.dumps(exception)
content['links'] = [{
'rel': 'self',
'type': 'application/json',
'href': '{}/collections/{}/items/{}'.format(
self.config['server']['url'], dataset, identifier)
}, {
'rel': 'collection',
'type': 'application/json',
'title': self.config['datasets'][dataset]['title'],
'href': '{}/collections/{}'.format(
self.config['server']['url'], dataset)
}, {
'rel': 'prev',
'type': 'application/json',
'href': '{}/collections/{}/items/{}'.format(
self.config['server']['url'], dataset, identifier)
}, {
'rel': 'next',
'type': 'application/json',
'href': '{}/collections/{}/items/{}'.format(
self.config['server']['url'], dataset, identifier)
}
]
if format_ == 'html': # render
headers_['Content-Type'] = 'text/html'
content = _render_j2_template(self.config, 'item.html',
content)
return headers_, 200, content
return headers_, 200, json.dumps(content)
def describe_processes(self, headers, args, process=None):
"""
Provide processes metadata
:param headers: dict of HTTP headers
:param args: dict of HTTP request parameters
:param process: name of process
:returns: tuple of headers, status code, content
"""
headers_ = HEADERS.copy()
processes_config = self.config['processes']
if process is not None:
if process not in processes_config.keys():
exception = {
'code': 'NotFound',
'description': 'identifier not found'
}
LOGGER.error(exception)
return headers_, 404, json.dumps(exception)
p = load_plugin('process', processes_config[process]['processor'])
p.metadata['jobControlOptions'] = ['sync-execute']
p.metadata['outputTransmission'] = ['value']
response = p.metadata
else:
processes = []
for k, v in processes_config.items():
p = load_plugin('process', processes_config[k]['processor'])
p.metadata['jobControlOptions'] = ['sync-execute']
p.metadata['outputTransmission'] = ['value']
processes.append(p.metadata)
response = {
'processes': processes
}
return headers_, 200, json.dumps(response)
def execute_process(self, headers, args, data, process):
"""
Execute process
:param headers: dict of HTTP headers
:param args: dict of HTTP request parameters
:param data: process data
:param process: name of process
:returns: tuple of headers, status code, content
"""
headers_ = HEADERS.copy()
data_dict = {}
response = {}
if not data:
exception = {
'code': 'MissingParameterValue',
'description': 'missing request data'
}
LOGGER.error(exception)
return headers_, 400, json.dumps(exception)
if process not in self.config['processes'].keys():
exception = {
'code': 'NotFound',
'description': 'identifier not found'
}
LOGGER.error(exception)
return headers_, 404, json.dumps(exception)
p = load_plugin('process',
self.config['processes'][process]['processor'])
data_ = json.loads(data)
for input_ in data_['inputs']:
data_dict[input_['id']] = input_['value']
try:
outputs = p.execute(data_dict)
response['outputs'] = outputs
return headers_, 201, json.dumps(response)
except Exception as err:
exception = {
'code': 'InvalidParameterValue',
'description': str(err)
}
LOGGER.error(exception)
return headers_, 400, json.dumps(exception)
def check_format(args, headers):
"""
check format requested from arguments or headers
:param args: dict of request keyword value pairs
:param headers: dict of request headers
:returns: format value
"""
# Optional f=html or f=json query param
# overrides accept
format_ = args.get('f')
if format_:
return format_
# Format not specified: get from accept headers
# format_ = 'text/html'
headers_ = None
if 'accept' in headers.keys():
headers_ = headers['accept']
elif 'Accept' in headers.keys():
headers_ = headers['Accept']
format_ = None
if headers_:
headers_ = headers_.split(',')
if 'text/html' in headers_:
format_ = 'html'
elif 'application/json' in headers_:
format_ = 'json'
return format_
def to_json(dict_):
"""
serialize dict to json
:param dict_: dict_
:returns: JSON string representation
"""
return json.dumps(dict_)
def _render_j2_template(config, template, data):
"""
render Jinja2 template
:param config: dict of configuration
:param template: template (relative path)
:param data: dict of data
:returns: string of rendered template
"""
env = Environment(loader=FileSystemLoader(TEMPLATES))
env.filters['to_json'] = to_json
env.globals.update(to_json=to_json)
template = env.get_template(template)
return template.render(config=config, data=data, version=__version__)