Proposal to split api.py into different files (#1405)

* Move api to subdirectory

* Move processes api to own file

* Adapt processes view methods

* Move openapi definition to processes api

* Use processes api in flask

* Linter

* Fix import issues

* Allow calling refactored views from starlette

* Allow calling refactored views from django

* Linter

* Move edr api to own file

* Adapt edr api to new style

* Fix typo in django views

* Move maps api to own file

* Adapt maps api to new style

* Move edr openapi to edr api file

* Move maps openapi to maps api file

* Move stac views to own file

* Refactor stac views to new file

* Move stac openapi to stac api file

* Move tiles api to own file

* Adapt tiles api to new style

* Also move tilematrixset to tiles api

* Adapt tilesetmatrix views to new style

NOTE: I had to remove one tilematrixsets test because
it tested that an invalid format would produce an error.
This now happens by default for all views, but the actual
code is outside of the endpoint function.

* update features, records, coverages

* update release version

* switch back to dev

* backport of #1313

* backport of #1313 fix

* backport of #1585

* Flask: sanitize OGC schema pathing (#1593)

* update release version

* switch back to dev

* backport of #1596

* Port test_gzip_csv test

Note that apply_gzip is now called by the web framework adapters,
so to test it in general, we have to call it in the test manually

* Add empty conformance class list to stac api

* Fix queryables call in starlette

* fix ref

* Unify request validity checking

The default case is handled by the web framework adapters. If custom
format handling is required, the check in the adapter must be skipped.

* Fix imports in django views

* backport #1598

* Remove test about format handling in endpoint

This is now handled outside of the endpoint function

* add docstring to base process manager (#1603)

* backport of #1601

* Port api ogr tests to new style

* Move processes tests to own file

* Run api tests from new dir in CI

* Move edr tests to own file

* Move maps tests to own file

* Move tiles tests to own file

* Actually hide hidden layers in openapi

* 1600 allow providing default value in config (#1604)

* move coverages tests to own file

* move itemtypes to own file, move core into init test

* fix OpenAPI output

* update tests

* add missing descriptions to OpenAPI admin responses

* update tests

* fix tests autodiscovery

* remove unused logging in tests

* address PR comments

* test with xarray 2024.2.0

* remove unneeded file

* safeguard xarray error

* unpin xarray

* fix OpenAPI generation

* fix schema endpoint in Flask and Starlette

* Safely serialize configuration JSON (#1605)

* Safely serialize configuration JSON

Co-Authored-By: Tom Kralidis <tomkralidis@gmail.com>

* Revert "Safely serialize configuration JSON"

This reverts commit 36feb067ee6f87e61955852dc48994f075806370.

* Add test for datetime with Admin API

* Safely serialize configuration JSON

---------

Co-authored-by: Tom Kralidis <tomkralidis@gmail.com>

* backport #1611

* Also fix schema endpoint for django

Fix is analogous to e72d4ba3a5ba3b8621ca839e7814429beeeb8f01

* address additional PR comments

---------

Co-authored-by: Tom Kralidis <tomkralidis@gmail.com>
Co-authored-by: Angelos Tzotsos <gcpp.kalxas@gmail.com>
Co-authored-by: Ricardo Garcia Silva <ricardo.garcia.silva@gmail.com>
Co-authored-by: Benjamin Webb <40066515+webb-ben@users.noreply.github.com>
This commit is contained in:
Bernhard Mallinger
2024-04-05 12:06:25 +02:00
committed by GitHub
parent 36e75f8b72
commit 35bdcb6f02
31 changed files with 8918 additions and 7812 deletions
+1 -1
View File
@@ -109,7 +109,7 @@ jobs:
env:
POSTGRESQL_PASSWORD: ${{ secrets.DatabasePassword || 'postgres' }}
run: |
pytest tests/test_api.py
pytest tests/api
pytest tests/test_api_ogr_provider.py
pytest tests/test_config.py
pytest tests/test_csv__formatter.py
+1 -1
View File
@@ -20,7 +20,7 @@ Tests can be run locally as part of development workflow. They are also run on
`GitHub Actions setup`_ against all commits and pull requests to the code repository.
To run all tests, simply run ``pytest`` in the repository. To run a specific test file,
run ``pytest tests/test_api.py``, for example.
run ``pytest tests/api/test_itemtypes.py``, for example.
CQL extension lifecycle
-4319
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+253
View File
@@ -0,0 +1,253 @@
# =================================================================
# Authors: Tom Kralidis <tomkralidis@gmail.com>
# Francesco Bartoli <xbartolone@gmail.com>
# Sander Schaminee <sander.schaminee@geocat.net>
# John A Stevenson <jostev@bgs.ac.uk>
# Colin Blackburn <colb@bgs.ac.uk>
# Ricardo Garcia Silva <ricardo.garcia.silva@geobeyond.it>
# Bernhard Mallinger <bernhard.mallinger@eox.at>
#
# Copyright (c) 2024 Tom Kralidis
# Copyright (c) 2022 Francesco Bartoli
# Copyright (c) 2022 John A Stevenson and Colin Blackburn
# Copyright (c) 2023 Ricardo Garcia Silva
# Copyright (c) 2024 Bernhard Mallinger
#
# 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 logging
from http import HTTPStatus
from typing import Tuple
from pygeoapi import l10n
from pygeoapi.plugin import load_plugin
from pygeoapi.provider.base import ProviderGenericError, ProviderTypeError
from pygeoapi.util import (
filter_dict_by_key_value, get_provider_by_type, to_json
)
from . import (
APIRequest, API, F_JSON, SYSTEM_LOCALE, validate_bbox, validate_datetime,
validate_subset
)
LOGGER = logging.getLogger(__name__)
CONFORMANCE_CLASSES = [
'http://www.opengis.net/spec/ogcapi-coverages-1/1.0/conf/core',
'http://www.opengis.net/spec/ogcapi-coverages-1/1.0/conf/oas30',
'http://www.opengis.net/spec/ogcapi-coverages-1/1.0/conf/html',
'http://www.opengis.net/spec/ogcapi-coverages-1/1.0/conf/geodata-coverage',
'http://www.opengis.net/spec/ogcapi-coverages-1/1.0/conf/coverage-subset',
'http://www.opengis.net/spec/ogcapi-coverages-1/1.0/conf/coverage-rangesubset', # noqa
'http://www.opengis.net/spec/ogcapi-coverages-1/1.0/conf/coverage-bbox',
'http://www.opengis.net/spec/ogcapi-coverages-1/1.0/conf/coverage-datetime'
]
def get_collection_coverage(
api: API, request: APIRequest, dataset) -> Tuple[dict, int, str]:
"""
Returns a subset of a collection coverage
:param request: A request object
:param dataset: dataset name
:returns: tuple of headers, status code, content
"""
query_args = {}
format_ = request.format or F_JSON
# Force response content type and language (en-US only) headers
headers = request.get_response_headers(SYSTEM_LOCALE, **api.api_headers)
LOGGER.debug('Loading provider')
try:
collection_def = get_provider_by_type(
api.config['resources'][dataset]['providers'], 'coverage')
p = load_plugin('provider', collection_def)
except KeyError:
msg = 'collection does not exist'
return api.get_exception(
HTTPStatus.NOT_FOUND, headers, format_,
'InvalidParameterValue', msg)
except ProviderGenericError as err:
LOGGER.error(err)
return api.get_exception(
err.http_status_code, headers, request.format,
err.ogc_exception_code, err.message)
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 api.get_exception(
HTTPStatus.INTERNAL_SERVER_ERROR, headers, format_,
'InvalidParameterValue', msg)
query_args['bbox'] = bbox
LOGGER.debug('Processing bbox-crs parameter')
bbox_crs = request.params.get('bbox-crs')
if bbox_crs is not None:
query_args['bbox_crs'] = bbox_crs
LOGGER.debug('Processing datetime parameter')
datetime_ = request.params.get('datetime')
try:
datetime_ = validate_datetime(
api.config['resources'][dataset]['extents'], datetime_)
except ValueError as err:
msg = str(err)
return api.get_exception(
HTTPStatus.BAD_REQUEST, headers, format_,
'InvalidParameterValue', msg)
query_args['datetime_'] = datetime_
query_args['format_'] = format_
properties = request.params.get('properties')
if properties:
LOGGER.debug('Processing properties parameter')
query_args['properties'] = [rs for
rs in properties.split(',') if rs]
LOGGER.debug(f"Fields: {query_args['properties']}")
for a in query_args['properties']:
if a not in p.fields:
msg = 'Invalid field specified'
return api.get_exception(
HTTPStatus.BAD_REQUEST, headers, format_,
'InvalidParameterValue', msg)
if 'subset' in request.params:
LOGGER.debug('Processing subset parameter')
try:
subsets = validate_subset(request.params['subset'] or '')
except (AttributeError, ValueError) as err:
msg = f'Invalid subset: {err}'
LOGGER.error(msg)
return api.get_exception(
HTTPStatus.BAD_REQUEST, headers, format_,
'InvalidParameterValue', msg)
if not set(subsets.keys()).issubset(p.axes):
msg = 'Invalid axis name'
LOGGER.error(msg)
return api.get_exception(
HTTPStatus.BAD_REQUEST, headers, format_,
'InvalidParameterValue', msg)
query_args['subsets'] = subsets
LOGGER.debug(f"Subsets: {query_args['subsets']}")
LOGGER.debug('Querying coverage')
try:
data = p.query(**query_args)
except ProviderGenericError as err:
LOGGER.error(err)
return api.get_exception(
err.http_status_code, headers, request.format,
err.ogc_exception_code, err.message)
mt = collection_def['format']['name']
if format_ == mt: # native format
if p.filename is not None:
cd = f'attachment; filename="{p.filename}"'
headers['Content-Disposition'] = cd
headers['Content-Type'] = collection_def['format']['mimetype']
return headers, HTTPStatus.OK, data
elif format_ == F_JSON:
headers['Content-Type'] = 'application/prs.coverage+json'
return headers, HTTPStatus.OK, to_json(data, api.pretty_print)
else:
return api.get_format_exception(request)
def get_oas_30(cfg: dict, locale: str) -> tuple[list[dict[str, str]], dict[str, dict]]: # noqa
"""
Get OpenAPI fragments
:param cfg: `dict` of configuration
:param locale: `str` of locale
:returns: `tuple` of `list` of tag objects, and `dict` of path objects
"""
from pygeoapi.openapi import OPENAPI_YAML, get_visible_collections
paths = {}
collections = filter_dict_by_key_value(cfg['resources'],
'type', 'collection')
for k, v in get_visible_collections(cfg).items():
try:
load_plugin('provider', get_provider_by_type(
collections[k]['providers'], 'coverage'))
except ProviderTypeError:
LOGGER.debug('collection is not coverage based')
continue
coverage_path = f'/collections/{k}/coverage'
title = l10n.translate(v['title'], locale)
description = l10n.translate(v['description'], locale)
paths[coverage_path] = {
'get': {
'summary': f'Get {title} coverage',
'description': description,
'tags': [k],
'operationId': f'get{k.capitalize()}Coverage',
'parameters': [
{'$ref': '#/components/parameters/lang'},
{'$ref': '#/components/parameters/f'},
{'$ref': '#/components/parameters/bbox'},
{'$ref': '#/components/parameters/bbox-crs'}
],
'responses': {
'200': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/Features"}, # noqa
'400': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/InvalidParameter"}, # noqa
'404': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/NotFound"}, # noqa
'500': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/ServerError"} # noqa
}
}
}
return [{'name': 'coverages'}], {'paths': paths}
@@ -0,0 +1,332 @@
# =================================================================
# Authors: Tom Kralidis <tomkralidis@gmail.com>
# Francesco Bartoli <xbartolone@gmail.com>
# Sander Schaminee <sander.schaminee@geocat.net>
# John A Stevenson <jostev@bgs.ac.uk>
# Colin Blackburn <colb@bgs.ac.uk>
# Ricardo Garcia Silva <ricardo.garcia.silva@geobeyond.it>
# Bernhard Mallinger <bernhard.mallinger@eox.at>
#
# Copyright (c) 2024 Tom Kralidis
# Copyright (c) 2022 Francesco Bartoli
# Copyright (c) 2022 John A Stevenson and Colin Blackburn
# Copyright (c) 2023 Ricardo Garcia Silva
# Copyright (c) 2024 Bernhard Mallinger
#
# 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 http import HTTPStatus
import logging
from typing import Tuple
from shapely.errors import WKTReadingError
from shapely.wkt import loads as shapely_loads
from pygeoapi.plugin import load_plugin, PLUGINS
from pygeoapi.provider.base import ProviderGenericError
from pygeoapi.util import (
filter_providers_by_type, get_provider_by_type, render_j2_template,
to_json, filter_dict_by_key_value
)
from . import APIRequest, API, F_HTML, validate_datetime, validate_bbox
LOGGER = logging.getLogger(__name__)
CONFORMANCE_CLASSES = [
'http://www.opengis.net/spec/ogcapi-edr-1/1.0/conf/core'
]
def get_collection_edr_query(api: API, request: APIRequest,
dataset, instance, query_type,
location_id=None) -> Tuple[dict, int, str]:
"""
Queries collection EDR
:param request: APIRequest instance with query params
:param dataset: dataset name
:param instance: instance name
:param query_type: EDR query type
:param location_id: location id of a /location/<location_id> query
:returns: tuple of headers, status code, content
"""
if not request.is_valid(PLUGINS['formatter'].keys()):
return api.get_format_exception(request)
headers = request.get_response_headers(api.default_locale,
**api.api_headers)
collections = filter_dict_by_key_value(api.config['resources'],
'type', 'collection')
if dataset not in collections.keys():
msg = 'Collection not found'
return api.get_exception(
HTTPStatus.NOT_FOUND, headers, request.format, 'NotFound', msg)
LOGGER.debug('Processing query parameters')
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 api.get_exception(
HTTPStatus.BAD_REQUEST, headers, request.format,
'InvalidParameterValue', msg)
LOGGER.debug('Processing parameter_names parameter')
parameternames = request.params.get('parameter_names') or []
if isinstance(parameternames, str):
parameternames = parameternames.split(',')
bbox = None
if query_type in ['cube', 'locations']:
LOGGER.debug('Processing cube bbox')
try:
bbox = validate_bbox(request.params.get('bbox'))
if not bbox and query_type == 'cube':
raise ValueError('bbox parameter required by cube queries')
except ValueError as err:
return api.get_exception(
HTTPStatus.BAD_REQUEST, headers, request.format,
'InvalidParameterValue', str(err))
LOGGER.debug('Processing coords parameter')
wkt = request.params.get('coords')
if wkt:
try:
wkt = shapely_loads(wkt)
except WKTReadingError:
msg = 'invalid coords parameter'
return api.get_exception(
HTTPStatus.BAD_REQUEST, headers, request.format,
'InvalidParameterValue', msg)
elif query_type not in ['cube', 'locations']:
msg = 'missing coords parameter'
return api.get_exception(
HTTPStatus.BAD_REQUEST, headers, request.format,
'InvalidParameterValue', msg)
within = within_units = None
if query_type == 'radius':
LOGGER.debug('Processing within / within-units parameters')
within = request.params.get('within')
within_units = request.params.get('within-units')
LOGGER.debug('Processing z parameter')
z = request.params.get('z')
LOGGER.debug('Loading provider')
try:
p = load_plugin('provider', get_provider_by_type(
collections[dataset]['providers'], 'edr'))
except ProviderGenericError as err:
LOGGER.error(err)
return api.get_exception(
err.http_status_code, headers, request.format,
err.ogc_exception_code, err.message)
if instance is not None and not p.get_instance(instance):
msg = 'Invalid instance identifier'
return api.get_exception(
HTTPStatus.BAD_REQUEST, headers,
request.format, 'InvalidParameterValue', msg)
if query_type not in p.get_query_types():
msg = 'Unsupported query type'
return api.get_exception(
HTTPStatus.BAD_REQUEST, headers, request.format,
'InvalidParameterValue', msg)
if parameternames and not any((fld in parameternames)
for fld in p.get_fields().keys()):
msg = 'Invalid parameter_names'
return api.get_exception(
HTTPStatus.BAD_REQUEST, headers, request.format,
'InvalidParameterValue', msg)
query_args = dict(
query_type=query_type,
instance=instance,
format_=request.format,
datetime_=datetime_,
select_properties=parameternames,
wkt=wkt,
z=z,
bbox=bbox,
within=within,
within_units=within_units,
limit=int(api.config['server']['limit']),
location_id=location_id,
)
try:
data = p.query(**query_args)
except ProviderGenericError as err:
LOGGER.error(err)
return api.get_exception(
err.http_status_code, headers, request.format,
err.ogc_exception_code, err.message)
if request.format == F_HTML: # render
content = render_j2_template(api.tpl_config,
'collections/edr/query.html', data,
api.default_locale)
else:
content = to_json(data, api.pretty_print)
return headers, HTTPStatus.OK, content
def get_oas_30(cfg: dict, locale: str) -> tuple[list[dict[str, str]], dict[str, dict]]: # noqa
"""
Get OpenAPI fragments
:param cfg: `dict` of configuration
:param locale: `str` of locale
:returns: `tuple` of `list` of tag objects, and `dict` of path objects
"""
from pygeoapi.openapi import OPENAPI_YAML, get_visible_collections
LOGGER.debug('setting up edr endpoints')
paths = {}
collections = filter_dict_by_key_value(cfg['resources'],
'type', 'collection')
for k, v in get_visible_collections(cfg).items():
edr_extension = filter_providers_by_type(
collections[k]['providers'], 'edr')
if edr_extension:
collection_name_path = f'/collections/{k}'
ep = load_plugin('provider', edr_extension)
edr_query_endpoints = []
for qt in [qt for qt in ep.get_query_types() if qt != 'locations']:
edr_query_endpoints.append({
'path': f'{collection_name_path}/{qt}',
'qt': qt,
'op_id': f'query{qt.capitalize()}{k.capitalize()}'
})
if ep.instances:
edr_query_endpoints.append({
'path': f'{collection_name_path}/instances/{{instanceId}}/{qt}', # noqa
'qt': qt,
'op_id': f'query{qt.capitalize()}Instance{k.capitalize()}' # noqa
})
for eqe in edr_query_endpoints:
if eqe['qt'] == 'cube':
spatial_parameter = 'bbox'
else:
spatial_parameter = f"{eqe['qt']}Coords"
paths[eqe['path']] = {
'get': {
'summary': f"query {v['description']} by {eqe['qt']}",
'description': v['description'],
'tags': [k],
'operationId': eqe['op_id'],
'parameters': [
{'$ref': f"{OPENAPI_YAML['oaedr']}/parameters/{spatial_parameter}.yaml"}, # noqa
{'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/parameters/datetime"}, # noqa
{'$ref': f"{OPENAPI_YAML['oaedr']}/parameters/parameter-name.yaml"}, # noqa
{'$ref': f"{OPENAPI_YAML['oaedr']}/parameters/z.yaml"}, # noqa
{'$ref': '#/components/parameters/f'}
],
'responses': {
'200': {
'description': 'Response',
'content': {
'application/prs.coverage+json': {
'schema': {
'$ref': f"{OPENAPI_YAML['oaedr']}/schemas/coverageJSON.yaml" # noqa
}
}
}
}
}
}
}
if 'locations' in ep.get_query_types():
paths[f'{collection_name_path}/locations'] = {
'get': {
'summary': f"Get pre-defined locations of {v['description']}", # noqa
'description': v['description'],
'tags': [k],
'operationId': f'queryLOCATIONS{k.capitalize()}',
'parameters': [
{'$ref': f"{OPENAPI_YAML['oaedr']}/parameters/bbox.yaml"}, # noqa
{'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/parameters/datetime"}, # noqa
{'$ref': '#/components/parameters/f'}
],
'responses': {
'200': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/Features"}, # noqa
'400': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/InvalidParameter"}, # noqa
'500': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/ServerError"} # noqa
}
}
}
paths[f'{collection_name_path}/locations/{{locId}}'] = {
'get': {
'summary': f"query {v['description']} by location",
'description': v['description'],
'tags': [k],
'operationId': f'queryLOCATIONSBYID{k.capitalize()}',
'parameters': [
{'$ref': f"{OPENAPI_YAML['oaedr']}/parameters/{spatial_parameter}.yaml"}, # noqa
{'$ref': f"{OPENAPI_YAML['oaedr']}/parameters/locationId.yaml"}, # noqa
{'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/parameters/datetime"}, # noqa
{'$ref': f"{OPENAPI_YAML['oaedr']}/parameters/parameter-name.yaml"}, # noqa
{'$ref': f"{OPENAPI_YAML['oaedr']}/parameters/z.yaml"}, # noqa
{'$ref': '#/components/parameters/f'}
],
'responses': {
'200': {
'description': 'Response',
'content': {
'application/prs.coverage+json': {
'schema': {
'$ref': f"{OPENAPI_YAML['oaedr']}/schemas/coverageJSON.yaml" # noqa
}
}
}
}
}
}
}
return [{'name': 'edr'}], {'paths': paths}
File diff suppressed because it is too large Load Diff
+340
View File
@@ -0,0 +1,340 @@
# =================================================================
# Authors: Tom Kralidis <tomkralidis@gmail.com>
# Francesco Bartoli <xbartolone@gmail.com>
# Sander Schaminee <sander.schaminee@geocat.net>
# John A Stevenson <jostev@bgs.ac.uk>
# Colin Blackburn <colb@bgs.ac.uk>
# Ricardo Garcia Silva <ricardo.garcia.silva@geobeyond.it>
# Bernhard Mallinger <bernhard.mallinger@eox.at>
#
# Copyright (c) 2024 Tom Kralidis
# Copyright (c) 2022 Francesco Bartoli
# Copyright (c) 2022 John A Stevenson and Colin Blackburn
# Copyright (c) 2023 Ricardo Garcia Silva
# Copyright (c) 2024 Bernhard Mallinger
#
# 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 copy import deepcopy
from http import HTTPStatus
import logging
from typing import Tuple
from pygeoapi.openapi import get_oas_30_parameters
from pygeoapi.plugin import load_plugin
from pygeoapi.provider.base import ProviderGenericError
from pygeoapi.util import (
get_provider_by_type, to_json, filter_providers_by_type,
filter_dict_by_key_value
)
from . import APIRequest, API, validate_datetime
LOGGER = logging.getLogger(__name__)
CONFORMANCE_CLASSES = [
'http://www.opengis.net/spec/ogcapi-maps-1/1.0/conf/core'
]
def get_collection_map(api: API, request: APIRequest,
dataset, style=None) -> Tuple[dict, int, str]:
"""
Returns a subset of a collection map
:param request: A request object
:param dataset: dataset name
:param style: style name
:returns: tuple of headers, status code, content
"""
query_args = {
'crs': 'CRS84'
}
format_ = request.format or 'png'
headers = request.get_response_headers(**api.api_headers)
LOGGER.debug('Processing query parameters')
LOGGER.debug('Loading provider')
try:
collection_def = get_provider_by_type(
api.config['resources'][dataset]['providers'], 'map')
p = load_plugin('provider', collection_def)
except KeyError:
exception = {
'code': 'InvalidParameterValue',
'description': 'collection does not exist'
}
headers['Content-type'] = 'application/json'
LOGGER.error(exception)
return headers, HTTPStatus.NOT_FOUND, to_json(
exception, api.pretty_print)
except ProviderGenericError as err:
LOGGER.error(err)
return api.get_exception(
err.http_status_code, headers, request.format,
err.ogc_exception_code, err.message)
query_args['format_'] = request.params.get('f', 'png')
query_args['style'] = style
query_args['crs'] = request.params.get('bbox-crs', 4326)
query_args['transparent'] = request.params.get('transparent', True)
try:
query_args['width'] = int(request.params.get('width', 500))
query_args['height'] = int(request.params.get('height', 300))
except ValueError:
exception = {
'code': 'InvalidParameterValue',
'description': 'invalid width/height'
}
headers['Content-type'] = 'application/json'
LOGGER.error(exception)
return headers, HTTPStatus.BAD_REQUEST, to_json(
exception, api.pretty_print)
LOGGER.debug('Processing bbox parameter')
try:
bbox = request.params.get('bbox').split(',')
if len(bbox) != 4:
exception = {
'code': 'InvalidParameterValue',
'description': 'bbox values should be minx,miny,maxx,maxy'
}
headers['Content-type'] = 'application/json'
LOGGER.error(exception)
return headers, HTTPStatus.BAD_REQUEST, to_json(
exception, api.pretty_print)
except AttributeError:
bbox = api.config['resources'][dataset]['extents']['spatial']['bbox'] # noqa
try:
query_args['bbox'] = [float(c) for c in bbox]
except ValueError:
exception = {
'code': 'InvalidParameterValue',
'description': 'bbox values must be numbers'
}
headers['Content-type'] = 'application/json'
LOGGER.error(exception)
return headers, HTTPStatus.BAD_REQUEST, to_json(
exception, api.pretty_print)
LOGGER.debug('Processing datetime parameter')
datetime_ = request.params.get('datetime')
try:
query_args['datetime_'] = validate_datetime(
api.config['resources'][dataset]['extents'], datetime_)
except ValueError as err:
msg = str(err)
return api.get_exception(
HTTPStatus.BAD_REQUEST, headers, request.format,
'InvalidParameterValue', msg)
LOGGER.debug('Generating map')
try:
data = p.query(**query_args)
except ProviderGenericError as err:
LOGGER.error(err)
return api.get_exception(
err.http_status_code, headers, request.format,
err.ogc_exception_code, err.message)
mt = collection_def['format']['name']
if format_ == mt:
headers['Content-Type'] = collection_def['format']['mimetype']
return headers, HTTPStatus.OK, data
elif format_ in [None, 'html']:
headers['Content-Type'] = collection_def['format']['mimetype']
return headers, HTTPStatus.OK, data
else:
exception = {
'code': 'InvalidParameterValue',
'description': 'invalid format parameter'
}
LOGGER.error(exception)
return headers, HTTPStatus.BAD_REQUEST, to_json(
data, api.pretty_print)
def get_collection_map_legend(api: API, request: APIRequest,
dataset, style=None) -> Tuple[dict, int, str]:
"""
Returns a subset of a collection map legend
:param request: A request object
:param dataset: dataset name
:param style: style name
:returns: tuple of headers, status code, content
"""
format_ = 'png'
headers = request.get_response_headers(**api.api_headers)
LOGGER.debug('Processing query parameters')
LOGGER.debug('Loading provider')
try:
collection_def = get_provider_by_type(
api.config['resources'][dataset]['providers'], 'map')
p = load_plugin('provider', collection_def)
except KeyError:
exception = {
'code': 'InvalidParameterValue',
'description': 'collection does not exist'
}
LOGGER.error(exception)
return headers, HTTPStatus.NOT_FOUND, to_json(
exception, api.pretty_print)
except ProviderGenericError as err:
LOGGER.error(err)
return api.get_exception(
err.http_status_code, headers, request.format,
err.ogc_exception_code, err.message)
LOGGER.debug('Generating legend')
try:
data = p.get_legend(style, request.params.get('f', 'png'))
except ProviderGenericError as err:
LOGGER.error(err)
return api.get_exception(
err.http_status_code, headers, request.format,
err.ogc_exception_code, err.message)
mt = collection_def['format']['name']
if format_ == mt:
headers['Content-Type'] = collection_def['format']['mimetype']
return headers, HTTPStatus.OK, data
else:
exception = {
'code': 'InvalidParameterValue',
'description': 'invalid format parameter'
}
LOGGER.error(exception)
return headers, HTTPStatus.BAD_REQUEST, to_json(
data, api.pretty_print)
def get_oas_30(cfg: dict, locale: str) -> tuple[list[dict[str, str]], dict[str, dict]]: # noqa
"""
Get OpenAPI fragments
:param cfg: `dict` of configuration
:param locale: `str` of locale
:returns: `tuple` of `list` of tag objects, and `dict` of path objects
"""
from pygeoapi.openapi import OPENAPI_YAML, get_visible_collections
LOGGER.debug('setting up maps endpoints')
paths = {}
collections = filter_dict_by_key_value(cfg['resources'],
'type', 'collection')
parameters = get_oas_30_parameters(cfg, locale)
for k, v in get_visible_collections(cfg).items():
map_extension = filter_providers_by_type(
collections[k]['providers'], 'map')
if map_extension:
mp = load_plugin('provider', map_extension)
map_f = deepcopy(parameters['f'])
map_f['schema']['enum'] = [map_extension['format']['name']]
map_f['schema']['default'] = map_extension['format']['name']
pth = f'/collections/{k}/map'
paths[pth] = {
'get': {
'summary': 'Get map',
'description': f"{v['description']} map",
'tags': [k],
'operationId': 'getMap',
'parameters': [
{'$ref': '#/components/parameters/bbox'},
{'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/parameters/datetime"}, # noqa
{
'name': 'width',
'in': 'query',
'description': 'Response image width',
'required': False,
'schema': {
'type': 'integer',
},
'style': 'form',
'explode': False
},
{
'name': 'height',
'in': 'query',
'description': 'Response image height',
'required': False,
'schema': {
'type': 'integer',
},
'style': 'form',
'explode': False
},
{
'name': 'transparent',
'in': 'query',
'description': 'Background transparency of map (default=true).', # noqa
'required': False,
'schema': {
'type': 'boolean',
'default': True,
},
'style': 'form',
'explode': False
},
{'$ref': '#/components/parameters/bbox-crs-epsg'},
map_f
],
'responses': {
'200': {
'description': 'Response',
'content': {
'application/json': {}
}
},
'400': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/InvalidParameter"}, # noqa
'500': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/ServerError"}, # noqa
}
}
}
if mp.time_field is not None:
paths[pth]['get']['parameters'].append(
{'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/parameters/datetime"}) # noqa
return [{'name': 'maps'}], {'paths': paths}
+740
View File
@@ -0,0 +1,740 @@
# =================================================================
# Authors: Tom Kralidis <tomkralidis@gmail.com>
# Francesco Bartoli <xbartolone@gmail.com>
# Sander Schaminee <sander.schaminee@geocat.net>
# John A Stevenson <jostev@bgs.ac.uk>
# Colin Blackburn <colb@bgs.ac.uk>
# Ricardo Garcia Silva <ricardo.garcia.silva@geobeyond.it>
# Bernhard Mallinger <bernhard.mallinger@eox.at>
#
# Copyright (c) 2024 Tom Kralidis
# Copyright (c) 2022 Francesco Bartoli
# Copyright (c) 2022 John A Stevenson and Colin Blackburn
# Copyright (c) 2023 Ricardo Garcia Silva
# Copyright (c) 2024 Bernhard Mallinger
#
# 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 copy import deepcopy
from datetime import datetime, timezone
from http import HTTPStatus
import json
import logging
from typing import Tuple
from pygeoapi import l10n
from pygeoapi.util import (
json_serial, render_j2_template, JobStatus, RequestedProcessExecutionMode,
to_json, DATETIME_FORMAT)
from pygeoapi.process.base import (
JobNotFoundError, JobResultNotFoundError, ProcessorExecuteError
)
from pygeoapi.process.manager.base import get_manager, Subscriber
from . import (
APIRequest, API, SYSTEM_LOCALE, F_JSON, FORMAT_TYPES, F_HTML, F_JSONLD,
)
LOGGER = logging.getLogger(__name__)
CONFORMANCE_CLASSES = [
'http://www.opengis.net/spec/ogcapi-processes-1/1.0/conf/ogc-process-description', # noqa
'http://www.opengis.net/spec/ogcapi-processes-1/1.0/conf/core',
'http://www.opengis.net/spec/ogcapi-processes-1/1.0/conf/json',
'http://www.opengis.net/spec/ogcapi-processes-1/1.0/conf/oas30',
'http://www.opengis.net/spec/ogcapi-processes-1/1.0/conf/callback'
]
def describe_processes(api: API, request: APIRequest,
process=None) -> Tuple[dict, int, str]:
"""
Provide processes metadata
:param request: A request object
:param process: process identifier, defaults to None to obtain
information about all processes
:returns: tuple of headers, status code, content
"""
processes = []
headers = request.get_response_headers(**api.api_headers)
if process is not None:
if process not in api.manager.processes.keys():
msg = 'Identifier not found'
return api.get_exception(
HTTPStatus.NOT_FOUND, headers,
request.format, 'NoSuchProcess', msg)
if len(api.manager.processes) > 0:
if process is not None:
relevant_processes = [process]
else:
LOGGER.debug('Processing limit parameter')
try:
limit = int(request.params.get('limit'))
if limit <= 0:
msg = 'limit value should be strictly positive'
return api.get_exception(
HTTPStatus.BAD_REQUEST, headers, request.format,
'InvalidParameterValue', msg)
relevant_processes = list(api.manager.processes)[:limit]
except TypeError:
LOGGER.debug('returning all processes')
relevant_processes = api.manager.processes.keys()
except ValueError:
msg = 'limit value should be an integer'
return api.get_exception(
HTTPStatus.BAD_REQUEST, headers, request.format,
'InvalidParameterValue', msg)
for key in relevant_processes:
p = api.manager.get_processor(key)
p2 = l10n.translate_struct(deepcopy(p.metadata),
request.locale)
p2['id'] = key
if process is None:
p2.pop('inputs')
p2.pop('outputs')
p2.pop('example', None)
p2['jobControlOptions'] = ['sync-execute']
if api.manager.is_async:
p2['jobControlOptions'].append('async-execute')
p2['outputTransmission'] = ['value']
p2['links'] = p2.get('links', [])
jobs_url = f"{api.base_url}/jobs"
process_url = f"{api.base_url}/processes/{key}"
# TODO translation support
link = {
'type': FORMAT_TYPES[F_JSON],
'rel': request.get_linkrel(F_JSON),
'href': f'{process_url}?f={F_JSON}',
'title': 'Process description as JSON',
'hreflang': api.default_locale
}
p2['links'].append(link)
link = {
'type': FORMAT_TYPES[F_HTML],
'rel': request.get_linkrel(F_HTML),
'href': f'{process_url}?f={F_HTML}',
'title': 'Process description as HTML',
'hreflang': api.default_locale
}
p2['links'].append(link)
link = {
'type': FORMAT_TYPES[F_HTML],
'rel': 'http://www.opengis.net/def/rel/ogc/1.0/job-list',
'href': f'{jobs_url}?f={F_HTML}',
'title': 'jobs for this process as HTML',
'hreflang': api.default_locale
}
p2['links'].append(link)
link = {
'type': FORMAT_TYPES[F_JSON],
'rel': 'http://www.opengis.net/def/rel/ogc/1.0/job-list',
'href': f'{jobs_url}?f={F_JSON}',
'title': 'jobs for this process as JSON',
'hreflang': api.default_locale
}
p2['links'].append(link)
link = {
'type': FORMAT_TYPES[F_JSON],
'rel': 'http://www.opengis.net/def/rel/ogc/1.0/execute',
'href': f'{process_url}/execution?f={F_JSON}',
'title': 'Execution for this process as JSON',
'hreflang': api.default_locale
}
p2['links'].append(link)
processes.append(p2)
if process is not None:
response = processes[0]
else:
process_url = f"{api.base_url}/processes"
response = {
'processes': processes,
'links': [{
'type': FORMAT_TYPES[F_JSON],
'rel': request.get_linkrel(F_JSON),
'title': 'This document as JSON',
'href': f'{process_url}?f={F_JSON}'
}, {
'type': FORMAT_TYPES[F_JSONLD],
'rel': request.get_linkrel(F_JSONLD),
'title': 'This document as RDF (JSON-LD)',
'href': f'{process_url}?f={F_JSONLD}'
}, {
'type': FORMAT_TYPES[F_HTML],
'rel': request.get_linkrel(F_HTML),
'title': 'This document as HTML',
'href': f'{process_url}?f={F_HTML}'
}]
}
if request.format == F_HTML: # render
if process is not None:
response = render_j2_template(api.tpl_config,
'processes/process.html',
response, request.locale)
else:
response = render_j2_template(api.tpl_config,
'processes/index.html', response,
request.locale)
return headers, HTTPStatus.OK, response
return headers, HTTPStatus.OK, to_json(response, api.pretty_print)
# TODO: get_jobs doesn't have tests
def get_jobs(api: API, request: APIRequest,
job_id=None) -> Tuple[dict, int, str]:
"""
Get process jobs
:param request: A request object
:param job_id: id of job
:returns: tuple of headers, status code, content
"""
headers = request.get_response_headers(SYSTEM_LOCALE,
**api.api_headers)
if job_id is None:
jobs = sorted(api.manager.get_jobs(),
key=lambda k: k['job_start_datetime'],
reverse=True)
else:
try:
jobs = [api.manager.get_job(job_id)]
except JobNotFoundError:
return api.get_exception(
HTTPStatus.NOT_FOUND, headers, request.format,
'InvalidParameterValue', job_id)
serialized_jobs = {
'jobs': [],
'links': [{
'href': f"{api.base_url}/jobs?f={F_HTML}",
'rel': request.get_linkrel(F_HTML),
'type': FORMAT_TYPES[F_HTML],
'title': 'Jobs list as HTML'
}, {
'href': f"{api.base_url}/jobs?f={F_JSON}",
'rel': request.get_linkrel(F_JSON),
'type': FORMAT_TYPES[F_JSON],
'title': 'Jobs list as JSON'
}]
}
for job_ in jobs:
job2 = {
'type': 'process',
'processID': job_['process_id'],
'jobID': job_['identifier'],
'status': job_['status'],
'message': job_['message'],
'progress': job_['progress'],
'parameters': job_.get('parameters'),
'job_start_datetime': job_['job_start_datetime'],
'job_end_datetime': job_['job_end_datetime']
}
# TODO: translate
if JobStatus[job_['status']] in (
JobStatus.successful, JobStatus.running, JobStatus.accepted):
job_result_url = f"{api.base_url}/jobs/{job_['identifier']}/results" # noqa
job2['links'] = [{
'href': f'{job_result_url}?f={F_HTML}',
'rel': 'http://www.opengis.net/def/rel/ogc/1.0/results',
'type': FORMAT_TYPES[F_HTML],
'title': f'results of job {job_id} as HTML'
}, {
'href': f'{job_result_url}?f={F_JSON}',
'rel': 'http://www.opengis.net/def/rel/ogc/1.0/results',
'type': FORMAT_TYPES[F_JSON],
'title': f'results of job {job_id} as JSON'
}]
if job_['mimetype'] not in (FORMAT_TYPES[F_JSON],
FORMAT_TYPES[F_HTML]):
job2['links'].append({
'href': job_result_url,
'rel': 'http://www.opengis.net/def/rel/ogc/1.0/results', # noqa
'type': job_['mimetype'],
'title': f"results of job {job_id} as {job_['mimetype']}" # noqa
})
serialized_jobs['jobs'].append(job2)
if job_id is None:
j2_template = 'jobs/index.html'
else:
serialized_jobs = serialized_jobs['jobs'][0]
j2_template = 'jobs/job.html'
if request.format == F_HTML:
data = {
'jobs': serialized_jobs,
'now': datetime.now(timezone.utc).strftime(DATETIME_FORMAT)
}
response = render_j2_template(api.tpl_config, j2_template, data,
request.locale)
return headers, HTTPStatus.OK, response
return headers, HTTPStatus.OK, to_json(serialized_jobs,
api.pretty_print)
def execute_process(api: API, request: APIRequest,
process_id) -> Tuple[dict, int, str]:
"""
Execute process
:param request: A request object
:param process_id: id of process
:returns: tuple of headers, status code, content
"""
# Responses are always in US English only
headers = request.get_response_headers(SYSTEM_LOCALE,
**api.api_headers)
if process_id not in api.manager.processes:
msg = 'identifier not found'
return api.get_exception(
HTTPStatus.NOT_FOUND, headers,
request.format, 'NoSuchProcess', msg)
data = request.data
if not data:
# TODO not all processes require input, e.g. time-dependent or
# random value generators
msg = 'missing request data'
return api.get_exception(
HTTPStatus.BAD_REQUEST, headers, request.format,
'MissingParameterValue', msg)
try:
# Parse bytes data, if applicable
data = data.decode()
LOGGER.debug(data)
except (UnicodeDecodeError, AttributeError):
pass
try:
data = json.loads(data)
except (json.decoder.JSONDecodeError, TypeError) as err:
# Input does not appear to be valid JSON
LOGGER.error(err)
msg = 'invalid request data'
return api.get_exception(
HTTPStatus.BAD_REQUEST, headers, request.format,
'InvalidParameterValue', msg)
data_dict = data.get('inputs', {})
LOGGER.debug(data_dict)
subscriber = None
subscriber_dict = data.get('subscriber')
if subscriber_dict:
try:
success_uri = subscriber_dict['successUri']
except KeyError:
return api.get_exception(
HTTPStatus.BAD_REQUEST, headers, request.format,
'MissingParameterValue', 'Missing successUri')
else:
subscriber = Subscriber(
# NOTE: successUri is mandatory according to the standard
success_uri=success_uri,
in_progress_uri=subscriber_dict.get('inProgressUri'),
failed_uri=subscriber_dict.get('failedUri'),
)
try:
execution_mode = RequestedProcessExecutionMode(
request.headers.get('Prefer', request.headers.get('prefer'))
)
except ValueError:
execution_mode = None
try:
LOGGER.debug('Executing process')
result = api.manager.execute_process(
process_id, data_dict, execution_mode=execution_mode,
subscriber=subscriber)
job_id, mime_type, outputs, status, additional_headers = result
headers.update(additional_headers or {})
headers['Location'] = f'{api.base_url}/jobs/{job_id}'
except ProcessorExecuteError as err:
LOGGER.error(err)
return api.get_exception(
err.http_status_code, headers,
request.format, err.ogc_exception_code, err.message)
response = {}
if status == JobStatus.failed:
response = outputs
if data.get('response', 'raw') == 'raw':
headers['Content-Type'] = mime_type
response = outputs
elif status not in (JobStatus.failed, JobStatus.accepted):
response['outputs'] = [outputs]
if status == JobStatus.accepted:
http_status = HTTPStatus.CREATED
elif status == JobStatus.failed:
http_status = HTTPStatus.BAD_REQUEST
else:
http_status = HTTPStatus.OK
if mime_type == 'application/json':
response2 = to_json(response, api.pretty_print)
else:
response2 = response
return headers, http_status, response2
def get_job_result(api: API, request: APIRequest,
job_id) -> Tuple[dict, int, str]:
"""
Get result of job (instance of a process)
:param request: A request object
:param job_id: ID of job
:returns: tuple of headers, status code, content
"""
headers = request.get_response_headers(SYSTEM_LOCALE,
**api.api_headers)
try:
job = api.manager.get_job(job_id)
except JobNotFoundError:
return api.get_exception(
HTTPStatus.NOT_FOUND, headers,
request.format, 'NoSuchJob', job_id
)
status = JobStatus[job['status']]
if status == JobStatus.running:
msg = 'job still running'
return api.get_exception(
HTTPStatus.NOT_FOUND, headers,
request.format, 'ResultNotReady', msg)
elif status == JobStatus.accepted:
# NOTE: this case is not mentioned in the specification
msg = 'job accepted but not yet running'
return api.get_exception(
HTTPStatus.NOT_FOUND, headers,
request.format, 'ResultNotReady', msg)
elif status == JobStatus.failed:
msg = 'job failed'
return api.get_exception(
HTTPStatus.BAD_REQUEST, headers, request.format,
'InvalidParameterValue', msg)
try:
mimetype, job_output = api.manager.get_job_result(job_id)
except JobResultNotFoundError:
return api.get_exception(
HTTPStatus.INTERNAL_SERVER_ERROR, headers,
request.format, 'JobResultNotFound', job_id
)
if mimetype not in (None, FORMAT_TYPES[F_JSON]):
headers['Content-Type'] = mimetype
content = job_output
else:
if request.format == F_JSON:
content = json.dumps(job_output, sort_keys=True, indent=4,
default=json_serial)
else:
# HTML
headers['Content-Type'] = "text/html"
data = {
'job': {'id': job_id},
'result': job_output
}
content = render_j2_template(
api.config, 'jobs/results/index.html',
data, request.locale)
return headers, HTTPStatus.OK, content
def delete_job(
api: API, request: APIRequest, job_id
) -> Tuple[dict, int, str]:
"""
Delete a process job
:param job_id: job identifier
:returns: tuple of headers, status code, content
"""
response_headers = request.get_response_headers(
SYSTEM_LOCALE, **api.api_headers)
try:
success = api.manager.delete_job(job_id)
except JobNotFoundError:
return api.get_exception(
HTTPStatus.NOT_FOUND, response_headers, request.format,
'NoSuchJob', job_id
)
else:
if success:
http_status = HTTPStatus.OK
jobs_url = f"{api.base_url}/jobs"
response = {
'jobID': job_id,
'status': JobStatus.dismissed.value,
'message': 'Job dismissed',
'progress': 100,
'links': [{
'href': jobs_url,
'rel': 'up',
'type': FORMAT_TYPES[F_JSON],
'title': 'The job list for the current process'
}]
}
else:
return api.get_exception(
HTTPStatus.INTERNAL_SERVER_ERROR, response_headers,
request.format, 'InternalError', job_id
)
LOGGER.info(response)
# TODO: this response does not have any headers
return {}, http_status, response
def get_oas_30(cfg: dict, locale: str) -> tuple[list[dict[str, str]], dict[str, dict]]: # noqa
"""
Get OpenAPI fragments
:param cfg: `dict` of configuration
:param locale: `str` of locale
:returns: `tuple` of `list` of tag objects, and `dict` of path objects
"""
from pygeoapi.openapi import OPENAPI_YAML
LOGGER.debug('setting up processes endpoints')
oas = {'tags': []}
paths = {}
process_manager = get_manager(cfg)
if len(process_manager.processes) > 0:
paths['/processes'] = {
'get': {
'summary': 'Processes',
'description': 'Processes',
'tags': ['server'],
'operationId': 'getProcesses',
'parameters': [
{'$ref': '#/components/parameters/f'}
],
'responses': {
'200': {'$ref': f"{OPENAPI_YAML['oapip']}/responses/ProcessList.yaml"}, # noqa
'default': {'$ref': '#/components/responses/default'}
}
}
}
LOGGER.debug('setting up processes')
for k, v in process_manager.processes.items():
if k.startswith('_'):
LOGGER.debug(f'Skipping hidden layer: {k}')
continue
name = l10n.translate(k, locale)
p = process_manager.get_processor(k)
md_desc = l10n.translate(p.metadata['description'], locale)
process_name_path = f'/processes/{name}'
tag = {
'name': name,
'description': md_desc,
'externalDocs': {}
}
for link in p.metadata.get('links', []):
if link['type'] == 'information':
translated_link = l10n.translate(link, locale)
tag['externalDocs']['description'] = translated_link[
'type']
tag['externalDocs']['url'] = translated_link['url']
break
if len(tag['externalDocs']) == 0:
del tag['externalDocs']
oas['tags'].append(tag)
paths[process_name_path] = {
'get': {
'summary': 'Get process metadata',
'description': md_desc,
'tags': [name],
'operationId': f'describe{name.capitalize()}Process',
'parameters': [
{'$ref': '#/components/parameters/f'}
],
'responses': {
'200': {'$ref': '#/components/responses/200'},
'default': {'$ref': '#/components/responses/default'}
}
}
}
paths[f'{process_name_path}/execution'] = {
'post': {
'summary': f"Process {l10n.translate(p.metadata['title'], locale)} execution", # noqa
'description': md_desc,
'tags': [name],
'operationId': f'execute{name.capitalize()}Job',
'responses': {
'200': {'$ref': '#/components/responses/200'},
'201': {'$ref': f"{OPENAPI_YAML['oapip']}/responses/ExecuteAsync.yaml"}, # noqa
'404': {'$ref': f"{OPENAPI_YAML['oapip']}/responses/NotFound.yaml"}, # noqa
'500': {'$ref': f"{OPENAPI_YAML['oapip']}/responses/ServerError.yaml"}, # noqa
'default': {'$ref': '#/components/responses/default'}
},
'requestBody': {
'description': 'Mandatory execute request JSON',
'required': True,
'content': {
'application/json': {
'schema': {
'$ref': f"{OPENAPI_YAML['oapip']}/schemas/execute.yaml" # noqa
}
}
}
}
}
}
if 'example' in p.metadata:
paths[f'{process_name_path}/execution']['post']['requestBody']['content']['application/json']['example'] = p.metadata['example'] # noqa
name_in_path = {
'name': 'jobId',
'in': 'path',
'description': 'job identifier',
'required': True,
'schema': {
'type': 'string'
}
}
paths['/jobs'] = {
'get': {
'summary': 'Retrieve jobs list',
'description': 'Retrieve a list of jobs',
'tags': ['jobs'],
'operationId': 'getJobs',
'responses': {
'200': {'$ref': '#/components/responses/200'},
'404': {'$ref': f"{OPENAPI_YAML['oapip']}/responses/NotFound.yaml"}, # noqa
'default': {'$ref': '#/components/responses/default'}
}
}
}
paths['/jobs/{jobId}'] = {
'get': {
'summary': 'Retrieve job details',
'description': 'Retrieve job details',
'tags': ['jobs'],
'parameters': [
name_in_path,
{'$ref': '#/components/parameters/f'}
],
'operationId': 'getJob',
'responses': {
'200': {'$ref': '#/components/responses/200'},
'404': {'$ref': f"{OPENAPI_YAML['oapip']}/responses/NotFound.yaml"}, # noqa
'default': {'$ref': '#/components/responses/default'}
}
},
'delete': {
'summary': 'Cancel / delete job',
'description': 'Cancel / delete job',
'tags': ['jobs'],
'parameters': [
name_in_path
],
'operationId': 'deleteJob',
'responses': {
'204': {'$ref': '#/components/responses/204'},
'404': {'$ref': f"{OPENAPI_YAML['oapip']}/responses/NotFound.yaml"}, # noqa
'default': {'$ref': '#/components/responses/default'}
}
},
}
paths['/jobs/{jobId}/results'] = {
'get': {
'summary': 'Retrieve job results',
'description': 'Retrive job resiults',
'tags': ['jobs'],
'parameters': [
name_in_path,
{'$ref': '#/components/parameters/f'}
],
'operationId': 'getJobResults',
'responses': {
'200': {'$ref': '#/components/responses/200'},
'404': {'$ref': f"{OPENAPI_YAML['oapip']}/responses/NotFound.yaml"}, # noqa
'default': {'$ref': '#/components/responses/default'}
}
}
}
return [{'name': 'proceses'}, {'name': 'jobs'}], {'paths': paths}
+256
View File
@@ -0,0 +1,256 @@
# =================================================================
# Authors: Tom Kralidis <tomkralidis@gmail.com>
# Francesco Bartoli <xbartolone@gmail.com>
# Sander Schaminee <sander.schaminee@geocat.net>
# John A Stevenson <jostev@bgs.ac.uk>
# Colin Blackburn <colb@bgs.ac.uk>
# Ricardo Garcia Silva <ricardo.garcia.silva@geobeyond.it>
# Bernhard Mallinger <bernhard.mallinger@eox.at>
#
# Copyright (c) 2024 Tom Kralidis
# Copyright (c) 2022 Francesco Bartoli
# Copyright (c) 2022 John A Stevenson and Colin Blackburn
# Copyright (c) 2023 Ricardo Garcia Silva
# Copyright (c) 2024 Bernhard Mallinger
#
# 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 http import HTTPStatus
import logging
from typing import Tuple
from pygeoapi import l10n
from pygeoapi.plugin import load_plugin
from pygeoapi.provider.base import (
ProviderConnectionError, ProviderNotFoundError
)
from pygeoapi.util import (
get_provider_by_type, to_json, filter_dict_by_key_value,
render_j2_template
)
from . import APIRequest, API, FORMAT_TYPES, F_JSON, F_HTML
LOGGER = logging.getLogger(__name__)
CONFORMANCE_CLASSES = []
# TODO: no tests for this?
def get_stac_root(api: API, request: APIRequest) -> Tuple[dict, int, str]:
"""
Provide STAC root page
:param request: APIRequest instance with query params
:returns: tuple of headers, status code, content
"""
headers = request.get_response_headers(**api.api_headers)
id_ = 'pygeoapi-stac'
stac_version = '1.0.0-rc.2'
stac_url = f'{api.base_url}/stac'
content = {
'id': id_,
'type': 'Catalog',
'stac_version': stac_version,
'title': l10n.translate(
api.config['metadata']['identification']['title'],
request.locale),
'description': l10n.translate(
api.config['metadata']['identification']['description'],
request.locale),
'links': []
}
stac_collections = filter_dict_by_key_value(api.config['resources'],
'type', 'stac-collection')
for key, value in stac_collections.items():
content['links'].append({
'rel': 'child',
'href': f'{stac_url}/{key}?f={F_JSON}',
'type': FORMAT_TYPES[F_JSON]
})
content['links'].append({
'rel': 'child',
'href': f'{stac_url}/{key}',
'type': FORMAT_TYPES[F_HTML]
})
if request.format == F_HTML: # render
content = render_j2_template(api.tpl_config,
'stac/collection.html',
content, request.locale)
return headers, HTTPStatus.OK, content
return headers, HTTPStatus.OK, to_json(content, api.pretty_print)
# TODO: no tests for this?
def get_stac_path(api: API, request: APIRequest,
path) -> Tuple[dict, int, str]:
"""
Provide STAC resource path
:param request: APIRequest instance with query params
:returns: tuple of headers, status code, content
"""
headers = request.get_response_headers(**api.api_headers)
dataset = None
LOGGER.debug(f'Path: {path}')
dir_tokens = path.split('/')
if dir_tokens:
dataset = dir_tokens[0]
stac_collections = filter_dict_by_key_value(api.config['resources'],
'type', 'stac-collection')
if dataset not in stac_collections:
msg = 'Collection not found'
return api.get_exception(HTTPStatus.NOT_FOUND, headers,
request.format, 'NotFound', msg)
LOGGER.debug('Loading provider')
try:
p = load_plugin('provider', get_provider_by_type(
stac_collections[dataset]['providers'], 'stac'))
except ProviderConnectionError as err:
LOGGER.error(err)
msg = 'connection error (check logs)'
return api.get_exception(
HTTPStatus.INTERNAL_SERVER_ERROR, headers,
request.format, 'NoApplicableCode', msg)
id_ = f'{dataset}-stac'
stac_version = '1.0.0-rc.2'
content = {
'id': id_,
'type': 'Catalog',
'stac_version': stac_version,
'description': l10n.translate(
stac_collections[dataset]['description'], request.locale),
'links': []
}
try:
stac_data = p.get_data_path(
f'{api.base_url}/stac',
path,
path.replace(dataset, '', 1)
)
except ProviderNotFoundError as err:
LOGGER.error(err)
msg = 'resource not found'
return api.get_exception(HTTPStatus.NOT_FOUND, headers,
request.format, 'NotFound', msg)
except Exception as err:
LOGGER.error(err)
msg = 'data query error'
return api.get_exception(
HTTPStatus.INTERNAL_SERVER_ERROR, headers,
request.format, 'NoApplicableCode', msg)
if isinstance(stac_data, dict):
content.update(stac_data)
content['links'].extend(
stac_collections[dataset].get('links', []))
if request.format == F_HTML: # render
content['path'] = path
if 'assets' in content: # item view
if content['type'] == 'Collection':
content = render_j2_template(
api.tpl_config,
'stac/collection_base.html',
content,
request.locale
)
elif content['type'] == 'Feature':
content = render_j2_template(
api.tpl_config,
'stac/item.html',
content,
request.locale
)
else:
msg = f'Unknown STAC type {content.type}'
LOGGER.error(msg)
return api.get_exception(
HTTPStatus.INTERNAL_SERVER_ERROR,
headers,
request.format,
'NoApplicableCode',
msg)
else:
content = render_j2_template(api.tpl_config,
'stac/catalog.html',
content, request.locale)
return headers, HTTPStatus.OK, content
return headers, HTTPStatus.OK, to_json(content, api.pretty_print)
else: # send back file
headers.pop('Content-Type', None)
return headers, HTTPStatus.OK, stac_data
def get_oas_30(cfg: dict, locale: str) -> tuple[list[dict[str, str]], dict[str, dict]]: # noqa
"""
Get OpenAPI fragments
:param cfg: `dict` of configuration
:param locale: `str` of locale
:returns: `tuple` of `list` of tag objects, and `dict` of path objects
"""
LOGGER.debug('setting up STAC')
stac_collections = filter_dict_by_key_value(cfg['resources'],
'type', 'stac-collection')
paths = {}
if stac_collections:
paths['/stac'] = {
'get': {
'summary': 'SpatioTemporal Asset Catalog',
'description': 'SpatioTemporal Asset Catalog',
'tags': ['stac'],
'operationId': 'getStacCatalog',
'parameters': [],
'responses': {
'200': {'$ref': '#/components/responses/200'},
'default': {'$ref': '#/components/responses/default'}
}
}
}
return [{'name': 'stac'}], {'paths': paths}
+534
View File
@@ -0,0 +1,534 @@
# =================================================================
# Authors: Tom Kralidis <tomkralidis@gmail.com>
# Francesco Bartoli <xbartolone@gmail.com>
# Sander Schaminee <sander.schaminee@geocat.net>
# John A Stevenson <jostev@bgs.ac.uk>
# Colin Blackburn <colb@bgs.ac.uk>
# Ricardo Garcia Silva <ricardo.garcia.silva@geobeyond.it>
# Bernhard Mallinger <bernhard.mallinger@eox.at>
#
# Copyright (c) 2024 Tom Kralidis
# Copyright (c) 2022 Francesco Bartoli
# Copyright (c) 2022 John A Stevenson and Colin Blackburn
# Copyright (c) 2023 Ricardo Garcia Silva
# Copyright (c) 2024 Bernhard Mallinger
#
# 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 logging
from http import HTTPStatus
from typing import Tuple
from pygeoapi import l10n
from pygeoapi.plugin import load_plugin
from pygeoapi.models.provider.base import (TilesMetadataFormat,
TileMatrixSetEnum)
from pygeoapi.provider.base import (
ProviderGenericError, ProviderTypeError
)
from pygeoapi.util import (
get_provider_by_type, to_json, filter_dict_by_key_value,
filter_providers_by_type, render_j2_template
)
from . import (
APIRequest, API, FORMAT_TYPES, F_JSON, F_HTML, SYSTEM_LOCALE, F_JSONLD
)
LOGGER = logging.getLogger(__name__)
CONFORMANCE_CLASSES = [
'http://www.opengis.net/spec/ogcapi-tiles-1/1.0/conf/core',
'http://www.opengis.net/spec/ogcapi-tiles-1/1.0/conf/mvt',
'http://www.opengis.net/spec/ogcapi-tiles-1/1.0/conf/tileset',
'http://www.opengis.net/spec/ogcapi-tiles-1/1.0/conf/tilesets-list',
'http://www.opengis.net/spec/ogcapi-tiles-1/1.0/conf/oas30',
'http://www.opengis.net/spec/ogcapi-tiles-1/1.0/conf/geodata-tilesets'
]
def get_collection_tiles(api: API, request: APIRequest,
dataset=None) -> Tuple[dict, int, str]:
"""
Provide collection tiles
:param request: A request object
:param dataset: name of collection
:returns: tuple of headers, status code, content
"""
headers = request.get_response_headers(SYSTEM_LOCALE,
**api.api_headers)
if any([dataset is None,
dataset not in api.config['resources'].keys()]):
msg = 'Collection not found'
return api.get_exception(
HTTPStatus.NOT_FOUND, headers, request.format, 'NotFound', msg)
LOGGER.debug('Creating collection tiles')
LOGGER.debug('Loading provider')
try:
t = get_provider_by_type(
api.config['resources'][dataset]['providers'], 'tile')
p = load_plugin('provider', t)
except (KeyError, ProviderTypeError):
msg = 'Invalid collection tiles'
return api.get_exception(
HTTPStatus.BAD_REQUEST, headers, request.format,
'InvalidParameterValue', msg)
except ProviderGenericError as err:
LOGGER.error(err)
return api.get_exception(
err.http_status_code, headers, request.format,
err.ogc_exception_code, err.message)
tiles = {
'links': [],
'tilesets': []
}
tiles['links'].append({
'type': FORMAT_TYPES[F_JSON],
'rel': request.get_linkrel(F_JSON),
'title': 'This document as JSON',
'href': f'{api.get_collections_url()}/{dataset}/tiles?f={F_JSON}'
})
tiles['links'].append({
'type': FORMAT_TYPES[F_JSONLD],
'rel': request.get_linkrel(F_JSONLD),
'title': 'This document as RDF (JSON-LD)',
'href': f'{api.get_collections_url()}/{dataset}/tiles?f={F_JSONLD}'
})
tiles['links'].append({
'type': FORMAT_TYPES[F_HTML],
'rel': request.get_linkrel(F_HTML),
'title': 'This document as HTML',
'href': f'{api.get_collections_url()}/{dataset}/tiles?f={F_HTML}'
})
tile_services = p.get_tiles_service(
baseurl=api.base_url,
servicepath=f'{api.get_collections_url()}/{dataset}/tiles/{{tileMatrixSetId}}/{{tileMatrix}}/{{tileRow}}/{{tileCol}}?f={p.format_type}' # noqa
)
for service in tile_services['links']:
tiles['links'].append(service)
tiling_schemes = p.get_tiling_schemes()
for matrix in tiling_schemes:
tile_matrix = {
'title': dataset,
'tileMatrixSetURI': matrix.tileMatrixSetURI,
'crs': matrix.crs,
'dataType': 'vector',
'links': []
}
tile_matrix['links'].append({
'type': FORMAT_TYPES[F_JSON],
'rel': 'http://www.opengis.net/def/rel/ogc/1.0/tiling-scheme',
'title': f'{matrix.tileMatrixSet} TileMatrixSet definition (as {F_JSON})', # noqa
'href': f'{api.base_url}/TileMatrixSets/{matrix.tileMatrixSet}?f={F_JSON}' # noqa
})
tile_matrix['links'].append({
'type': FORMAT_TYPES[F_JSON],
'rel': request.get_linkrel(F_JSON),
'title': f'{dataset} - {matrix.tileMatrixSet} - {F_JSON}',
'href': f'{api.get_collections_url()}/{dataset}/tiles/{matrix.tileMatrixSet}?f={F_JSON}' # noqa
})
tile_matrix['links'].append({
'type': FORMAT_TYPES[F_HTML],
'rel': request.get_linkrel(F_HTML),
'title': f'{dataset} - {matrix.tileMatrixSet} - {F_HTML}',
'href': f'{api.get_collections_url()}/{dataset}/tiles/{matrix.tileMatrixSet}?f={F_HTML}' # noqa
})
tiles['tilesets'].append(tile_matrix)
if request.format == F_HTML: # render
tiles['id'] = dataset
tiles['title'] = l10n.translate(
api.config['resources'][dataset]['title'], SYSTEM_LOCALE)
tiles['tilesets'] = [
scheme.tileMatrixSet for scheme in p.get_tiling_schemes()]
tiles['bounds'] = \
api.config['resources'][dataset]['extents']['spatial']['bbox']
tiles['minzoom'] = p.options['zoom']['min']
tiles['maxzoom'] = p.options['zoom']['max']
tiles['collections_path'] = api.get_collections_url()
tiles['tile_type'] = p.tile_type
content = render_j2_template(api.tpl_config,
'collections/tiles/index.html', tiles,
request.locale)
return headers, HTTPStatus.OK, content
return headers, HTTPStatus.OK, to_json(tiles, api.pretty_print)
# TODO: no test for this function?
def get_collection_tiles_data(
api: API, request: APIRequest,
dataset=None, matrix_id=None,
z_idx=None, y_idx=None, x_idx=None) -> Tuple[dict, int, str]:
"""
Get collection items tiles
:param request: A request object
:param dataset: dataset name
:param matrix_id: matrix identifier
:param z_idx: z index
:param y_idx: y index
:param x_idx: x index
:returns: tuple of headers, status code, content
"""
format_ = request.format
if not format_:
return api.get_format_exception(request)
headers = request.get_response_headers(SYSTEM_LOCALE,
**api.api_headers)
LOGGER.debug('Processing tiles')
collections = filter_dict_by_key_value(api.config['resources'],
'type', 'collection')
if dataset not in collections.keys():
msg = 'Collection not found'
return api.get_exception(
HTTPStatus.NOT_FOUND, headers, request.format, 'NotFound', msg)
LOGGER.debug('Loading tile provider')
try:
t = get_provider_by_type(
api.config['resources'][dataset]['providers'], 'tile')
p = load_plugin('provider', t)
format_ = p.format_type
headers['Content-Type'] = format_
LOGGER.debug(f'Fetching tileset id {matrix_id} and tile {z_idx}/{y_idx}/{x_idx}') # noqa
content = p.get_tiles(layer=p.get_layer(), tileset=matrix_id,
z=z_idx, y=y_idx, x=x_idx, format_=format_)
if content is None:
msg = 'identifier not found'
return api.get_exception(
HTTPStatus.NOT_FOUND, headers, format_, 'NotFound', msg)
else:
return headers, HTTPStatus.OK, content
# @TODO: figure out if the spec requires to return json errors
except KeyError:
msg = 'Invalid collection tiles'
return api.get_exception(
HTTPStatus.BAD_REQUEST, headers, format_,
'InvalidParameterValue', msg)
except ProviderGenericError as err:
LOGGER.error(err)
return api.get_exception(
err.http_status_code, headers, request.format,
err.ogc_exception_code, err.message)
# TODO: no test for this function?
def get_collection_tiles_metadata(
api: API, request: APIRequest,
dataset=None, matrix_id=None) -> Tuple[dict, int, str]:
"""
Get collection items tiles
:param request: A request object
:param dataset: dataset name
:param matrix_id: matrix identifier
:returns: tuple of headers, status code, content
"""
if not request.is_valid([TilesMetadataFormat.TILEJSON]):
return api.get_format_exception(request)
headers = request.get_response_headers(**api.api_headers)
if any([dataset is None,
dataset not in api.config['resources'].keys()]):
msg = 'Collection not found'
return api.get_exception(
HTTPStatus.NOT_FOUND, headers, request.format, 'NotFound', msg)
LOGGER.debug('Creating collection tiles')
LOGGER.debug('Loading provider')
try:
t = get_provider_by_type(
api.config['resources'][dataset]['providers'], 'tile')
p = load_plugin('provider', t)
except KeyError:
msg = 'Invalid collection tiles'
return api.get_exception(
HTTPStatus.BAD_REQUEST, headers, request.format,
'InvalidParameterValue', msg)
except ProviderGenericError as err:
LOGGER.error(err)
return api.get_exception(
err.http_status_code, headers, request.format,
err.ogc_exception_code, err.message)
# Get provider language (if any)
prv_locale = l10n.get_plugin_locale(t, request.raw_locale)
# Set response language to requested provider locale
# (if it supports language) and/or otherwise the requested pygeoapi
# locale (or fallback default locale)
l10n.set_response_language(headers, prv_locale, request.locale)
tiles_metadata = p.get_metadata(
dataset=dataset, server_url=api.base_url,
layer=p.get_layer(), tileset=matrix_id,
metadata_format=request._format, title=l10n.translate(
api.config['resources'][dataset]['title'],
request.locale),
description=l10n.translate(
api.config['resources'][dataset]['description'],
request.locale),
language=prv_locale)
if request.format == F_HTML: # render
content = render_j2_template(api.tpl_config,
'collections/tiles/metadata.html',
tiles_metadata, request.locale)
return headers, HTTPStatus.OK, content
else:
return headers, HTTPStatus.OK, tiles_metadata
def tilematrixsets(api: API,
request: APIRequest) -> Tuple[dict, int, str]:
"""
Provide tileMatrixSets definition
:param request: A request object
:returns: tuple of headers, status code, content
"""
headers = request.get_response_headers(**api.api_headers)
# Retrieve available TileMatrixSets
enums = [e.value for e in TileMatrixSetEnum]
tms = {"tileMatrixSets": []}
for e in enums:
tms['tileMatrixSets'].append({
"title": e.title,
"id": e.tileMatrixSet,
"uri": e.tileMatrixSetURI,
"links": [
{
"rel": "self",
"type": "text/html",
"title": f"The HTML representation of the {e.tileMatrixSet} tile matrix set", # noqa
"href": f"{api.base_url}/TileMatrixSets/{e.tileMatrixSet}?f=html" # noqa
},
{
"rel": "self",
"type": "application/json",
"title": f"The JSON representation of the {e.tileMatrixSet} tile matrix set", # noqa
"href": f"{api.base_url}/TileMatrixSets/{e.tileMatrixSet}?f=json" # noqa
}
]
})
tms['links'] = [{
"rel": "alternate",
"type": "text/html",
"title": "This document as HTML",
"href": f"{api.base_url}/tileMatrixSets?f=html"
}, {
"rel": "self",
"type": "application/json",
"title": "This document",
"href": f"{api.base_url}/tileMatrixSets?f=json"
}]
if request.format == F_HTML: # render
content = render_j2_template(api.tpl_config,
'tilematrixsets/index.html',
tms, request.locale)
return headers, HTTPStatus.OK, content
return headers, HTTPStatus.OK, to_json(tms, api.pretty_print)
def tilematrixset(api: API,
request: APIRequest,
tileMatrixSetId) -> Tuple[dict,
int, str]:
"""
Provide tile matrix definition
:param request: A request object
:returns: tuple of headers, status code, content
"""
headers = request.get_response_headers(**api.api_headers)
# Retrieve relevant TileMatrixSet
enums = [e.value for e in TileMatrixSetEnum]
enum = None
try:
for e in enums:
if tileMatrixSetId == e.tileMatrixSet:
enum = e
if not enum:
raise ValueError('could not find this tilematrixset')
except ValueError as err:
return api.get_exception(
HTTPStatus.BAD_REQUEST, headers, request.format,
'InvalidParameterValue', str(err))
tms = {
"title": enum.tileMatrixSet,
"crs": enum.crs,
"id": enum.tileMatrixSet,
"uri": enum.tileMatrixSetURI,
"orderedAxes": enum.orderedAxes,
"wellKnownScaleSet": enum.wellKnownScaleSet,
"tileMatrices": enum.tileMatrices
}
if request.format == F_HTML: # render
content = render_j2_template(api.tpl_config,
'tilematrixsets/tilematrixset.html',
tms, request.locale)
return headers, HTTPStatus.OK, content
return headers, HTTPStatus.OK, to_json(tms, api.pretty_print)
def get_oas_30(cfg: dict, locale: str) -> tuple[list[dict[str, str]], dict[str, dict]]: # noqa
"""
Get OpenAPI fragments
:param cfg: `dict` of configuration
:param locale: `str` of locale
:returns: `tuple` of `list` of tag objects, and `dict` of path objects
"""
from pygeoapi.openapi import OPENAPI_YAML, get_visible_collections
paths = {}
LOGGER.debug('setting up tiles endpoints')
collections = filter_dict_by_key_value(cfg['resources'],
'type', 'collection')
for k, v in get_visible_collections(cfg).items():
tile_extension = filter_providers_by_type(
collections[k]['providers'], 'tile')
if tile_extension:
tp = load_plugin('provider', tile_extension)
tiles_path = f'/collections/{k}/tiles'
title = l10n.translate(v['title'], locale)
description = l10n.translate(v['description'], locale)
paths[tiles_path] = {
'get': {
'summary': f'Fetch a {title} tiles description',
'description': description,
'tags': [k],
'operationId': f'describe{k.capitalize()}Tiles',
'parameters': [
{'$ref': '#/components/parameters/f'},
{'$ref': '#/components/parameters/lang'}
],
'responses': {
'200': {'$ref': '#/components/responses/Tiles'},
'400': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/InvalidParameter"}, # noqa
'404': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/NotFound"}, # noqa
'500': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/ServerError"} # noqa
}
}
}
tiles_data_path = f'{tiles_path}/{{tileMatrixSetId}}/{{tileMatrix}}/{{tileRow}}/{{tileCol}}' # noqa
paths[tiles_data_path] = {
'get': {
'summary': f'Get a {title} tile',
'description': description,
'tags': [k],
'operationId': f'get{k.capitalize()}Tiles',
'parameters': [
{'$ref': f"{OPENAPI_YAML['oapit']}#/components/parameters/tileMatrixSetId"}, # noqa
{'$ref': f"{OPENAPI_YAML['oapit']}#/components/parameters/tileMatrix"}, # noqa
{'$ref': f"{OPENAPI_YAML['oapit']}#/components/parameters/tileRow"}, # noqa
{'$ref': f"{OPENAPI_YAML['oapit']}#/components/parameters/tileCol"}, # noqa
{
'name': 'f',
'in': 'query',
'description': 'The optional f parameter indicates the output format which the server shall provide as part of the response document.', # noqa
'required': False,
'schema': {
'type': 'string',
'enum': [tp.format_type],
'default': tp.format_type
},
'style': 'form',
'explode': False
}
],
'responses': {
'400': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/InvalidParameter"}, # noqa
'404': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/NotFound"}, # noqa
'500': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/ServerError"} # noqa
}
}
}
mimetype = tile_extension['format']['mimetype']
paths[tiles_data_path]['get']['responses']['200'] = {
'description': 'successful operation',
'content': {
mimetype: {
'schema': {
'type': 'string',
'format': 'binary'
}
}
}
}
return [{'name': 'tiles'}], {'paths': paths}
+76 -68
View File
@@ -8,7 +8,7 @@
# Copyright (c) 2022 Francesco Bartoli
# Copyright (c) 2022 Luca Delucchi
# Copyright (c) 2022 Krishna Lodha
# Copyright (c) 2022 Tom Kralidis
# Copyright (c) 2024 Tom Kralidis
#
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation
@@ -40,7 +40,14 @@ from typing import Tuple, Dict, Mapping, Optional
from django.conf import settings
from django.http import HttpRequest, HttpResponse
from pygeoapi.api import API
from pygeoapi.api import API, APIRequest, apply_gzip
import pygeoapi.api.coverages as coverages_api
import pygeoapi.api.environmental_data_retrieval as edr_api
import pygeoapi.api.itemtypes as itemtypes_api
import pygeoapi.api.maps as maps_api
import pygeoapi.api.processes as processes_api
import pygeoapi.api.stac as stac_api
import pygeoapi.api.tiles as tiles_api
def landing_page(request: HttpRequest) -> HttpResponse:
@@ -157,8 +164,8 @@ def collection_queryables(request: HttpRequest,
:returns: Django HTTP Response
"""
response_ = _feed_response(
request, 'get_collection_queryables', collection_id
response_ = execute_from_django(
itemtypes_api.get_collection_queryables, request, collection_id
)
response = _to_django_response(*response_)
@@ -176,22 +183,26 @@ def collection_items(request: HttpRequest, collection_id: str) -> HttpResponse:
"""
if request.method == 'GET':
response_ = _feed_response(
response_ = execute_from_django(
itemtypes_api.get_collection_items,
request,
'get_collection_items',
collection_id,
skip_valid_check=True,
)
elif request.method == 'POST':
if request.content_type is not None:
if request.content_type == 'application/geo+json':
response_ = _feed_response(request, 'manage_collection_item',
request, 'create', collection_id)
response_ = execute_from_django(
itemtypes_api.manage_collection_item, request,
'create', collection_id, skip_valid_check=True)
else:
response_ = _feed_response(request, 'post_collection_items',
request, collection_id)
response_ = execute_from_django(
itemtypes_api.post_collection_items,
request, collection_id, skip_valid_check=True,)
elif request.method == 'OPTIONS':
response_ = _feed_response(request, 'manage_collection_item',
request, 'options', collection_id)
response_ = execute_from_django(itemtypes_api.manage_collection_item,
request, 'options', collection_id,
skip_valid_check=True)
response = _to_django_response(*response_)
@@ -206,12 +217,9 @@ def collection_map(request: HttpRequest, collection_id: str):
:returns: HTTP response
"""
response_ = _feed_response(request, 'get_collection_map', collection_id)
response = _to_django_response(*response_)
return response
return execute_from_django(
maps_api.get_collection_map, request, collection_id
)
def collection_style_map(request: HttpRequest, collection_id: str,
@@ -252,17 +260,17 @@ def collection_item(request: HttpRequest,
elif request.method == 'PUT':
response_ = _feed_response(
request, 'manage_collection_item', request, 'update',
collection_id, item_id
collection_id, item_id, skip_valid_check=True,
)
elif request.method == 'DELETE':
response_ = _feed_response(
request, 'manage_collection_item', request, 'delete',
collection_id, item_id
collection_id, item_id, skip_valid_check=True,
)
elif request.method == 'OPTIONS':
response_ = _feed_response(
request, 'manage_collection_item', request, 'options',
collection_id, item_id)
collection_id, item_id, skip_valid_check=True)
response = _to_django_response(*response_)
@@ -280,8 +288,8 @@ def collection_coverage(request: HttpRequest,
:returns: Django HTTP response
"""
response_ = _feed_response(
request, 'get_collection_coverage', collection_id
response_ = execute_from_django(
coverages_api.get_collection_coverage, request, collection_id
)
response = _to_django_response(*response_)
@@ -298,10 +306,8 @@ def collection_tiles(request: HttpRequest, collection_id: str) -> HttpResponse:
:returns: Django HTTP response
"""
response_ = _feed_response(request, 'get_collection_tiles', collection_id)
response = _to_django_response(*response_)
return response
return execute_from_django(tiles_api.get_collection_tiles, request,
collection_id)
def collection_tiles_metadata(request: HttpRequest, collection_id: str,
@@ -316,15 +322,11 @@ def collection_tiles_metadata(request: HttpRequest, collection_id: str,
:returns: Django HTTP response
"""
response_ = _feed_response(
request,
'get_collection_tiles_metadata',
collection_id,
tileMatrixSetId,
return execute_from_django(
tiles_api.get_collection_tiles_metadata,
request, collection_id, tileMatrixSetId,
skip_valid_check=True,
)
response = _to_django_response(*response_)
return response
def collection_item_tiles(request: HttpRequest, collection_id: str,
@@ -343,18 +345,16 @@ def collection_item_tiles(request: HttpRequest, collection_id: str,
:returns: Django HTTP response
"""
response_ = _feed_response(
return execute_from_django(
tiles_api.get_collection_tiles_data,
request,
'get_collection_tiles_data',
collection_id,
tileMatrixSetId,
tileMatrix,
tileRow,
tileCol,
skip_valid_check=True,
)
response = _to_django_response(*response_)
return response
def processes(request: HttpRequest,
@@ -368,10 +368,8 @@ def processes(request: HttpRequest,
:returns: Django HTTP response
"""
response_ = _feed_response(request, 'describe_processes', process_id)
response = _to_django_response(*response_)
return response
return execute_from_django(processes_api.describe_processes, request,
process_id)
def jobs(request: HttpRequest, job_id: Optional[str] = None) -> HttpResponse:
@@ -384,11 +382,7 @@ def jobs(request: HttpRequest, job_id: Optional[str] = None) -> HttpResponse:
:returns: Django HTTP response
"""
response_ = _feed_response(request, 'get_jobs', job_id)
response = _to_django_response(*response_)
return response
return execute_from_django(processes_api.get_jobs, request, job_id)
def job_results(request: HttpRequest,
@@ -402,10 +396,7 @@ def job_results(request: HttpRequest,
:returns: Django HTTP response
"""
response_ = _feed_response(request, 'get_job_result', job_id)
response = _to_django_response(*response_)
return response
return execute_from_django(processes_api.get_job_result, request, job_id)
def job_results_resource(request: HttpRequest, process_id: str, job_id: str,
@@ -420,6 +411,7 @@ def job_results_resource(request: HttpRequest, process_id: str, job_id: str,
:returns: Django HTTP response
"""
# TODO: this api method does not exist
response_ = _feed_response(
request,
'get_job_result_resource',
@@ -451,17 +443,15 @@ def get_collection_edr_query(
query_type = 'locations'
else:
query_type = request.path.split('/')[-1]
response_ = _feed_response(
return execute_from_django(
edr_api.get_collection_edr_query,
request,
'get_collection_edr_query',
collection_id,
instance_id,
query_type,
location_id
location_id,
skip_valid_check=True,
)
response = _to_django_response(*response_)
return response
def stac_catalog_root(request: HttpRequest) -> HttpResponse:
@@ -473,10 +463,7 @@ def stac_catalog_root(request: HttpRequest) -> HttpResponse:
:returns: Django HTTP response
"""
response_ = _feed_response(request, 'get_stac_root')
response = _to_django_response(*response_)
return response
return execute_from_django(stac_api.get_stac_root, request)
def stac_catalog_path(request: HttpRequest, path: str) -> HttpResponse:
@@ -489,10 +476,7 @@ def stac_catalog_path(request: HttpRequest, path: str) -> HttpResponse:
:returns: Django HTTP response
"""
response_ = _feed_response(request, 'get_stac_path', path)
response = _to_django_response(*response_)
return response
return execute_from_django(stac_api.get_stac_path, request, path)
def admin_config(request: HttpRequest) -> HttpResponse:
@@ -551,6 +535,7 @@ def admin_config_resource(request: HttpRequest,
resource_id)
# TODO: remove this when all views have been refactored
def _feed_response(request: HttpRequest, api_definition: str,
*args, **kwargs) -> Tuple[Dict, int, str]:
"""Use pygeoapi api to process the input request"""
@@ -566,8 +551,31 @@ def _feed_response(request: HttpRequest, api_definition: str,
return api(request, *args, **kwargs)
def execute_from_django(api_function, request: HttpRequest, *args,
skip_valid_check=False) -> HttpResponse:
api_: API | "Admin"
if settings.PYGEOAPI_CONFIG['server'].get('admin'): # noqa
from pygeoapi.admin import Admin
api_ = Admin(settings.PYGEOAPI_CONFIG, settings.OPENAPI_DOCUMENT)
else:
api_ = API(settings.PYGEOAPI_CONFIG, settings.OPENAPI_DOCUMENT)
api_request = APIRequest.from_django(request, api_.locales)
content: str | bytes
if not skip_valid_check and not api_request.is_valid():
headers, status, content = api_.get_format_exception(api_request)
else:
headers, status, content = api_function(api_, api_request, *args)
content = apply_gzip(headers, content)
return _to_django_response(headers, status, content)
# TODO: inline this to execute_from_django after refactoring
def _to_django_response(headers: Mapping, status_code: int,
content: str) -> HttpResponse:
content: str | bytes) -> HttpResponse:
"""Convert API payload to a django response"""
response = HttpResponse(content, status=status_code)
+117 -53
View File
@@ -34,9 +34,17 @@ import os
import click
from flask import Flask, Blueprint, make_response, request, send_from_directory
from flask import (Flask, Blueprint, make_response, request,
send_from_directory, Response, Request)
from pygeoapi.api import API
from pygeoapi.api import API, APIRequest, apply_gzip
import pygeoapi.api.coverages as coverages_api
import pygeoapi.api.environmental_data_retrieval as edr_api
import pygeoapi.api.itemtypes as itemtypes_api
import pygeoapi.api.maps as maps_api
import pygeoapi.api.processes as processes_api
import pygeoapi.api.stac as stac_api
import pygeoapi.api.tiles as tiles_api
from pygeoapi.openapi import load_openapi_document
from pygeoapi.config import get_config
from pygeoapi.util import get_mimetype, get_api_rules
@@ -110,6 +118,7 @@ if (OGC_SCHEMAS_LOCATION is not None and
mimetype=get_mimetype(basename_))
# TODO: inline in execute_from_flask when all views have been refactored
def get_response(result: tuple):
"""
Creates a Flask Response object and updates matching headers.
@@ -117,7 +126,7 @@ def get_response(result: tuple):
:param result: The result of the API call.
This should be a tuple of (headers, status, content).
:returns: A Response instance.
:returns: A Response instance
"""
headers, status, content = result
@@ -128,6 +137,33 @@ def get_response(result: tuple):
return response
def execute_from_flask(api_function, request: Request, *args,
skip_valid_check=False) -> Response:
"""
Executes API function from Flask
:param api_function: API function
:param request: request object
:param *args: variable length additional arguments
:param skip_validity_check: bool
:returns: A Response instance
"""
api_request = APIRequest.from_flask(request, api_.locales)
content: str | bytes
if not skip_valid_check and not api_request.is_valid():
headers, status, content = api_.get_format_exception(api_request)
else:
headers, status, content = api_function(api_, api_request, *args)
content = apply_gzip(headers, content)
# handle jsonld too?
return get_response((headers, status, content))
@BLUEPRINT.route('/')
def landing_page():
"""
@@ -145,6 +181,7 @@ def openapi():
:returns: HTTP response
"""
return get_response(api_.openapi_(request))
@@ -155,6 +192,7 @@ def conformance():
:returns: HTTP response
"""
return get_response(api_.conformance(request))
@@ -164,9 +202,12 @@ def get_tilematrix_set(tileMatrixSetId=None):
OGC API TileMatrixSet endpoint
:param tileMatrixSetId: identifier of tile matrix set
:returns: HTTP response
"""
return get_response(api_.tilematrixset(request, tileMatrixSetId))
return execute_from_flask(tiles_api.tilematrixset, request,
tileMatrixSetId)
@BLUEPRINT.route('/TileMatrixSets')
@@ -176,7 +217,8 @@ def get_tilematrix_sets():
:returns: HTTP response
"""
return get_response(api_.tilematrixsets(request))
return execute_from_flask(tiles_api.tilematrixsets, request)
@BLUEPRINT.route('/collections')
@@ -189,6 +231,7 @@ def collections(collection_id=None):
:returns: HTTP response
"""
return get_response(api_.describe_collections(request, collection_id))
@@ -201,6 +244,7 @@ def collection_schema(collection_id):
:returns: HTTP response
"""
return get_response(api_.get_collection_schema(request, collection_id))
@@ -213,7 +257,9 @@ def collection_queryables(collection_id=None):
:returns: HTTP response
"""
return get_response(api_.get_collection_queryables(request, collection_id))
return execute_from_flask(itemtypes_api.get_collection_queryables, request,
collection_id)
@BLUEPRINT.route('/collections/<path:collection_id>/items',
@@ -234,36 +280,40 @@ def collection_items(collection_id, item_id=None):
if item_id is None:
if request.method == 'GET': # list items
return get_response(
api_.get_collection_items(request, collection_id))
return execute_from_flask(itemtypes_api.get_collection_items,
request, collection_id,
skip_valid_check=True)
elif request.method == 'POST': # filter or manage items
if request.content_type is not None:
if request.content_type == 'application/geo+json':
return get_response(
api_.manage_collection_item(request, 'create',
collection_id))
return execute_from_flask(
itemtypes_api.manage_collection_item,
request, 'create', collection_id,
skip_valid_check=True)
else:
return get_response(
api_.post_collection_items(request, collection_id))
return execute_from_flask(
itemtypes_api.post_collection_items, request,
collection_id, skip_valid_check=True)
elif request.method == 'OPTIONS':
return get_response(
api_.manage_collection_item(request, 'options', collection_id))
return execute_from_flask(
itemtypes_api.manage_collection_item, request, 'options',
collection_id, skip_valid_check=True)
elif request.method == 'DELETE':
return get_response(
api_.manage_collection_item(request, 'delete',
collection_id, item_id))
return execute_from_flask(itemtypes_api.manage_collection_item,
request, 'delete', collection_id, item_id,
skip_valid_check=True)
elif request.method == 'PUT':
return get_response(
api_.manage_collection_item(request, 'update',
collection_id, item_id))
return execute_from_flask(itemtypes_api.manage_collection_item,
request, 'update', collection_id, item_id,
skip_valid_check=True)
elif request.method == 'OPTIONS':
return get_response(
api_.manage_collection_item(request, 'options',
collection_id, item_id))
return execute_from_flask(itemtypes_api.manage_collection_item,
request, 'options', collection_id, item_id,
skip_valid_check=True)
else:
return get_response(
api_.get_collection_item(request, collection_id, item_id))
return execute_from_flask(itemtypes_api.get_collection_item, request,
collection_id, item_id)
@BLUEPRINT.route('/collections/<path:collection_id>/coverage')
@@ -275,7 +325,9 @@ def collection_coverage(collection_id):
:returns: HTTP response
"""
return get_response(api_.get_collection_coverage(request, collection_id))
return execute_from_flask(coverages_api.get_collection_coverage, request,
collection_id)
@BLUEPRINT.route('/collections/<path:collection_id>/tiles')
@@ -287,8 +339,9 @@ def get_collection_tiles(collection_id=None):
:returns: HTTP response
"""
return get_response(api_.get_collection_tiles(
request, collection_id))
return execute_from_flask(tiles_api.get_collection_tiles, request,
collection_id)
@BLUEPRINT.route('/collections/<path:collection_id>/tiles/<tileMatrixSetId>')
@@ -302,8 +355,10 @@ def get_collection_tiles_metadata(collection_id=None, tileMatrixSetId=None):
:returns: HTTP response
"""
return get_response(api_.get_collection_tiles_metadata(
request, collection_id, tileMatrixSetId))
return execute_from_flask(tiles_api.get_collection_tiles_metadata,
request, collection_id, tileMatrixSetId,
skip_valid_check=True)
@BLUEPRINT.route('/collections/<path:collection_id>/tiles/\
@@ -321,8 +376,12 @@ def get_collection_tiles_data(collection_id=None, tileMatrixSetId=None,
:returns: HTTP response
"""
return get_response(api_.get_collection_tiles_data(
request, collection_id, tileMatrixSetId, tileMatrix, tileRow, tileCol))
return execute_from_flask(
tiles_api.get_collection_tiles_data,
request, collection_id, tileMatrixSetId, tileMatrix, tileRow, tileCol,
skip_valid_check=True,
)
@BLUEPRINT.route('/collections/<collection_id>/map')
@@ -337,15 +396,9 @@ def collection_map(collection_id, style_id=None):
:returns: HTTP response
"""
headers, status_code, content = api_.get_collection_map(
request, collection_id, style_id)
response = make_response(content, status_code)
if headers:
response.headers = headers
return response
return execute_from_flask(
maps_api.get_collection_map, request, collection_id, style_id
)
@BLUEPRINT.route('/processes')
@@ -358,7 +411,9 @@ def get_processes(process_id=None):
:returns: HTTP response
"""
return get_response(api_.describe_processes(request, process_id))
return execute_from_flask(processes_api.describe_processes, request,
process_id)
@BLUEPRINT.route('/jobs')
@@ -374,12 +429,12 @@ def get_jobs(job_id=None):
"""
if job_id is None:
return get_response(api_.get_jobs(request))
return execute_from_flask(processes_api.get_jobs, request)
else:
if request.method == 'DELETE': # dismiss job
return get_response(api_.delete_job(request, job_id))
return execute_from_flask(processes_api.delete_jobs, request)
else: # Return status of a specific job
return get_response(api_.get_jobs(request, job_id))
return execute_from_flask(processes_api.get_jobs, request, job_id)
@BLUEPRINT.route('/processes/<process_id>/execution', methods=['POST'])
@@ -392,7 +447,8 @@ def execute_process_jobs(process_id):
:returns: HTTP response
"""
return get_response(api_.execute_process(request, process_id))
return execute_from_flask(processes_api.execute_process, request,
process_id)
@BLUEPRINT.route('/jobs/<job_id>/results',
@@ -405,7 +461,8 @@ def get_job_result(job_id=None):
:returns: HTTP response
"""
return get_response(api_.get_job_result(request, job_id))
return execute_from_flask(processes_api.get_job_result, request, job_id)
@BLUEPRINT.route('/jobs/<job_id>/results/<resource>',
@@ -419,6 +476,8 @@ def get_job_result_resource(job_id, resource):
:returns: HTTP response
"""
# TODO: this does not seem to exist?
return get_response(api_.get_job_result_resource(
request, job_id, resource))
@@ -450,14 +509,17 @@ def get_collection_edr_query(collection_id, instance_id=None,
:returns: HTTP response
"""
if location_id:
query_type = 'locations'
else:
query_type = request.path.split('/')[-1]
return get_response(api_.get_collection_edr_query(request, collection_id,
instance_id, query_type,
location_id))
return execute_from_flask(
edr_api.get_collection_edr_query, request, collection_id, instance_id,
query_type, location_id,
skip_valid_check=True,
)
@BLUEPRINT.route('/stac')
@@ -467,7 +529,8 @@ def stac_catalog_root():
:returns: HTTP response
"""
return get_response(api_.get_stac_root(request))
return execute_from_flask(stac_api.get_stac_root, request)
@BLUEPRINT.route('/stac/<path:path>')
@@ -479,7 +542,8 @@ def stac_catalog_path(path):
:returns: HTTP response
"""
return get_response(api_.get_stac_path(request, path))
return execute_from_flask(stac_api.get_stac_path, request, path)
@ADMIN_BLUEPRINT.route('/admin/config', methods=['GET', 'PUT', 'PATCH'])
+189 -969
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -52,7 +52,7 @@ from pygeoapi.util import (
JobStatus,
ProcessExecutionMode,
RequestedProcessExecutionMode,
Subscriber,
Subscriber
)
LOGGER = logging.getLogger(__name__)
@@ -318,7 +318,6 @@ class BaseManager:
jfmt = 'application/json'
self.update_job(job_id, job_metadata)
self._send_failed_notification(subscriber)
return jfmt, outputs, current_status
@@ -328,7 +327,7 @@ class BaseManager:
process_id: str,
data_dict: dict,
execution_mode: Optional[RequestedProcessExecutionMode] = None,
subscriber: Optional[Subscriber] = None,
subscriber: Optional[Subscriber] = None
) -> Tuple[str, Any, JobStatus, Optional[Dict[str, str]]]:
"""
Default process execution handler
@@ -390,6 +389,7 @@ class BaseManager:
# managers
**({'subscriber': subscriber} if self.supports_subscribing else {})
)
return job_id, mime_type, outputs, status, response_headers
def _send_in_progress_notification(self, subscriber: Optional[Subscriber]):
+1 -1
View File
@@ -72,7 +72,7 @@ class DummyManager(BaseManager):
process_id: str,
data_dict: dict,
execution_mode: Optional[RequestedProcessExecutionMode] = None,
subscriber: Optional[Subscriber] = None,
subscriber: Optional[Subscriber] = None
) -> Tuple[str, str, Any, JobStatus, Optional[Dict[str, str]]]:
"""
Default process execution handler
+7 -4
View File
@@ -398,12 +398,15 @@ class XarrayProvider(BaseProvider):
}
if 'crs' in self._data.variables.keys():
properties['bbox_crs'] = f'http://www.opengis.net/def/crs/OGC/1.3/{self._data.crs.epsg_code}' # noqa
try:
properties['bbox_crs'] = f'http://www.opengis.net/def/crs/OGC/1.3/{self._data.crs.epsg_code}' # noqa
properties['inverse_flattening'] = self._data.crs.\
inverse_flattening
properties['inverse_flattening'] = self._data.crs.\
inverse_flattening
properties['crs_type'] = 'ProjectedCRS'
properties['crs_type'] = 'ProjectedCRS'
except AttributeError:
pass
properties['axes'] = [
properties['x_axis_label'],
+100 -55
View File
@@ -50,7 +50,14 @@ from starlette.responses import (
)
import uvicorn
from pygeoapi.api import API
from pygeoapi.api import API, APIRequest, apply_gzip
import pygeoapi.api.coverages as coverages_api
import pygeoapi.api.environmental_data_retrieval as edr_api
import pygeoapi.api.itemtypes as itemtypes_api
import pygeoapi.api.maps as maps_api
import pygeoapi.api.processes as processes_api
import pygeoapi.api.stac as stac_api
import pygeoapi.api.tiles as tiles_api
from pygeoapi.openapi import load_openapi_document
from pygeoapi.config import get_config
from pygeoapi.util import get_api_rules
@@ -113,10 +120,12 @@ async def get_response(
"""
loop = asyncio.get_running_loop()
result = await loop.run_in_executor(
headers, status, content = await loop.run_in_executor(
None, call_api_threadsafe, loop, api_call, *args)
return _to_response(headers, status, content)
headers, status, content = result
def _to_response(headers, status, content):
if headers['Content-Type'] == 'text/html':
response = HTMLResponse(content=content, status_code=status)
else:
@@ -130,6 +139,27 @@ async def get_response(
return response
async def execute_from_starlette(api_function, request: Request, *args,
skip_valid_check=False) -> Response:
api_request = await APIRequest.from_starlette(request, api_.locales)
content: str | bytes
if not skip_valid_check and not api_request.is_valid():
headers, status, content = api_.get_format_exception(api_request)
else:
loop = asyncio.get_running_loop()
headers, status, content = await loop.run_in_executor(
None, call_api_threadsafe, loop, api_function,
api_, api_request, *args)
# NOTE: that gzip currently doesn't work in starlette
# https://github.com/geopython/pygeoapi/issues/1591
content = apply_gzip(headers, content)
response = _to_response(headers, status, content)
return response
async def landing_page(request: Request):
"""
OGC API landing page endpoint
@@ -173,8 +203,9 @@ async def get_tilematrix_set(request: Request, tileMatrixSetId=None):
if 'tileMatrixSetId' in request.path_params:
tileMatrixSetId = request.path_params['tileMatrixSetId']
return await get_response(
api_.tilematrixset, request, tileMatrixSetId)
return await execute_from_starlette(
tiles_api.tilematrixset, request, tileMatrixSetId,
)
async def get_tilematrix_sets(request: Request):
@@ -183,7 +214,7 @@ async def get_tilematrix_sets(request: Request):
:returns: HTTP response
"""
return await get_response(api_.tilematrixsets, request)
return await execute_from_starlette(tiles_api.tilematrixsets, request)
async def collection_schema(request: Request, collection_id=None):
@@ -198,8 +229,8 @@ async def collection_schema(request: Request, collection_id=None):
if 'collection_id' in request.path_params:
collection_id = request.path_params['collection_id']
return await get_response(
api_.get_collection_schema, request, collection_id)
return await get_response(api_.get_collection_schema, request,
collection_id)
async def collection_queryables(request: Request, collection_id=None):
@@ -214,8 +245,9 @@ async def collection_queryables(request: Request, collection_id=None):
if 'collection_id' in request.path_params:
collection_id = request.path_params['collection_id']
return await get_response(
api_.get_collection_queryables, request, collection_id)
return await execute_from_starlette(
itemtypes_api.get_collection_queryables, request, collection_id,
)
async def get_collection_tiles(request: Request, collection_id=None):
@@ -229,8 +261,9 @@ async def get_collection_tiles(request: Request, collection_id=None):
"""
if 'collection_id' in request.path_params:
collection_id = request.path_params['collection_id']
return await get_response(
api_.get_collection_tiles, request, collection_id)
return await execute_from_starlette(
tiles_api.get_collection_tiles, request, collection_id)
async def get_collection_tiles_metadata(request: Request, collection_id=None,
@@ -247,9 +280,10 @@ async def get_collection_tiles_metadata(request: Request, collection_id=None,
collection_id = request.path_params['collection_id']
if 'tileMatrixSetId' in request.path_params:
tileMatrixSetId = request.path_params['tileMatrixSetId']
return await get_response(
api_.get_collection_tiles_metadata, request,
collection_id, tileMatrixSetId
return await execute_from_starlette(
tiles_api.get_collection_tiles_metadata, request,
collection_id, tileMatrixSetId, skip_valid_check=True,
)
@@ -278,9 +312,10 @@ async def get_collection_items_tiles(request: Request, collection_id=None,
tileRow = request.path_params['tileRow']
if 'tileCol' in request.path_params:
tileCol = request.path_params['tileCol']
return await get_response(
api_.get_collection_tiles_data, request, collection_id,
tileMatrixSetId, tile_matrix, tileRow, tileCol
return await execute_from_starlette(
tiles_api.get_collection_tiles_data, request, collection_id,
tileMatrixSetId, tile_matrix, tileRow, tileCol,
skip_valid_check=True,
)
@@ -301,45 +336,47 @@ async def collection_items(request: Request, collection_id=None, item_id=None):
item_id = request.path_params['item_id']
if item_id is None:
if request.method == 'GET': # list items
return await get_response(
api_.get_collection_items, request, collection_id)
return await execute_from_starlette(
itemtypes_api.get_collection_items, request, collection_id,
skip_valid_check=True)
elif request.method == 'POST': # filter or manage items
content_type = request.headers.get('content-type')
if content_type is not None:
if content_type == 'application/geo+json':
return await get_response(
api_.manage_collection_item, request,
'create', collection_id)
return await execute_from_starlette(
itemtypes_api.manage_collection_item, request,
'create', collection_id, skip_valid_check=True)
else:
return await get_response(
api_.post_collection_items,
return await execute_from_starlette(
itemtypes_api.post_collection_items,
request,
collection_id
collection_id,
skip_valid_check=True,
)
elif request.method == 'OPTIONS':
return await get_response(
api_.manage_collection_item, request,
'options', collection_id
return await execute_from_starlette(
itemtypes_api.manage_collection_item, request,
'options', collection_id, skip_valid_check=True,
)
elif request.method == 'DELETE':
return await get_response(
api_.manage_collection_item, request, 'delete',
collection_id, item_id
return await execute_from_starlette(
itemtypes_api.manage_collection_item, request, 'delete',
collection_id, item_id, skip_valid_check=True,
)
elif request.method == 'PUT':
return await get_response(
api_.manage_collection_item, request, 'update',
collection_id, item_id
return await execute_from_starlette(
itemtypes_api.manage_collection_item, request, 'update',
collection_id, item_id, skip_valid_check=True,
)
elif request.method == 'OPTIONS':
return await get_response(
api_.manage_collection_item, request, 'options',
collection_id, item_id
return await execute_from_starlette(
itemtypes_api.manage_collection_item, request, 'options',
collection_id, item_id, skip_valid_check=True,
)
else:
return await get_response(
api_.get_collection_item, request, collection_id, item_id)
return await execute_from_starlette(
itemtypes_api.get_collection_item, request, collection_id, item_id)
async def collection_coverage(request: Request, collection_id=None):
@@ -354,8 +391,8 @@ async def collection_coverage(request: Request, collection_id=None):
if 'collection_id' in request.path_params:
collection_id = request.path_params['collection_id']
return await get_response(
api_.get_collection_coverage, request, collection_id)
return await execute_from_starlette(
coverages_api.get_collection_coverage, request, collection_id)
async def collection_map(request: Request, collection_id, style_id=None):
@@ -373,8 +410,9 @@ async def collection_map(request: Request, collection_id, style_id=None):
if 'style_id' in request.path_params:
style_id = request.path_params['style_id']
return await get_response(
api_.get_collection_map, request, collection_id, style_id)
return await execute_from_starlette(
maps_api.get_collection_map, request, collection_id, style_id
)
async def get_processes(request: Request, process_id=None):
@@ -389,7 +427,8 @@ async def get_processes(request: Request, process_id=None):
if 'process_id' in request.path_params:
process_id = request.path_params['process_id']
return await get_response(api_.describe_processes, request, process_id)
return await execute_from_starlette(processes_api.describe_processes,
request, process_id)
async def get_jobs(request: Request, job_id=None):
@@ -406,12 +445,14 @@ async def get_jobs(request: Request, job_id=None):
job_id = request.path_params['job_id']
if job_id is None: # list of submit job
return await get_response(api_.get_jobs, request)
return await execute_from_starlette(processes_api.get_jobs, request)
else: # get or delete job
if request.method == 'DELETE':
return await get_response(api_.delete_job, job_id)
return await execute_from_starlette(processes_api.delete_job,
request, job_id)
else: # Return status of a specific job
return await get_response(api_.get_jobs, request, job_id)
return await execute_from_starlette(processes_api.get_jobs,
request, job_id)
async def execute_process_jobs(request: Request, process_id=None):
@@ -427,7 +468,8 @@ async def execute_process_jobs(request: Request, process_id=None):
if 'process_id' in request.path_params:
process_id = request.path_params['process_id']
return await get_response(api_.execute_process, request, process_id)
return await execute_from_starlette(processes_api.execute_process,
request, process_id)
async def get_job_result(request: Request, job_id=None):
@@ -443,7 +485,8 @@ async def get_job_result(request: Request, job_id=None):
if 'job_id' in request.path_params:
job_id = request.path_params['job_id']
return await get_response(api_.get_job_result, request, job_id)
return await execute_from_starlette(processes_api.get_job_result,
request, job_id)
async def get_job_result_resource(request: Request,
@@ -463,6 +506,7 @@ async def get_job_result_resource(request: Request,
if 'resource' in request.path_params:
resource = request.path_params['resource']
# TODO: this api function currently doesn't exist
return await get_response(
api_.get_job_result_resource, request, job_id, resource)
@@ -484,9 +528,10 @@ async def get_collection_edr_query(request: Request, collection_id=None, instanc
instance_id = request.path_params['instance_id']
query_type = request["path"].split('/')[-1] # noqa
return await get_response(
api_.get_collection_edr_query, request, collection_id,
instance_id, query_type
return await execute_from_starlette(
edr_api.get_collection_edr_query, request, collection_id,
instance_id, query_type,
skip_valid_check=True,
)
@@ -513,7 +558,7 @@ async def stac_catalog_root(request: Request):
:returns: Starlette HTTP response
"""
return await get_response(api_.get_stac_root, request)
return await execute_from_starlette(stac_api.get_stac_root, request)
async def stac_catalog_path(request: Request):
@@ -525,7 +570,7 @@ async def stac_catalog_path(request: Request):
:returns: Starlette HTTP response
"""
path = request.path_params["path"]
return await get_response(api_.get_stac_path, request, path)
return await execute_from_starlette(stac_api.get_stac_path, request, path)
async def admin_config(request: Request):
+3 -1
View File
@@ -609,10 +609,12 @@ class JobStatus(Enum):
@dataclass(frozen=True)
class Subscriber:
"""Store subscriber urls as defined in:
"""
Store subscriber URLs as defined in:
https://schemas.opengis.net/ogcapi/processes/part1/1.0/openapi/schemas/subscriber.yaml # noqa
"""
success_uri: str
in_progress_uri: Optional[str]
failed_uri: Optional[str]
+869
View File
@@ -0,0 +1,869 @@
# =================================================================
#
# Authors: Tom Kralidis <tomkralidis@gmail.com>
# John A Stevenson <jostev@bgs.ac.uk>
# Colin Blackburn <colb@bgs.ac.uk>
# Bernhard Mallinger <bernhard.mallinger@eox.at>
#
# Copyright (c) 2024 Tom Kralidis
# Copyright (c) 2022 John A Stevenson and Colin Blackburn
#
# 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 json
import gzip
from http import HTTPStatus
from pyld import jsonld
import pytest
from pygeoapi.api import (
API, APIRequest, FORMAT_TYPES, F_HTML, F_JSON, F_JSONLD, F_GZIP,
__version__, validate_bbox, validate_datetime,
validate_subset
)
from pygeoapi.util import yaml_load, get_api_rules, get_base_url
from tests.util import (get_test_file_path, mock_flask, mock_starlette,
mock_request)
@pytest.fixture()
def config():
with open(get_test_file_path('pygeoapi-test-config.yml')) as fh:
return yaml_load(fh)
@pytest.fixture()
def config_with_rules() -> dict:
""" Returns a pygeoapi configuration with default API rules. """
with open(get_test_file_path('pygeoapi-test-config-apirules.yml')) as fh:
return yaml_load(fh)
@pytest.fixture()
def config_enclosure() -> dict:
""" Returns a pygeoapi configuration with enclosure links. """
with open(get_test_file_path('pygeoapi-test-config-enclosure.yml')) as fh:
return yaml_load(fh)
@pytest.fixture()
def config_hidden_resources():
filename = 'pygeoapi-test-config-hidden-resources.yml'
with open(get_test_file_path(filename)) as fh:
return yaml_load(fh)
@pytest.fixture()
def enclosure_api(config_enclosure, openapi):
""" Returns an API instance with a collection with enclosure links. """
return API(config_enclosure, openapi)
@pytest.fixture()
def rules_api(config_with_rules, openapi):
""" Returns an API instance with URL prefix and strict slashes policy.
The API version is extracted from the current version here.
"""
return API(config_with_rules, openapi)
@pytest.fixture()
def api_hidden_resources(config_hidden_resources, openapi):
return API(config_hidden_resources, openapi)
def test_apirequest(api_):
# Test without (valid) locales
with pytest.raises(ValueError):
req = mock_request()
APIRequest(req, [])
APIRequest(req, None)
APIRequest(req, ['zz'])
# Test all supported formats from query args
for f, mt in FORMAT_TYPES.items():
req = mock_request({'f': f})
apireq = APIRequest(req, api_.locales)
assert apireq.is_valid()
assert apireq.format == f
assert apireq.get_response_headers()['Content-Type'] == mt
# Test all supported formats from Accept header
for f, mt in FORMAT_TYPES.items():
req = mock_request(HTTP_ACCEPT=mt)
apireq = APIRequest(req, api_.locales)
assert apireq.is_valid()
assert apireq.format == f
assert apireq.get_response_headers()['Content-Type'] == mt
# Test nonsense format
req = mock_request({'f': 'foo'})
apireq = APIRequest(req, api_.locales)
assert not apireq.is_valid()
assert apireq.format == 'foo'
assert apireq.is_valid(('foo',))
assert apireq.get_response_headers()['Content-Type'] == \
FORMAT_TYPES[F_JSON]
# Test without format
req = mock_request()
apireq = APIRequest(req, api_.locales)
assert apireq.is_valid()
assert apireq.format is None
assert apireq.get_response_headers()['Content-Type'] == \
FORMAT_TYPES[F_JSON]
assert apireq.get_linkrel(F_JSON) == 'self'
assert apireq.get_linkrel(F_HTML) == 'alternate'
# Test complex format string
hh = 'text/html,application/xhtml+xml,application/xml;q=0.9,'
req = mock_request(HTTP_ACCEPT=hh)
apireq = APIRequest(req, api_.locales)
assert apireq.is_valid()
assert apireq.format == F_HTML
assert apireq.get_response_headers()['Content-Type'] == \
FORMAT_TYPES[F_HTML]
assert apireq.get_linkrel(F_HTML) == 'self'
assert apireq.get_linkrel(F_JSON) == 'alternate'
# Test accept header with multiple valid formats
hh = 'plain/text,application/ld+json,application/json;q=0.9,'
req = mock_request(HTTP_ACCEPT=hh)
apireq = APIRequest(req, api_.locales)
assert apireq.is_valid()
assert apireq.format == F_JSONLD
assert apireq.get_response_headers()['Content-Type'] == \
FORMAT_TYPES[F_JSONLD]
assert apireq.get_linkrel(F_JSONLD) == 'self'
assert apireq.get_linkrel(F_HTML) == 'alternate'
# Overrule HTTP content negotiation
req = mock_request({'f': 'html'}, HTTP_ACCEPT='application/json') # noqa
apireq = APIRequest(req, api_.locales)
assert apireq.is_valid()
assert apireq.format == F_HTML
assert apireq.get_response_headers()['Content-Type'] == \
FORMAT_TYPES[F_HTML]
# Test data
for d in (None, '', 'test', {'key': 'value'}):
req = mock_request(data=d)
apireq = APIRequest.with_data(req, api_.locales)
if not d:
assert apireq.data == b''
elif isinstance(d, dict):
assert d == json.loads(apireq.data)
else:
assert apireq.data == d.encode()
# Test multilingual
test_lang = {
'nl': ('en', 'en-US'), # unsupported lang should return default
'en-US': ('en', 'en-US'),
'de_CH': ('en', 'en-US'),
'fr-CH, fr;q=0.9, en;q=0.8': ('fr', 'fr-CA'),
'fr-CH, fr-BE;q=0.9': ('fr', 'fr-CA'),
}
sup_lang = ('en-US', 'fr_CA')
for lang_in, (lang_out, cl_out) in test_lang.items():
# Using l query parameter
req = mock_request({'lang': lang_in})
apireq = APIRequest(req, sup_lang)
assert apireq.raw_locale == lang_in
assert apireq.locale.language == lang_out
assert apireq.get_response_headers()['Content-Language'] == cl_out
# Using Accept-Language header
req = mock_request(HTTP_ACCEPT_LANGUAGE=lang_in)
apireq = APIRequest(req, sup_lang)
assert apireq.raw_locale == lang_in
assert apireq.locale.language == lang_out
assert apireq.get_response_headers()['Content-Language'] == cl_out
# Test language override
req = mock_request({'lang': 'fr'}, HTTP_ACCEPT_LANGUAGE='en_US')
apireq = APIRequest(req, sup_lang)
assert apireq.raw_locale == 'fr'
assert apireq.locale.language == 'fr'
assert apireq.get_response_headers()['Content-Language'] == 'fr-CA'
# Test locale territory
req = mock_request({'lang': 'en-GB'})
apireq = APIRequest(req, sup_lang)
assert apireq.raw_locale == 'en-GB'
assert apireq.locale.language == 'en'
assert apireq.locale.territory == 'US'
assert apireq.get_response_headers()['Content-Language'] == 'en-US'
# Test without Accept-Language header or 'lang' query parameter
# (should return default language from YAML config)
req = mock_request()
apireq = APIRequest(req, api_.locales)
assert apireq.raw_locale is None
assert apireq.locale.language == api_.default_locale.language
assert apireq.get_response_headers()['Content-Language'] == 'en-US'
# Test without Accept-Language header or 'lang' query param
# (should return first in custom list of languages)
sup_lang = ('de', 'fr', 'en')
apireq = APIRequest(req, sup_lang)
assert apireq.raw_locale is None
assert apireq.locale.language == 'de'
assert apireq.get_response_headers()['Content-Language'] == 'de'
def test_apirules_active(config_with_rules, rules_api):
assert rules_api.config == config_with_rules
rules = get_api_rules(config_with_rules)
base_url = get_base_url(config_with_rules)
# Test Flask
flask_prefix = rules.get_url_prefix('flask')
with mock_flask('pygeoapi-test-config-apirules.yml') as flask_client:
# Test happy path
response = flask_client.get(f'{flask_prefix}/conformance')
assert response.status_code == 200
assert response.headers['X-API-Version'] == __version__
assert response.request.url == \
flask_client.application.url_for('pygeoapi.conformance')
response = flask_client.get(f'{flask_prefix}/static/img/pygeoapi.png')
assert response.status_code == 200
# Test that static resources also work without URL prefix
response = flask_client.get('/static/img/pygeoapi.png')
assert response.status_code == 200
# Test strict slashes
response = flask_client.get(f'{flask_prefix}/conformance/')
assert response.status_code == 404
# For the landing page ONLY, trailing slashes are actually preferred.
# See https://docs.opengeospatial.org/is/17-069r4/17-069r4.html#_api_landing_page # noqa
# Omitting the trailing slash should lead to a redirect.
response = flask_client.get(f'{flask_prefix}/')
assert response.status_code == 200
response = flask_client.get(flask_prefix)
assert response.status_code in (307, 308)
# Test links on landing page for correct URLs
response = flask_client.get(flask_prefix, follow_redirects=True)
assert response.status_code == 200
assert response.is_json
links = response.json['links']
assert all(
href.startswith(base_url) for href in (rel['href'] for rel in links) # noqa
)
# Test Starlette
starlette_prefix = rules.get_url_prefix('starlette')
with mock_starlette('pygeoapi-test-config-apirules.yml') as starlette_client: # noqa
# Test happy path
response = starlette_client.get(f'{starlette_prefix}/conformance')
assert response.status_code == 200
assert response.headers['X-API-Version'] == __version__
response = starlette_client.get(f'{starlette_prefix}/static/img/pygeoapi.png') # noqa
assert response.status_code == 200
# Test that static resources also work without URL prefix
response = starlette_client.get('/static/img/pygeoapi.png')
assert response.status_code == 200
# Test strict slashes
response = starlette_client.get(f'{starlette_prefix}/conformance/')
assert response.status_code == 404
# For the landing page ONLY, trailing slashes are actually preferred.
# See https://docs.opengeospatial.org/is/17-069r4/17-069r4.html#_api_landing_page # noqa
# Omitting the trailing slash should lead to a redirect.
response = starlette_client.get(f'{starlette_prefix}/')
assert response.status_code == 200
response = starlette_client.get(starlette_prefix)
assert response.status_code in (307, 308)
# Test links on landing page for correct URLs
response = starlette_client.get(starlette_prefix, follow_redirects=True) # noqa
assert response.status_code == 200
links = response.json()['links']
assert all(
href.startswith(base_url) for href in (rel['href'] for rel in links) # noqa
)
def test_apirules_inactive(config, api_):
assert api_.config == config
rules = get_api_rules(config)
# Test Flask
flask_prefix = rules.get_url_prefix('flask')
assert flask_prefix == ''
with mock_flask('pygeoapi-test-config.yml') as flask_client:
response = flask_client.get('')
assert response.status_code == 200
response = flask_client.get('/conformance')
assert response.status_code == 200
assert 'X-API-Version' not in response.headers
assert response.request.url == \
flask_client.application.url_for('pygeoapi.conformance')
response = flask_client.get('/static/img/pygeoapi.png')
assert response.status_code == 200
# Test trailing slashes
response = flask_client.get('/')
assert response.status_code == 200
response = flask_client.get('/conformance/')
assert response.status_code == 200
assert 'X-API-Version' not in response.headers
# Test Starlette
starlette_prefix = rules.get_url_prefix('starlette')
assert starlette_prefix == ''
with mock_starlette('pygeoapi-test-config.yml') as starlette_client:
response = starlette_client.get('')
assert response.status_code == 200
response = starlette_client.get('/conformance')
assert response.status_code == 200
assert 'X-API-Version' not in response.headers
assert str(response.url) == f"{starlette_client.base_url}/conformance"
response = starlette_client.get('/static/img/pygeoapi.png')
assert response.status_code == 200
# Test trailing slashes
response = starlette_client.get('/')
assert response.status_code == 200
response = starlette_client.get('/conformance/', follow_redirects=True)
assert response.status_code == 200
assert 'X-API-Version' not in response.headers
def test_api(config, api_, openapi):
assert api_.config == config
assert isinstance(api_.config, dict)
req = mock_request(HTTP_ACCEPT='application/json')
rsp_headers, code, response = api_.openapi_(req)
assert rsp_headers['Content-Type'] == 'application/vnd.oai.openapi+json;version=3.0' # noqa
# No language requested: should be set to default from YAML
assert rsp_headers['Content-Language'] == 'en-US'
root = json.loads(response)
assert isinstance(root, dict)
a = 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
req = mock_request(HTTP_ACCEPT=a)
rsp_headers, code, response = api_.openapi_(req)
assert rsp_headers['Content-Type'] == FORMAT_TYPES[F_HTML] == \
FORMAT_TYPES[F_HTML]
assert 'Swagger UI' in response
a = 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
req = mock_request({'ui': 'redoc'}, HTTP_ACCEPT=a)
rsp_headers, code, response = api_.openapi_(req)
assert rsp_headers['Content-Type'] == FORMAT_TYPES[F_HTML] == \
FORMAT_TYPES[F_HTML]
assert 'ReDoc' in response
req = mock_request({'f': 'foo'})
rsp_headers, code, response = api_.openapi_(req)
assert rsp_headers['Content-Language'] == 'en-US'
assert code == HTTPStatus.BAD_REQUEST
assert api_.get_collections_url() == 'http://localhost:5000/collections'
def test_api_exception(config, api_):
req = mock_request({'f': 'foo'})
rsp_headers, code, response = api_.landing_page(req)
assert rsp_headers['Content-Language'] == 'en-US'
assert code == HTTPStatus.BAD_REQUEST
# When a language is set, the exception should still be English
req = mock_request({'f': 'foo', 'lang': 'fr'})
rsp_headers, code, response = api_.landing_page(req)
assert rsp_headers['Content-Language'] == 'en-US'
assert code == HTTPStatus.BAD_REQUEST
def test_gzip(config, api_, openapi):
# Requests for each response type and gzip encoding
req_gzip_json = mock_request(HTTP_ACCEPT=FORMAT_TYPES[F_JSON],
HTTP_ACCEPT_ENCODING=F_GZIP)
req_gzip_jsonld = mock_request(HTTP_ACCEPT=FORMAT_TYPES[F_JSONLD],
HTTP_ACCEPT_ENCODING=F_GZIP)
req_gzip_html = mock_request(HTTP_ACCEPT=FORMAT_TYPES[F_HTML],
HTTP_ACCEPT_ENCODING=F_GZIP)
req_gzip_gzip = mock_request(HTTP_ACCEPT='application/gzip',
HTTP_ACCEPT_ENCODING=F_GZIP)
# Responses from server config without gzip compression
rsp_headers, _, rsp_json = api_.landing_page(req_gzip_json)
assert rsp_headers['Content-Type'] == FORMAT_TYPES[F_JSON]
rsp_headers, _, rsp_jsonld = api_.landing_page(req_gzip_jsonld)
assert rsp_headers['Content-Type'] == FORMAT_TYPES[F_JSONLD]
rsp_headers, _, rsp_html = api_.landing_page(req_gzip_html)
assert rsp_headers['Content-Type'] == FORMAT_TYPES[F_HTML]
rsp_headers, _, _ = api_.landing_page(req_gzip_gzip)
assert rsp_headers['Content-Type'] == FORMAT_TYPES[F_JSON]
# Add gzip to server and use utf-16 encoding
config['server']['gzip'] = True
enc_16 = 'utf-16'
config['server']['encoding'] = enc_16
api_ = API(config, openapi)
# Responses from server with gzip compression
rsp_json_headers, _, rsp_gzip_json = api_.landing_page(req_gzip_json)
rsp_jsonld_headers, _, rsp_gzip_jsonld = api_.landing_page(req_gzip_jsonld)
rsp_html_headers, _, rsp_gzip_html = api_.landing_page(req_gzip_html)
rsp_gzip_headers, _, rsp_gzip_gzip = api_.landing_page(req_gzip_gzip)
# Validate compressed json response
assert rsp_json_headers['Content-Type'] == \
f'{FORMAT_TYPES[F_JSON]}; charset={enc_16}'
assert rsp_json_headers['Content-Encoding'] == F_GZIP
parsed_gzip_json = gzip.decompress(rsp_gzip_json).decode(enc_16)
assert isinstance(parsed_gzip_json, str)
parsed_gzip_json = json.loads(parsed_gzip_json)
assert isinstance(parsed_gzip_json, dict)
assert parsed_gzip_json == json.loads(rsp_json)
# Validate compressed jsonld response
assert rsp_jsonld_headers['Content-Type'] == \
f'{FORMAT_TYPES[F_JSONLD]}; charset={enc_16}'
assert rsp_jsonld_headers['Content-Encoding'] == F_GZIP
parsed_gzip_jsonld = gzip.decompress(rsp_gzip_jsonld).decode(enc_16)
assert isinstance(parsed_gzip_jsonld, str)
parsed_gzip_jsonld = json.loads(parsed_gzip_jsonld)
assert isinstance(parsed_gzip_jsonld, dict)
assert parsed_gzip_jsonld == json.loads(rsp_jsonld)
# Validate compressed html response
assert rsp_html_headers['Content-Type'] == \
f'{FORMAT_TYPES[F_HTML]}; charset={enc_16}'
assert rsp_html_headers['Content-Encoding'] == F_GZIP
parsed_gzip_html = gzip.decompress(rsp_gzip_html).decode(enc_16)
assert isinstance(parsed_gzip_html, str)
assert parsed_gzip_html == rsp_html
# Validate compressed gzip response
assert rsp_gzip_headers['Content-Type'] == \
f'{FORMAT_TYPES[F_GZIP]}; charset={enc_16}'
assert rsp_gzip_headers['Content-Encoding'] == F_GZIP
parsed_gzip_gzip = gzip.decompress(rsp_gzip_gzip).decode(enc_16)
assert isinstance(parsed_gzip_gzip, str)
parsed_gzip_gzip = json.loads(parsed_gzip_gzip)
assert isinstance(parsed_gzip_gzip, dict)
# Requests without content encoding header
req_json = mock_request(HTTP_ACCEPT=FORMAT_TYPES[F_JSON])
req_jsonld = mock_request(HTTP_ACCEPT=FORMAT_TYPES[F_JSONLD])
req_html = mock_request(HTTP_ACCEPT=FORMAT_TYPES[F_HTML])
# Responses without content encoding
_, _, rsp_json_ = api_.landing_page(req_json)
_, _, rsp_jsonld_ = api_.landing_page(req_jsonld)
_, _, rsp_html_ = api_.landing_page(req_html)
# Confirm each request is the same when decompressed
assert rsp_json_ == rsp_json == \
gzip.decompress(rsp_gzip_json).decode(enc_16)
assert rsp_jsonld_ == rsp_jsonld == \
gzip.decompress(rsp_gzip_jsonld).decode(enc_16)
assert rsp_html_ == rsp_html == \
gzip.decompress(rsp_gzip_html).decode(enc_16)
def test_root(config, api_):
req = mock_request()
rsp_headers, code, response = api_.landing_page(req)
root = json.loads(response)
assert rsp_headers['Content-Type'] == 'application/json' == \
FORMAT_TYPES[F_JSON]
assert rsp_headers['X-Powered-By'].startswith('pygeoapi')
assert rsp_headers['Content-Language'] == 'en-US'
assert isinstance(root, dict)
assert 'links' in root
assert root['links'][0]['rel'] == 'self'
assert root['links'][0]['type'] == FORMAT_TYPES[F_JSON]
assert root['links'][0]['href'].endswith('?f=json')
assert any(link['href'].endswith('f=jsonld') and link['rel'] == 'alternate'
for link in root['links'])
assert any(link['href'].endswith('f=html') and link['rel'] == 'alternate'
for link in root['links'])
assert len(root['links']) == 11
assert 'title' in root
assert root['title'] == 'pygeoapi default instance'
assert 'description' in root
assert root['description'] == 'pygeoapi provides an API to geospatial data'
req = mock_request({'f': 'html'})
rsp_headers, code, response = api_.landing_page(req)
assert rsp_headers['Content-Type'] == FORMAT_TYPES[F_HTML]
assert rsp_headers['Content-Language'] == 'en-US'
def test_root_structured_data(config, api_):
req = mock_request({"f": "jsonld"})
rsp_headers, code, response = api_.landing_page(req)
root = json.loads(response)
assert rsp_headers['Content-Type'] == 'application/ld+json' == \
FORMAT_TYPES[F_JSONLD]
assert rsp_headers['Content-Language'] == 'en-US'
assert rsp_headers['X-Powered-By'].startswith('pygeoapi')
assert isinstance(root, dict)
assert 'description' in root
assert root['description'] == 'pygeoapi provides an API to geospatial data'
assert '@context' in root
assert root['@context'] == 'https://schema.org/docs/jsonldcontext.jsonld'
expanded = jsonld.expand(root)[0]
assert '@type' in expanded
assert 'http://schema.org/DataCatalog' in expanded['@type']
assert 'http://schema.org/description' in expanded
assert root['description'] == expanded['http://schema.org/description'][0][
'@value']
assert 'http://schema.org/keywords' in expanded
assert len(expanded['http://schema.org/keywords']) == 3
assert '@value' in expanded['http://schema.org/keywords'][0].keys()
assert 'http://schema.org/provider' in expanded
assert expanded['http://schema.org/provider'][0]['@type'][
0] == 'http://schema.org/Organization'
assert expanded['http://schema.org/name'][0]['@value'] == root['name']
def test_conformance(config, api_):
req = mock_request()
rsp_headers, code, response = api_.conformance(req)
root = json.loads(response)
assert isinstance(root, dict)
assert 'conformsTo' in root
assert len(root['conformsTo']) == 37
assert 'http://www.opengis.net/spec/ogcapi-features-2/1.0/conf/crs' \
in root['conformsTo']
req = mock_request({'f': 'foo'})
rsp_headers, code, response = api_.conformance(req)
assert code == HTTPStatus.BAD_REQUEST
req = mock_request({'f': 'html'})
rsp_headers, code, response = api_.conformance(req)
assert rsp_headers['Content-Type'] == FORMAT_TYPES[F_HTML]
# No language requested: should be set to default from YAML
assert rsp_headers['Content-Language'] == 'en-US'
def test_describe_collections(config, api_):
req = mock_request({"f": "foo"})
rsp_headers, code, response = api_.describe_collections(req)
assert code == HTTPStatus.BAD_REQUEST
req = mock_request({"f": "html"})
rsp_headers, code, response = api_.describe_collections(req)
assert rsp_headers['Content-Type'] == FORMAT_TYPES[F_HTML]
req = mock_request()
rsp_headers, code, response = api_.describe_collections(req)
collections = json.loads(response)
assert len(collections) == 2
assert len(collections['collections']) == 9
assert len(collections['links']) == 3
rsp_headers, code, response = api_.describe_collections(req, 'foo')
collection = json.loads(response)
assert code == HTTPStatus.NOT_FOUND
rsp_headers, code, response = api_.describe_collections(req, 'obs')
collection = json.loads(response)
assert rsp_headers['Content-Language'] == 'en-US'
assert collection['id'] == 'obs'
assert collection['title'] == 'Observations'
assert collection['description'] == 'My cool observations'
assert len(collection['links']) == 14
assert collection['extent'] == {
'spatial': {
'bbox': [[-180, -90, 180, 90]],
'crs': 'http://www.opengis.net/def/crs/OGC/1.3/CRS84'
},
'temporal': {
'interval': [
['2000-10-30T18:24:39+00:00', '2007-10-30T08:57:29+00:00']
],
'trs': 'http://www.opengis.net/def/uom/ISO-8601/0/Gregorian'
}
}
# OAPIF Part 2 CRS 6.2.1 A, B, configured CRS + defaults
assert collection['crs'] is not None
crs_set = [
'http://www.opengis.net/def/crs/EPSG/0/28992',
'http://www.opengis.net/def/crs/OGC/1.3/CRS84',
'http://www.opengis.net/def/crs/EPSG/0/4326',
]
for crs in crs_set:
assert crs in collection['crs']
assert collection['storageCRS'] is not None
assert collection['storageCRS'] == 'http://www.opengis.net/def/crs/OGC/1.3/CRS84' # noqa
assert 'storageCrsCoordinateEpoch' not in collection
# French language request
req = mock_request({'lang': 'fr'})
rsp_headers, code, response = api_.describe_collections(req, 'obs')
collection = json.loads(response)
assert rsp_headers['Content-Language'] == 'fr-CA'
assert collection['title'] == 'Observations'
assert collection['description'] == 'Mes belles observations'
# Check HTML request in an unsupported language
req = mock_request({'f': 'html', 'lang': 'de'})
rsp_headers, code, response = api_.describe_collections(req, 'obs')
assert rsp_headers['Content-Type'] == FORMAT_TYPES[F_HTML]
assert rsp_headers['Content-Language'] == 'en-US'
# hiearchical collections
req = mock_request()
rsp_headers, code, response = api_.describe_collections(
req, 'naturalearth/lakes')
collection = json.loads(response)
assert collection['id'] == 'naturalearth/lakes'
# OAPIF Part 2 CRS 6.2.1 B, defaults when not configured
assert collection['crs'] is not None
default_crs_list = [
'http://www.opengis.net/def/crs/OGC/1.3/CRS84',
'http://www.opengis.net/def/crs/OGC/1.3/CRS84h',
]
contains_default = False
for crs in default_crs_list:
if crs in default_crs_list:
contains_default = True
assert contains_default
assert collection['storageCRS'] is not None
assert collection['storageCRS'] == 'http://www.opengis.net/def/crs/OGC/1.3/CRS84' # noqa
assert collection['storageCrsCoordinateEpoch'] == 2017.23
def test_describe_collections_hidden_resources(
config_hidden_resources, api_hidden_resources):
req = mock_request({})
rsp_headers, code, response = api_hidden_resources.describe_collections(req) # noqa
assert code == HTTPStatus.OK
assert len(config_hidden_resources['resources']) == 3
collections = json.loads(response)
assert len(collections['collections']) == 1
def test_describe_collections_json_ld(config, api_):
req = mock_request({'f': 'jsonld'})
rsp_headers, code, response = api_.describe_collections(req, 'obs')
collection = json.loads(response)
assert '@context' in collection
expanded = jsonld.expand(collection)[0]
# Metadata is about a schema:DataCollection that contains a schema:Dataset
assert not expanded['@id'].endswith('obs')
assert 'http://schema.org/dataset' in expanded
assert len(expanded['http://schema.org/dataset']) == 1
dataset = expanded['http://schema.org/dataset'][0]
assert dataset['@type'][0] == 'http://schema.org/Dataset'
assert len(dataset['http://schema.org/distribution']) == 14
assert all(dist['@type'][0] == 'http://schema.org/DataDownload'
for dist in dataset['http://schema.org/distribution'])
assert 'http://schema.org/Organization' in expanded[
'http://schema.org/provider'][0]['@type']
assert 'http://schema.org/Place' in dataset[
'http://schema.org/spatial'][0]['@type']
assert 'http://schema.org/GeoShape' in dataset[
'http://schema.org/spatial'][0]['http://schema.org/geo'][0]['@type']
assert dataset['http://schema.org/spatial'][0]['http://schema.org/geo'][
0]['http://schema.org/box'][0]['@value'] == '-180,-90 180,90'
assert 'http://schema.org/temporalCoverage' in dataset
assert dataset['http://schema.org/temporalCoverage'][0][
'@value'] == '2000-10-30T18:24:39+00:00/2007-10-30T08:57:29+00:00'
# No language requested: should be set to default from YAML
assert rsp_headers['Content-Language'] == 'en-US'
def test_describe_collections_enclosures(config_enclosure, enclosure_api):
original_enclosures = {
lnk['title']: lnk
for lnk in config_enclosure['resources']['objects']['links']
if lnk['rel'] == 'enclosure'
}
req = mock_request()
_, _, response = enclosure_api.describe_collections(req, 'objects')
features = json.loads(response)
modified_enclosures = {
lnk['title']: lnk for lnk in features['links']
if lnk['rel'] == 'enclosure'
}
# If type and length is set, do not verify/update link
assert original_enclosures['download link 1'] == \
modified_enclosures['download link 1']
# If length is missing, modify link type and length
assert original_enclosures['download link 2']['type'] == \
modified_enclosures['download link 2']['type']
assert modified_enclosures['download link 2']['type'] == \
modified_enclosures['download link 3']['type']
assert 'length' not in original_enclosures['download link 2']
assert modified_enclosures['download link 2']['length'] > 0
assert modified_enclosures['download link 2']['length'] == \
modified_enclosures['download link 3']['length']
assert original_enclosures['download link 3']['type'] != \
modified_enclosures['download link 3']['type']
def test_get_collection_schema(config, api_):
req = mock_request()
rsp_headers, code, response = api_.get_collection_schema(req, 'notfound')
assert code == HTTPStatus.NOT_FOUND
req = mock_request({'f': 'html'})
rsp_headers, code, response = api_.get_collection_schema(req, 'obs')
assert rsp_headers['Content-Type'] == FORMAT_TYPES[F_HTML]
req = mock_request({'f': 'json'})
rsp_headers, code, response = api_.get_collection_schema(req, 'obs')
assert rsp_headers['Content-Type'] == 'application/schema+json'
schema = json.loads(response)
assert 'properties' in schema
assert len(schema['properties']) == 5
def test_validate_bbox():
assert validate_bbox('1,2,3,4') == [1, 2, 3, 4]
assert validate_bbox('1,2,3,4,5,6') == [1, 2, 3, 4, 5, 6]
assert validate_bbox('-142,42,-52,84') == [-142, 42, -52, 84]
assert (validate_bbox('-142.1,42.12,-52.22,84.4') ==
[-142.1, 42.12, -52.22, 84.4])
assert (validate_bbox('-142.1,42.12,-5.28,-52.22,84.4,7.39') ==
[-142.1, 42.12, -5.28, -52.22, 84.4, 7.39])
assert (validate_bbox('177.0,65.0,-177.0,70.0') ==
[177.0, 65.0, -177.0, 70.0])
with pytest.raises(ValueError):
validate_bbox('1,2,4')
with pytest.raises(ValueError):
validate_bbox('1,2,4,5,6')
with pytest.raises(ValueError):
validate_bbox('3,4,1,2')
with pytest.raises(ValueError):
validate_bbox('1,2,6,4,5,3')
def test_validate_datetime():
config = yaml_load('''
temporal:
begin: 2000-10-30T18:24:39Z
end: 2007-10-30T08:57:29Z
''')
# test time instant
assert validate_datetime(config, '2004') == '2004'
assert validate_datetime(config, '2004-10') == '2004-10'
assert validate_datetime(config, '2001-10-30') == '2001-10-30'
with pytest.raises(ValueError):
_ = validate_datetime(config, '2009-10-30')
with pytest.raises(ValueError):
_ = validate_datetime(config, '2000-09-09')
with pytest.raises(ValueError):
_ = validate_datetime(config, '2000-10-30T17:24:39Z')
with pytest.raises(ValueError):
_ = validate_datetime(config, '2007-10-30T08:58:29Z')
# test time envelope
assert validate_datetime(config, '2004/2005') == '2004/2005'
assert validate_datetime(config, '2004-10/2005-10') == '2004-10/2005-10'
assert (validate_datetime(config, '2001-10-30/2002-10-30') ==
'2001-10-30/2002-10-30')
assert validate_datetime(config, '2004/..') == '2004/..'
assert validate_datetime(config, '../2005') == '../2005'
assert validate_datetime(config, '2004/') == '2004/..'
assert validate_datetime(config, '/2005') == '../2005'
assert validate_datetime(config, '2004-10/2005-10') == '2004-10/2005-10'
assert (validate_datetime(config, '2001-10-30/2002-10-30') ==
'2001-10-30/2002-10-30')
with pytest.raises(ValueError):
_ = validate_datetime(config, '2007-11-01/..')
with pytest.raises(ValueError):
_ = validate_datetime(config, '2009/..')
with pytest.raises(ValueError):
_ = validate_datetime(config, '../2000-09')
with pytest.raises(ValueError):
_ = validate_datetime(config, '../1999')
@pytest.mark.parametrize("value, expected", [
('time(2000-11-11)', {'time': ['2000-11-11']}),
('time("2000-11-11")', {'time': ['2000-11-11']}),
('time("2000-11-11T00:11:11")', {'time': ['2000-11-11T00:11:11']}),
('time("2000-11-11T11:12:13":"2021-12-22T:13:33:33")', {'time': ['2000-11-11T11:12:13', '2021-12-22T:13:33:33']}), # noqa
('lat(40)', {'lat': [40]}),
('lat(0:40)', {'lat': [0, 40]}),
('foo("bar")', {'foo': ['bar']}),
('foo("bar":"baz")', {'foo': ['bar', 'baz']})
])
def test_validate_subset(value, expected):
assert validate_subset(value) == expected
with pytest.raises(ValueError):
validate_subset('foo("bar)')
def test_get_exception(config, api_):
d = api_.get_exception(500, {}, 'json', 'NoApplicableCode', 'oops')
assert d[0] == {}
assert d[1] == 500
content = json.loads(d[2])
assert content['code'] == 'NoApplicableCode'
assert content['description'] == 'oops'
d = api_.get_exception(500, {}, 'html', 'NoApplicableCode', 'oops')
+177
View File
@@ -0,0 +1,177 @@
# =================================================================
#
# Authors: Tom Kralidis <tomkralidis@gmail.com>
# John A Stevenson <jostev@bgs.ac.uk>
# Colin Blackburn <colb@bgs.ac.uk>
#
# Copyright (c) 2024 Tom Kralidis
# Copyright (c) 2022 John A Stevenson and Colin Blackburn
#
# 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 json
from http import HTTPStatus
import pytest
from pygeoapi.api.coverages import get_collection_coverage
from pygeoapi.util import yaml_load
from tests.util import get_test_file_path, mock_request, mock_api_request
@pytest.fixture()
def config():
with open(get_test_file_path('pygeoapi-test-config.yml')) as fh:
return yaml_load(fh)
def test_describe_collections(config, api_):
req = mock_request()
rsp_headers, code, response = api_.describe_collections(
req, 'gdps-temperature')
collection = json.loads(response)
assert collection['id'] == 'gdps-temperature'
assert len(collection['links']) == 10
assert collection['extent']['spatial']['grid'][0]['cellsCount'] == 2400
assert collection['extent']['spatial']['grid'][0]['resolution'] == 0.15000000000000002 # noqa
assert collection['extent']['spatial']['grid'][1]['cellsCount'] == 1201
assert collection['extent']['spatial']['grid'][1]['resolution'] == 0.15
def test_get_collection_schema(config, api_):
req = mock_api_request({'f': 'html'})
rsp_headers, code, response = api_.get_collection_schema(
req, 'gdps-temperature')
assert rsp_headers['Content-Type'] == 'application/schema+json'
req = mock_api_request({'f': 'json'})
rsp_headers, code, response = api_.get_collection_schema(
req, 'gdps-temperature')
assert rsp_headers['Content-Type'] == 'application/schema+json'
schema = json.loads(response)
assert 'properties' in schema
assert len(schema['properties']) == 1
req = mock_api_request({'f': 'json'})
rsp_headers, code, response = api_.get_collection_schema(
req, 'gdps-temperature')
assert rsp_headers['Content-Type'] == 'application/schema+json'
schema = json.loads(response)
assert 'properties' in schema
assert len(schema['properties']) == 1
assert schema['properties']['1']['type'] == 'number'
assert schema['properties']['1']['title'] == 'Temperature [C]'
def test_get_collection_coverage(config, api_):
req = mock_api_request()
rsp_headers, code, response = get_collection_coverage(
api_, req, 'obs')
assert code == HTTPStatus.BAD_REQUEST
req = mock_api_request({'properties': '12'})
rsp_headers, code, response = get_collection_coverage(
api_, req, 'gdps-temperature')
assert code == HTTPStatus.BAD_REQUEST
req = mock_api_request({'subset': 'bad_axis(10:20)'})
rsp_headers, code, response = get_collection_coverage(
api_, req, 'gdps-temperature')
assert code == HTTPStatus.BAD_REQUEST
req = mock_api_request({'f': 'blah'})
rsp_headers, code, response = get_collection_coverage(
api_, req, 'gdps-temperature')
assert code == HTTPStatus.BAD_REQUEST
req = mock_api_request({'f': 'html'})
rsp_headers, code, response = get_collection_coverage(
api_, req, 'gdps-temperature')
assert code == HTTPStatus.BAD_REQUEST
assert rsp_headers['Content-Type'] == 'text/html'
req = mock_api_request(HTTP_ACCEPT='text/html')
rsp_headers, code, response = get_collection_coverage(
api_, req, 'gdps-temperature')
# NOTE: This test used to assert the code to be 200 OK,
# but it requested HTML, which is not available,
# so it should be 400 Bad Request
assert code == HTTPStatus.BAD_REQUEST
assert rsp_headers['Content-Type'] == 'text/html'
req = mock_api_request({'subset': 'Lat(5:10),Long(5:10)'})
rsp_headers, code, response = get_collection_coverage(
api_, req, 'gdps-temperature')
assert code == HTTPStatus.OK
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']
req = mock_api_request({'bbox': '-79,45,-75,49'})
rsp_headers, code, response = get_collection_coverage(
api_, req, 'gdps-temperature')
assert code == HTTPStatus.OK
content = json.loads(response)
assert content['domain']['axes']['x']['start'] == -79.0
assert content['domain']['axes']['x']['stop'] == -75.0
assert content['domain']['axes']['y']['start'] == 49.0
assert content['domain']['axes']['y']['stop'] == 45.0
req = mock_api_request({
'subset': 'Lat(5:10),Long(5:10)',
'f': 'GRIB'
})
rsp_headers, code, response = get_collection_coverage(
api_, req, 'gdps-temperature')
assert code == HTTPStatus.OK
assert isinstance(response, bytes)
req = mock_api_request(HTTP_ACCEPT='application/x-netcdf')
rsp_headers, code, response = get_collection_coverage(
api_, req, 'cmip5')
assert code == HTTPStatus.OK
assert rsp_headers['Content-Type'] == 'application/x-netcdf'
@@ -0,0 +1,226 @@
# =================================================================
#
# Authors: Tom Kralidis <tomkralidis@gmail.com>
# John A Stevenson <jostev@bgs.ac.uk>
# Colin Blackburn <colb@bgs.ac.uk>
# Bernhard Mallinger <bernhard.mallinger@eox.at>
#
# Copyright (c) 2024 Tom Kralidis
# Copyright (c) 2022 John A Stevenson and Colin Blackburn
#
# 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 json
from http import HTTPStatus
from pygeoapi.api.environmental_data_retrieval import get_collection_edr_query
from tests.util import mock_api_request
def test_get_collection_edr_query(config, api_):
# edr resource
req = mock_api_request()
rsp_headers, code, response = api_.describe_collections(req, 'icoads-sst')
collection = json.loads(response)
parameter_names = list(collection['parameter_names'].keys())
parameter_names.sort()
assert len(parameter_names) == 4
assert parameter_names == ['AIRT', 'SST', 'UWND', 'VWND']
# no coords parameter
rsp_headers, code, response = get_collection_edr_query(
api_, req, 'icoads-sst', None, 'position')
assert code == HTTPStatus.BAD_REQUEST
# bad query type
req = mock_api_request({'coords': 'POINT(11 11)'})
rsp_headers, code, response = get_collection_edr_query(
api_, req, 'icoads-sst', None, 'corridor')
assert code == HTTPStatus.BAD_REQUEST
# bad coords parameter
req = mock_api_request({'coords': 'gah'})
rsp_headers, code, response = get_collection_edr_query(
api_, req, 'icoads-sst', None, 'position')
assert code == HTTPStatus.BAD_REQUEST
# bad parameter_names parameter
req = mock_api_request({
'coords': 'POINT(11 11)', 'parameter_names': 'bad'
})
rsp_headers, code, response = get_collection_edr_query(
api_, req, 'icoads-sst', None, 'position')
assert code == HTTPStatus.BAD_REQUEST
# all parameters
req = mock_api_request({'coords': 'POINT(11 11)'})
rsp_headers, code, response = get_collection_edr_query(
api_, req, 'icoads-sst', None, 'position')
assert code == HTTPStatus.OK
data = json.loads(response)
axes = list(data['domain']['axes'].keys())
axes.sort()
assert len(axes) == 3
assert axes == ['TIME', 'x', 'y']
assert data['domain']['axes']['x']['start'] == 11.0
assert data['domain']['axes']['x']['stop'] == 11.0
assert data['domain']['axes']['y']['start'] == 11.0
assert data['domain']['axes']['y']['stop'] == 11.0
parameters = list(data['parameters'].keys())
parameters.sort()
assert len(parameters) == 4
assert parameters == ['AIRT', 'SST', 'UWND', 'VWND']
# single parameter
req = mock_api_request({
'coords': 'POINT(11 11)', 'parameter_names': 'SST'
})
rsp_headers, code, response = get_collection_edr_query(
api_, req, 'icoads-sst', None, 'position')
assert code == HTTPStatus.OK
data = json.loads(response)
assert len(data['parameters'].keys()) == 1
assert list(data['parameters'].keys())[0] == 'SST'
# Zulu time zone
req = mock_api_request({
'coords': 'POINT(11 11)',
'datetime': '2000-01-17T00:00:00Z/2000-06-16T23:00:00Z'
})
rsp_headers, code, response = get_collection_edr_query(
api_, req, 'icoads-sst', None, 'position')
assert code == HTTPStatus.OK
# bounded date range
req = mock_api_request({
'coords': 'POINT(11 11)',
'datetime': '2000-01-17/2000-06-16'
})
rsp_headers, code, response = get_collection_edr_query(
api_, req, 'icoads-sst', None, 'position')
assert code == HTTPStatus.OK
data = json.loads(response)
time_dict = data['domain']['axes']['TIME']
assert time_dict['start'] == '2000-02-15T16:29:05.999999999'
assert time_dict['stop'] == '2000-06-16T10:25:30.000000000'
assert time_dict['num'] == 5
# unbounded date range - start
req = mock_api_request({
'coords': 'POINT(11 11)',
'datetime': '../2000-06-16'
})
rsp_headers, code, response = get_collection_edr_query(
api_, req, 'icoads-sst', None, 'position')
assert code == HTTPStatus.OK
data = json.loads(response)
time_dict = data['domain']['axes']['TIME']
assert time_dict['start'] == '2000-01-16T06:00:00.000000000'
assert time_dict['stop'] == '2000-06-16T10:25:30.000000000'
assert time_dict['num'] == 6
# unbounded date range - end
req = mock_api_request({
'coords': 'POINT(11 11)',
'datetime': '2000-06-16/..'
})
rsp_headers, code, response = get_collection_edr_query(
api_, req, 'icoads-sst', None, 'position')
assert code == HTTPStatus.OK
data = json.loads(response)
time_dict = data['domain']['axes']['TIME']
assert time_dict['start'] == '2000-06-16T10:25:30.000000000'
assert time_dict['stop'] == '2000-12-16T01:20:05.999999996'
assert time_dict['num'] == 7
# some data
req = mock_api_request({
'coords': 'POINT(11 11)', 'datetime': '2000-01-16'
})
rsp_headers, code, response = get_collection_edr_query(
api_, req, 'icoads-sst', None, 'position')
assert code == HTTPStatus.OK
# no data
req = mock_api_request({
'coords': 'POINT(11 11)', 'datetime': '2000-01-17'
})
rsp_headers, code, response = get_collection_edr_query(
api_, req, 'icoads-sst', None, 'position')
assert code == HTTPStatus.NO_CONTENT
# position no coords
req = mock_api_request({
'datetime': '2000-01-17'
})
rsp_headers, code, response = get_collection_edr_query(
api_, req, 'icoads-sst', None, 'position')
assert code == HTTPStatus.BAD_REQUEST
# cube bbox parameter 4 dimensional
req = mock_api_request({
'bbox': '0,0,10,10'
})
rsp_headers, code, response = get_collection_edr_query(
api_, req, 'icoads-sst', None, 'cube')
assert code == HTTPStatus.OK
# cube bad bbox parameter
req = mock_api_request({
'bbox': '0,0,10'
})
rsp_headers, code, response = get_collection_edr_query(
api_, req, 'icoads-sst', None, 'cube')
assert code == HTTPStatus.BAD_REQUEST
# cube no bbox parameter
req = mock_api_request({})
rsp_headers, code, response = get_collection_edr_query(
api_, req, 'icoads-sst', None, 'cube')
assert code == HTTPStatus.BAD_REQUEST
# cube decreasing latitude coords and S3
req = mock_api_request({
'bbox': '-100,40,-99,45',
'parameter_names': 'tmn',
'datetime': '1994-01-01/1994-12-31',
})
rsp_headers, code, response = get_collection_edr_query(
api_, req, 'usgs-prism', None, 'cube')
assert code == HTTPStatus.OK
+659
View File
@@ -0,0 +1,659 @@
# =================================================================
#
# Authors: Tom Kralidis <tomkralidis@gmail.com>
# John A Stevenson <jostev@bgs.ac.uk>
# Colin Blackburn <colb@bgs.ac.uk>
#
# Copyright (c) 2024 Tom Kralidis
# Copyright (c) 2022 John A Stevenson and Colin Blackburn
#
# 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 copy
import gzip
import json
from http import HTTPStatus
from pyld import jsonld
import pytest
import pyproj
from shapely.geometry import Point
from pygeoapi.api import (API, FORMAT_TYPES, F_GZIP, F_HTML, F_JSONLD,
apply_gzip)
from pygeoapi.api.itemtypes import (
get_collection_queryables, get_collection_item,
get_collection_items, manage_collection_item)
from pygeoapi.util import yaml_load, get_crs_from_uri
from tests.util import get_test_file_path, mock_api_request
@pytest.fixture()
def config():
with open(get_test_file_path('pygeoapi-test-config.yml')) as fh:
return yaml_load(fh)
def test_get_collection_queryables(config, api_):
req = mock_api_request()
rsp_headers, code, response = get_collection_queryables(
api_, req, 'notfound')
assert code == HTTPStatus.NOT_FOUND
req = mock_api_request({'f': 'html'})
rsp_headers, code, response = get_collection_queryables(api_, req, 'obs')
assert rsp_headers['Content-Type'] == FORMAT_TYPES[F_HTML]
req = mock_api_request({'f': 'json'})
rsp_headers, code, response = get_collection_queryables(api_, req, 'obs')
assert rsp_headers['Content-Type'] == 'application/schema+json'
queryables = json.loads(response)
assert 'properties' in queryables
assert len(queryables['properties']) == 5
# test with provider filtered properties
api_.config['resources']['obs']['providers'][0]['properties'] = ['stn_id']
rsp_headers, code, response = get_collection_queryables(api_, req, 'obs')
queryables = json.loads(response)
assert 'properties' in queryables
assert len(queryables['properties']) == 2
assert 'geometry' in queryables['properties']
assert queryables['properties']['geometry']['$ref'] == 'https://geojson.org/schema/Geometry.json' # noqa
# No language requested: should be set to default from YAML
assert rsp_headers['Content-Language'] == 'en-US'
def test_get_collection_items(config, api_):
req = mock_api_request()
rsp_headers, code, response = get_collection_items(api_, req, 'foo')
features = json.loads(response)
assert code == HTTPStatus.NOT_FOUND
req = mock_api_request({'f': 'foo'})
rsp_headers, code, response = get_collection_items(api_, req, 'obs')
features = json.loads(response)
assert code == HTTPStatus.BAD_REQUEST
req = mock_api_request({'bbox': '1,2,3'})
rsp_headers, code, response = get_collection_items(api_, req, 'obs')
features = json.loads(response)
assert code == HTTPStatus.BAD_REQUEST
req = mock_api_request({'bbox': '1,2,3,4c'})
rsp_headers, code, response = get_collection_items(api_, req, 'obs')
assert code == HTTPStatus.BAD_REQUEST
req = mock_api_request({'bbox': '1,2,3,4', 'bbox-crs': 'bad_value'})
rsp_headers, code, response = get_collection_items(api_, req, 'obs')
assert code == HTTPStatus.BAD_REQUEST
req = mock_api_request({'bbox-crs': 'bad_value'})
rsp_headers, code, response = get_collection_items(api_, req, 'obs')
assert code == HTTPStatus.BAD_REQUEST
# bbox-crs must be in configured values for Collection
req = mock_api_request({'bbox': '1,2,3,4', 'bbox-crs': 'http://www.opengis.net/def/crs/EPSG/0/4258'}) # noqa
rsp_headers, code, response = get_collection_items(api_, req, 'obs')
assert code == HTTPStatus.BAD_REQUEST
# bbox-crs must be in configured values for Collection (CSV will ignore)
req = mock_api_request({'bbox': '52,4,53,5', 'bbox-crs': 'http://www.opengis.net/def/crs/EPSG/0/4326'}) # noqa
rsp_headers, code, response = get_collection_items(api_, req, 'obs')
assert code == HTTPStatus.OK
# bbox-crs can be a default even if not configured
req = mock_api_request({'bbox': '4,52,5,53', 'bbox-crs': 'http://www.opengis.net/def/crs/OGC/1.3/CRS84'}) # noqa
rsp_headers, code, response = get_collection_items(api_, req, 'obs')
assert code == HTTPStatus.OK
# bbox-crs can be a default even if not configured
req = mock_api_request({'bbox': '4,52,5,53'}) # noqa
rsp_headers, code, response = get_collection_items(api_, req, 'obs')
assert code == HTTPStatus.OK
req = mock_api_request({'f': 'html', 'lang': 'fr'})
rsp_headers, code, response = get_collection_items(api_, req, 'obs')
assert rsp_headers['Content-Type'] == FORMAT_TYPES[F_HTML]
assert rsp_headers['Content-Language'] == 'fr-CA'
req = mock_api_request()
rsp_headers, code, response = get_collection_items(api_, req, 'obs')
features = json.loads(response)
# No language requested: should be set to default from YAML
assert rsp_headers['Content-Language'] == 'en-US'
assert len(features['features']) == 5
req = mock_api_request({'resulttype': 'hits'})
rsp_headers, code, response = get_collection_items(api_, req, 'obs')
features = json.loads(response)
assert len(features['features']) == 0
# Invalid limit
req = mock_api_request({'limit': 0})
rsp_headers, code, response = get_collection_items(api_, req, 'obs')
features = json.loads(response)
assert code == HTTPStatus.BAD_REQUEST
req = mock_api_request({'stn_id': '35'})
rsp_headers, code, response = get_collection_items(api_, req, 'obs')
features = json.loads(response)
assert len(features['features']) == 2
assert features['numberMatched'] == 2
req = mock_api_request({'stn_id': '35', 'value': '93.9'})
rsp_headers, code, response = get_collection_items(api_, req, 'obs')
features = json.loads(response)
assert len(features['features']) == 1
assert features['numberMatched'] == 1
req = mock_api_request({'limit': 2})
rsp_headers, code, response = get_collection_items(api_, req, 'obs')
features = json.loads(response)
assert len(features['features']) == 2
assert features['features'][1]['properties']['stn_id'] == 35
links = features['links']
assert len(links) == 4
assert '/collections/obs/items?f=json' in links[0]['href']
assert links[0]['rel'] == 'self'
assert '/collections/obs/items?f=jsonld' in links[1]['href']
assert links[1]['rel'] == 'alternate'
assert '/collections/obs/items?f=html' in links[2]['href']
assert links[2]['rel'] == 'alternate'
assert '/collections/obs' in links[3]['href']
assert links[3]['rel'] == 'collection'
# Invalid offset
req = mock_api_request({'offset': -1})
rsp_headers, code, response = get_collection_items(api_, req, 'obs')
features = json.loads(response)
assert code == HTTPStatus.BAD_REQUEST
req = mock_api_request({'offset': 2})
rsp_headers, code, response = get_collection_items(api_, req, 'obs')
features = json.loads(response)
assert len(features['features']) == 3
assert features['features'][1]['properties']['stn_id'] == 2147
links = features['links']
assert len(links) == 5
assert '/collections/obs/items?f=json' in links[0]['href']
assert links[0]['rel'] == 'self'
assert '/collections/obs/items?f=jsonld' in links[1]['href']
assert links[1]['rel'] == 'alternate'
assert '/collections/obs/items?f=html' in links[2]['href']
assert links[2]['rel'] == 'alternate'
assert '/collections/obs/items?offset=0' in links[3]['href']
assert links[3]['rel'] == 'prev'
assert '/collections/obs' in links[4]['href']
assert links[4]['rel'] == 'collection'
req = mock_api_request({
'offset': 1,
'limit': 1,
'bbox': '-180,90,180,90'
})
rsp_headers, code, response = get_collection_items(api_, req, 'obs')
features = json.loads(response)
assert len(features['features']) == 1
links = features['links']
assert len(links) == 5
assert '/collections/obs/items?f=json&limit=1&bbox=-180,90,180,90' in \
links[0]['href']
assert links[0]['rel'] == 'self'
assert '/collections/obs/items?f=jsonld&limit=1&bbox=-180,90,180,90' in \
links[1]['href']
assert links[1]['rel'] == 'alternate'
assert '/collections/obs/items?f=html&limit=1&bbox=-180,90,180,90' in \
links[2]['href']
assert links[2]['rel'] == 'alternate'
assert '/collections/obs/items?offset=0&limit=1&bbox=-180,90,180,90' \
in links[3]['href']
assert links[3]['rel'] == 'prev'
assert '/collections/obs' in links[4]['href']
assert links[4]['rel'] == 'collection'
req = mock_api_request({
'sortby': 'bad-property',
'stn_id': '35'
})
rsp_headers, code, response = get_collection_items(api_, req, 'obs')
assert code == HTTPStatus.BAD_REQUEST
req = mock_api_request({'sortby': 'stn_id'})
rsp_headers, code, response = get_collection_items(api_, req, 'obs')
features = json.loads(response)
assert code == HTTPStatus.OK
req = mock_api_request({'sortby': '+stn_id'})
rsp_headers, code, response = get_collection_items(api_, req, 'obs')
features = json.loads(response)
assert code == HTTPStatus.OK
req = mock_api_request({'sortby': '-stn_id'})
rsp_headers, code, response = get_collection_items(api_, req, 'obs')
features = json.loads(response)
assert code == HTTPStatus.OK
req = mock_api_request({'f': 'csv'})
rsp_headers, code, response = get_collection_items(api_, req, 'obs')
assert rsp_headers['Content-Type'] == 'text/csv; charset=utf-8'
req = mock_api_request({'datetime': '2003'})
rsp_headers, code, response = get_collection_items(api_, req, 'obs')
assert code == HTTPStatus.OK
req = mock_api_request({'datetime': '1999'})
rsp_headers, code, response = get_collection_items(api_, req, 'obs')
assert code == HTTPStatus.BAD_REQUEST
req = mock_api_request({'datetime': '2010-04-22'})
rsp_headers, code, response = get_collection_items(api_, req, 'obs')
assert code == HTTPStatus.BAD_REQUEST
req = mock_api_request({'datetime': '2001-11-11/2003-12-18'})
rsp_headers, code, response = get_collection_items(api_, req, 'obs')
assert code == HTTPStatus.OK
req = mock_api_request({'datetime': '../2003-12-18'})
rsp_headers, code, response = get_collection_items(api_, req, 'obs')
assert code == HTTPStatus.OK
req = mock_api_request({'datetime': '2001-11-11/..'})
rsp_headers, code, response = get_collection_items(api_, req, 'obs')
assert code == HTTPStatus.OK
req = mock_api_request({'datetime': '1999/2005-04-22'})
rsp_headers, code, response = get_collection_items(api_, req, 'obs')
assert code == HTTPStatus.OK
req = mock_api_request({'datetime': '1999/2000-04-22'})
rsp_headers, code, response = get_collection_items(api_, req, 'obs')
assert code == HTTPStatus.BAD_REQUEST
api_.config['resources']['obs']['extents'].pop('temporal')
req = mock_api_request({'datetime': '2002/2014-04-22'})
rsp_headers, code, response = get_collection_items(api_, req, 'obs')
assert code == HTTPStatus.OK
req = mock_api_request({'scalerank': 1})
rsp_headers, code, response = get_collection_items(
api_, req, 'naturalearth/lakes')
features = json.loads(response)
assert len(features['features']) == 10
assert features['numberMatched'] == 11
assert features['numberReturned'] == 10
req = mock_api_request({'datetime': '2005-04-22'})
rsp_headers, code, response = get_collection_items(
api_, req, 'naturalearth/lakes')
assert code == HTTPStatus.BAD_REQUEST
req = mock_api_request({'skipGeometry': 'true'})
rsp_headers, code, response = get_collection_items(api_, req, 'obs')
assert json.loads(response)['features'][0]['geometry'] is None
req = mock_api_request({'properties': 'foo,bar'})
rsp_headers, code, response = get_collection_items(api_, req, 'obs')
assert code == HTTPStatus.BAD_REQUEST
def test_collection_items_gzip_csv(config, api_, openapi):
# Add gzip to server
config['server']['gzip'] = True
api_ = API(config, openapi)
req_csv = mock_api_request({'f': 'csv'})
rsp_csv_headers, _, rsp_csv = get_collection_items(api_, req_csv, 'obs')
rsp_csv = apply_gzip(rsp_csv_headers, rsp_csv)
assert rsp_csv_headers['Content-Type'] == 'text/csv; charset=utf-8'
rsp_csv = rsp_csv.decode('utf-8')
req_csv = mock_api_request({'f': 'csv'}, HTTP_ACCEPT_ENCODING=F_GZIP)
rsp_csv_headers, _, rsp_csv_gzip = get_collection_items(api_, req_csv, 'obs') # noqa
rsp_csv_gzip = apply_gzip(rsp_csv_headers, rsp_csv_gzip)
assert rsp_csv_headers['Content-Type'] == 'text/csv; charset=utf-8'
rsp_csv_ = gzip.decompress(rsp_csv_gzip).decode('utf-8')
assert rsp_csv == rsp_csv_
# Use utf-16 encoding
config['server']['encoding'] = 'utf-16'
api_ = API(config, openapi)
req_csv = mock_api_request({'f': 'csv'}, HTTP_ACCEPT_ENCODING=F_GZIP)
rsp_csv_headers, _, rsp_csv_gzip = get_collection_items(api_, req_csv, 'obs') # noqa
rsp_csv_gzip = apply_gzip(rsp_csv_headers, rsp_csv_gzip)
assert rsp_csv_headers['Content-Type'] == 'text/csv; charset=utf-8'
rsp_csv_ = gzip.decompress(rsp_csv_gzip).decode('utf-8')
assert rsp_csv == rsp_csv_
def test_get_collection_items_crs(config, api_):
# Invalid CRS query parameter
req = mock_api_request({'crs': '4326'})
rsp_headers, code, response = get_collection_items(api_, req, 'norway_pop')
assert code == HTTPStatus.BAD_REQUEST
# Unsupported CRS
req = mock_api_request(
{'crs': 'http://www.opengis.net/def/crs/EPSG/0/32633'})
rsp_headers, code, response = get_collection_items(api_, req, 'norway_pop')
assert code == HTTPStatus.BAD_REQUEST
# Supported CRSs
default_crs = 'http://www.opengis.net/def/crs/OGC/1.3/CRS84'
storage_crs = 'http://www.opengis.net/def/crs/EPSG/0/25833'
crs_4258 = 'http://www.opengis.net/def/crs/EPSG/0/4258'
supported_crs_list = [default_crs, storage_crs, crs_4258]
for crs in supported_crs_list:
req = mock_api_request({'crs': crs})
rsp_headers, code, response = get_collection_items(
api_, req, 'norway_pop')
assert code == HTTPStatus.OK
assert rsp_headers['Content-Crs'] == f'<{crs}>'
# With CRS query parameter, using storageCRS
req = mock_api_request({'crs': storage_crs})
rsp_headers, code, response = get_collection_items(
api_, req, 'norway_pop')
assert code == HTTPStatus.OK
assert rsp_headers['Content-Crs'] == f'<{storage_crs}>'
features_25833 = json.loads(response)
# With CRS query parameter resulting in coordinates transformation
req = mock_api_request({'crs': crs_4258})
rsp_headers, code, response = get_collection_items(
api_, req, 'norway_pop')
assert code == HTTPStatus.OK
assert rsp_headers['Content-Crs'] == f'<{crs_4258}>'
features_4258 = json.loads(response)
transform_func = pyproj.Transformer.from_crs(
pyproj.CRS.from_epsg(25833),
pyproj.CRS.from_epsg(4258),
always_xy=False,
).transform
for feat_orig in features_25833['features']:
id_ = feat_orig['id']
x, y, *_ = feat_orig['geometry']['coordinates']
loc_transf = Point(transform_func(x, y))
for feat_out in features_4258['features']:
if id_ == feat_out['id']:
loc_out = Point(feat_out['geometry']['coordinates'][:2])
assert loc_out.equals_exact(loc_transf, 1e-5)
break
# Without CRS query parameter: assume Transform to default WGS84 lon,lat
req = mock_api_request({})
rsp_headers, code, response = get_collection_items(
api_, req, 'norway_pop')
assert code == HTTPStatus.OK
assert rsp_headers['Content-Crs'] == f'<{default_crs}>'
features_wgs84 = json.loads(response)
# With CRS query parameter resulting in coordinates transformation
transform_func = pyproj.Transformer.from_crs(
pyproj.CRS.from_epsg(4258),
get_crs_from_uri(default_crs),
always_xy=False,
).transform
for feat_orig in features_4258['features']:
id_ = feat_orig['id']
x, y, *_ = feat_orig['geometry']['coordinates']
loc_transf = Point(transform_func(x, y))
for feat_out in features_wgs84['features']:
if id_ == feat_out['id']:
loc_out = Point(feat_out['geometry']['coordinates'][:2])
assert loc_out.equals_exact(loc_transf, 1e-5)
break
def test_manage_collection_item_read_only_options_req(config, api_):
"""Test OPTIONS request on a read-only items endpoint"""
req = mock_api_request()
_, code, _ = manage_collection_item(api_, req, 'options', 'foo')
assert code == HTTPStatus.NOT_FOUND
req = mock_api_request()
rsp_headers, code, _ = manage_collection_item(api_, req, 'options', 'obs')
assert code == HTTPStatus.OK
assert rsp_headers['Allow'] == 'HEAD, GET'
req = mock_api_request()
rsp_headers, code, _ = manage_collection_item(
api_, req, 'options', 'obs', 'ressource_id')
assert code == HTTPStatus.OK
assert rsp_headers['Allow'] == 'HEAD, GET'
def test_manage_collection_item_editable_options_req(config, openapi):
"""Test OPTIONS request on a editable items endpoint"""
config = copy.deepcopy(config)
config['resources']['obs']['providers'][0]['editable'] = True
api_ = API(config, openapi)
req = mock_api_request()
rsp_headers, code, _ = manage_collection_item(api_, req, 'options', 'obs')
assert code == HTTPStatus.OK
assert rsp_headers['Allow'] == 'HEAD, GET, POST'
req = mock_api_request()
rsp_headers, code, _ = manage_collection_item(
api_, req, 'options', 'obs', 'ressource_id')
assert code == HTTPStatus.OK
assert rsp_headers['Allow'] == 'HEAD, GET, PUT, DELETE'
def test_get_collection_items_json_ld(config, api_):
req = mock_api_request({
'f': 'jsonld',
'limit': 2
})
rsp_headers, code, response = get_collection_items(api_, req, 'obs')
assert rsp_headers['Content-Type'] == FORMAT_TYPES[F_JSONLD]
# No language requested: return default from YAML
assert rsp_headers['Content-Language'] == 'en-US'
collection = json.loads(response)
assert '@context' in collection
assert all((f in collection['@context'][0] for
f in ('schema', 'type', 'features', 'FeatureCollection')))
assert len(collection['@context']) > 1
assert collection['@context'][1]['schema'] == 'https://schema.org/'
expanded = jsonld.expand(collection)[0]
featuresUri = 'https://schema.org/itemListElement'
assert len(expanded[featuresUri]) == 2
def test_get_collection_item(config, api_):
req = mock_api_request({'f': 'json'})
rsp_headers, code, response = get_collection_item(
api_, req, 'gdps-temperature', '371')
assert code == HTTPStatus.BAD_REQUEST
req = mock_api_request()
rsp_headers, code, response = get_collection_item(api_, req, 'foo', '371')
assert code == HTTPStatus.NOT_FOUND
rsp_headers, code, response = get_collection_item(
api_, req, 'obs', 'notfound')
assert code == HTTPStatus.NOT_FOUND
req = mock_api_request({'f': 'html'})
rsp_headers, code, response = get_collection_item(api_, req, 'obs', '371')
assert rsp_headers['Content-Type'] == FORMAT_TYPES[F_HTML]
assert rsp_headers['Content-Language'] == 'en-US'
req = mock_api_request()
rsp_headers, code, response = get_collection_item(api_, req, 'obs', '371')
feature = json.loads(response)
assert feature['properties']['stn_id'] == 35
assert 'prev' not in feature['links']
assert 'next' not in feature['links']
def test_get_collection_item_json_ld(config, api_):
req = mock_api_request({'f': 'jsonld'})
rsp_headers, _, response = get_collection_item(api_, req, 'objects', '3')
assert rsp_headers['Content-Type'] == FORMAT_TYPES[F_JSONLD]
assert rsp_headers['Content-Language'] == 'en-US'
feature = json.loads(response)
assert '@context' in feature
assert all((f in feature['@context'][0] for
f in ('schema', 'type', 'gsp')))
assert len(feature['@context']) == 1
assert 'schema' in feature['@context'][0]
assert feature['@context'][0]['schema'] == 'https://schema.org/'
assert feature['id'] == 3
expanded = jsonld.expand(feature)[0]
assert expanded['@id'].startswith('http://')
assert expanded['@id'].endswith('/collections/objects/items/3')
assert expanded['http://www.opengis.net/ont/geosparql#hasGeometry'][0][
'http://www.opengis.net/ont/geosparql#asWKT'][0][
'@value'] == 'POINT (-85 33)'
assert expanded['https://schema.org/geo'][0][
'https://schema.org/latitude'][0][
'@value'] == 33
assert expanded['https://schema.org/geo'][0][
'https://schema.org/longitude'][0][
'@value'] == -85
_, _, response = get_collection_item(api_, req, 'objects', '2')
feature = json.loads(response)
assert feature['geometry']['type'] == 'MultiPoint'
expanded = jsonld.expand(feature)[0]
assert expanded['http://www.opengis.net/ont/geosparql#hasGeometry'][0][
'http://www.opengis.net/ont/geosparql#asWKT'][0][
'@value'] == 'MULTIPOINT (10 40, 40 30, 20 20, 30 10)'
assert expanded['https://schema.org/geo'][0][
'https://schema.org/polygon'][0][
'@value'] == "10.0,40.0 40.0,30.0 20.0,20.0 30.0,10.0 10.0,40.0"
_, _, response = get_collection_item(api_, req, 'objects', '1')
feature = json.loads(response)
expanded = jsonld.expand(feature)[0]
assert expanded['http://www.opengis.net/ont/geosparql#hasGeometry'][0][
'http://www.opengis.net/ont/geosparql#asWKT'][0][
'@value'] == 'LINESTRING (30 10, 10 30, 40 40)'
assert expanded['https://schema.org/geo'][0][
'https://schema.org/line'][0][
'@value'] == '30.0,10.0 10.0,30.0 40.0,40.0'
_, _, response = get_collection_item(api_, req, 'objects', '4')
feature = json.loads(response)
expanded = jsonld.expand(feature)[0]
assert expanded['http://www.opengis.net/ont/geosparql#hasGeometry'][0][
'http://www.opengis.net/ont/geosparql#asWKT'][0][
'@value'] == 'MULTILINESTRING ((10 10, 20 20, 10 40), ' \
'(40 40, 30 30, 40 20, 30 10))'
assert expanded['https://schema.org/geo'][0][
'https://schema.org/line'][0][
'@value'] == '10.0,10.0 20.0,20.0 10.0,40.0 40.0,40.0 ' \
'30.0,30.0 40.0,20.0 30.0,10.0'
_, _, response = get_collection_item(api_, req, 'objects', '5')
feature = json.loads(response)
expanded = jsonld.expand(feature)[0]
assert expanded['http://www.opengis.net/ont/geosparql#hasGeometry'][0][
'http://www.opengis.net/ont/geosparql#asWKT'][0][
'@value'] == 'POLYGON ((30 10, 40 40, 20 40, 10 20, 30 10))'
assert expanded['https://schema.org/geo'][0][
'https://schema.org/polygon'][0][
'@value'] == '30.0,10.0 40.0,40.0 20.0,40.0 10.0,20.0 30.0,10.0'
_, _, response = get_collection_item(api_, req, 'objects', '7')
feature = json.loads(response)
expanded = jsonld.expand(feature)[0]
assert expanded['http://www.opengis.net/ont/geosparql#hasGeometry'][0][
'http://www.opengis.net/ont/geosparql#asWKT'][0][
'@value'] == 'MULTIPOLYGON (((30 20, 45 40, 10 40, 30 20)), '\
'((15 5, 40 10, 10 20, 5 10, 15 5)))'
assert expanded['https://schema.org/geo'][0][
'https://schema.org/polygon'][0][
'@value'] == '15.0,5.0 5.0,10.0 10.0,40.0 '\
'45.0,40.0 40.0,10.0 15.0,5.0'
req = mock_api_request({'f': 'jsonld', 'lang': 'fr'})
rsp_headers, code, response = get_collection_item(api_, req, 'obs', '371')
assert rsp_headers['Content-Type'] == FORMAT_TYPES[F_JSONLD]
assert rsp_headers['Content-Language'] == 'fr-CA'
+52
View File
@@ -0,0 +1,52 @@
# =================================================================
#
# Authors: Tom Kralidis <tomkralidis@gmail.com>
# John A Stevenson <jostev@bgs.ac.uk>
# Colin Blackburn <colb@bgs.ac.uk>
# Bernhard Mallinger <bernhard.mallinger@eox.at>
#
# Copyright (c) 2024 Tom Kralidis
# Copyright (c) 2022 John A Stevenson and Colin Blackburn
#
# 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 http import HTTPStatus
from pygeoapi.api.maps import get_collection_map
from tests.util import mock_api_request
def test_get_collection_map(config, api_):
req = mock_api_request()
rsp_headers, code, response = get_collection_map(api_, req, 'notfound')
assert code == HTTPStatus.NOT_FOUND
req = mock_api_request()
rsp_headers, code, response = get_collection_map(
api_, req, 'mapserver_world_map')
assert code == HTTPStatus.OK
assert isinstance(response, bytes)
assert response[1:4] == b'PNG'
+428
View File
@@ -0,0 +1,428 @@
# =================================================================
#
# Authors: Tom Kralidis <tomkralidis@gmail.com>
# John A Stevenson <jostev@bgs.ac.uk>
# Colin Blackburn <colb@bgs.ac.uk>
# Bernhard Mallinger <bernhard.mallinger@eox.at>
#
# Copyright (c) 2024 Tom Kralidis
# Copyright (c) 2022 John A Stevenson and Colin Blackburn
#
# 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 json
from http import HTTPStatus
import time
from unittest import mock
from pygeoapi.api import FORMAT_TYPES, F_HTML, F_JSON
from pygeoapi.api.processes import (
describe_processes, execute_process, delete_job, get_job_result,
)
from tests.util import mock_api_request
def test_describe_processes(config, api_):
req = mock_api_request({'limit': 1})
# Test for description of single processes
rsp_headers, code, response = describe_processes(api_, req)
data = json.loads(response)
assert code == HTTPStatus.OK
assert len(data['processes']) == 1
assert len(data['links']) == 3
req = mock_api_request()
# Test for undefined process
rsp_headers, code, response = describe_processes(api_, req, 'foo')
data = json.loads(response)
assert code == HTTPStatus.NOT_FOUND
assert data['code'] == 'NoSuchProcess'
# Test for description of all processes
rsp_headers, code, response = describe_processes(api_, req)
data = json.loads(response)
assert code == HTTPStatus.OK
assert len(data['processes']) == 2
assert len(data['links']) == 3
# Test for particular, defined process
rsp_headers, code, response = describe_processes(api_, req, 'hello-world')
process = json.loads(response)
assert code == HTTPStatus.OK
assert rsp_headers['Content-Type'] == FORMAT_TYPES[F_JSON]
assert process['id'] == 'hello-world'
assert process['version'] == '0.2.0'
assert process['title'] == 'Hello World'
assert len(process['keywords']) == 3
assert len(process['links']) == 6
assert len(process['inputs']) == 2
assert len(process['outputs']) == 1
assert len(process['outputTransmission']) == 1
assert len(process['jobControlOptions']) == 2
assert 'sync-execute' in process['jobControlOptions']
assert 'async-execute' in process['jobControlOptions']
# Check HTML response when requested in headers
req = mock_api_request(HTTP_ACCEPT='text/html')
rsp_headers, code, response = describe_processes(api_, req, 'hello-world')
assert code == HTTPStatus.OK
assert rsp_headers['Content-Type'] == FORMAT_TYPES[F_HTML]
# No language requested: return default from YAML
assert rsp_headers['Content-Language'] == 'en-US'
# Check JSON response when requested in headers
req = mock_api_request(HTTP_ACCEPT='application/json')
rsp_headers, code, response = describe_processes(api_, req, 'hello-world')
assert code == HTTPStatus.OK
assert rsp_headers['Content-Type'] == FORMAT_TYPES[F_JSON]
assert rsp_headers['Content-Language'] == 'en-US'
# Check HTML response when requested with query parameter
req = mock_api_request({'f': 'html'})
rsp_headers, code, response = describe_processes(api_, req, 'hello-world')
assert code == HTTPStatus.OK
assert rsp_headers['Content-Type'] == FORMAT_TYPES[F_HTML]
# No language requested: return default from YAML
assert rsp_headers['Content-Language'] == 'en-US'
# Check JSON response when requested with query parameter
req = mock_api_request({'f': 'json'})
rsp_headers, code, response = describe_processes(api_, req, 'hello-world')
assert code == HTTPStatus.OK
assert rsp_headers['Content-Type'] == FORMAT_TYPES[F_JSON]
assert rsp_headers['Content-Language'] == 'en-US'
# Check JSON response when requested with French language parameter
req = mock_api_request({'lang': 'fr'})
rsp_headers, code, response = describe_processes(api_, req, 'hello-world')
assert code == HTTPStatus.OK
assert rsp_headers['Content-Type'] == FORMAT_TYPES[F_JSON]
assert rsp_headers['Content-Language'] == 'fr-CA'
process = json.loads(response)
assert process['title'] == 'Bonjour le Monde'
# Check JSON response when language requested in headers
req = mock_api_request(HTTP_ACCEPT_LANGUAGE='fr')
rsp_headers, code, response = describe_processes(api_, req, 'hello-world')
assert code == HTTPStatus.OK
assert rsp_headers['Content-Type'] == FORMAT_TYPES[F_JSON]
assert rsp_headers['Content-Language'] == 'fr-CA'
# Test for undefined process
req = mock_api_request()
rsp_headers, code, response = describe_processes(api_, req,
'goodbye-world')
data = json.loads(response)
assert code == HTTPStatus.NOT_FOUND
assert data['code'] == 'NoSuchProcess'
assert rsp_headers['Content-Type'] == FORMAT_TYPES[F_JSON]
# Test describe doesn't crash if example is missing
req = mock_api_request()
processor = api_.manager.get_processor("hello-world")
example = processor.metadata.pop("example")
rsp_headers, code, response = describe_processes(api_, req)
processor.metadata['example'] = example
data = json.loads(response)
assert code == HTTPStatus.OK
assert len(data['processes']) == 2
def test_execute_process(config, api_):
req_body_0 = {
'inputs': {
'name': 'Test'
}
}
req_body_1 = {
'inputs': {
'name': 'Test'
},
'response': 'document'
}
req_body_2 = {
'inputs': {
'name': 'Tést'
}
}
req_body_3 = {
'inputs': {
'name': 'Tést',
'message': 'This is a test.'
}
}
req_body_4 = {
'inputs': {
'foo': 'Tést'
}
}
req_body_5 = {
'inputs': {}
}
req_body_6 = {
'inputs': {
'name': None
}
}
req_body_7 = {
'inputs': {
'name': 'Test'
},
'subscriber': {
'successUri': 'https://example.com/success',
'inProgressUri': 'https://example.com/inProgress',
'failedUri': 'https://example.com/failed',
}
}
cleanup_jobs = set()
# Test posting empty payload to existing process
req = mock_api_request(data='')
rsp_headers, code, response = execute_process(api_, req, 'hello-world')
assert rsp_headers['Content-Language'] == 'en-US'
data = json.loads(response)
assert code == HTTPStatus.BAD_REQUEST
assert 'Location' not in rsp_headers
assert data['code'] == 'MissingParameterValue'
req = mock_api_request(data=req_body_0)
rsp_headers, code, response = execute_process(api_, req, 'foo')
data = json.loads(response)
assert code == HTTPStatus.NOT_FOUND
assert 'Location' not in rsp_headers
assert data['code'] == 'NoSuchProcess'
rsp_headers, code, response = execute_process(api_, req, 'hello-world')
data = json.loads(response)
assert code == HTTPStatus.OK
assert 'Location' in rsp_headers
assert len(data.keys()) == 2
assert data['id'] == 'echo'
assert data['value'] == 'Hello Test!'
cleanup_jobs.add(tuple(['hello-world',
rsp_headers['Location'].split('/')[-1]]))
req = mock_api_request(data=req_body_1)
rsp_headers, code, response = execute_process(api_, req, 'hello-world')
data = json.loads(response)
assert code == HTTPStatus.OK
assert 'Location' in rsp_headers
assert len(data.keys()) == 1
assert data['outputs'][0]['id'] == 'echo'
assert data['outputs'][0]['value'] == 'Hello Test!'
cleanup_jobs.add(tuple(['hello-world',
rsp_headers['Location'].split('/')[-1]]))
req = mock_api_request(data=req_body_2)
rsp_headers, code, response = execute_process(api_, req, 'hello-world')
data = json.loads(response)
assert code == HTTPStatus.OK
assert 'Location' in rsp_headers
assert data['value'] == 'Hello Tést!'
cleanup_jobs.add(tuple(['hello-world',
rsp_headers['Location'].split('/')[-1]]))
req = mock_api_request(data=req_body_3)
rsp_headers, code, response = execute_process(api_, req, 'hello-world')
data = json.loads(response)
assert code == HTTPStatus.OK
assert 'Location' in rsp_headers
assert data['value'] == 'Hello Tést! This is a test.'
cleanup_jobs.add(tuple(['hello-world',
rsp_headers['Location'].split('/')[-1]]))
req = mock_api_request(data=req_body_4)
rsp_headers, code, response = execute_process(api_, req, 'hello-world')
data = json.loads(response)
assert code == HTTPStatus.BAD_REQUEST
assert 'Location' in rsp_headers
assert data['code'] == 'InvalidParameterValue'
cleanup_jobs.add(tuple(['hello-world',
rsp_headers['Location'].split('/')[-1]]))
req = mock_api_request(data=req_body_5)
rsp_headers, code, response = execute_process(api_, req, 'hello-world')
data = json.loads(response)
assert code == HTTPStatus.BAD_REQUEST
assert 'Location' in rsp_headers
assert data['code'] == 'InvalidParameterValue'
assert data['description'] == 'Error updating job'
cleanup_jobs.add(tuple(['hello-world',
rsp_headers['Location'].split('/')[-1]]))
req = mock_api_request(data=req_body_6)
rsp_headers, code, response = execute_process(api_, req, 'hello-world')
data = json.loads(response)
assert code == HTTPStatus.BAD_REQUEST
assert 'Location' in rsp_headers
assert data['code'] == 'InvalidParameterValue'
assert data['description'] == 'Error updating job'
cleanup_jobs.add(tuple(['hello-world',
rsp_headers['Location'].split('/')[-1]]))
req = mock_api_request(data=req_body_0)
rsp_headers, code, response = execute_process(api_, req, 'goodbye-world')
response = json.loads(response)
assert code == HTTPStatus.NOT_FOUND
assert 'Location' not in rsp_headers
assert response['code'] == 'NoSuchProcess'
rsp_headers, code, response = execute_process(api_, req, 'hello-world')
response = json.loads(response)
assert code == HTTPStatus.OK
cleanup_jobs.add(tuple(['hello-world',
rsp_headers['Location'].split('/')[-1]]))
req = mock_api_request(data=req_body_1, HTTP_Prefer='respond-async')
rsp_headers, code, response = execute_process(api_, req, 'hello-world')
assert 'Location' in rsp_headers
response = json.loads(response)
assert isinstance(response, dict)
assert code == HTTPStatus.CREATED
cleanup_jobs.add(tuple(['hello-world',
rsp_headers['Location'].split('/')[-1]]))
req = mock_api_request(data=req_body_7)
with mock.patch(
'pygeoapi.process.manager.base.requests.post'
) as post_mocker:
rsp_headers, code, response = execute_process(api_, req, 'hello-world')
assert code == HTTPStatus.OK
post_mocker.assert_any_call(
req_body_7['subscriber']['inProgressUri'], json={}
)
post_mocker.assert_any_call(
req_body_7['subscriber']['successUri'],
json={'id': 'echo', 'value': 'Hello Test!'}
)
assert post_mocker.call_count == 2
cleanup_jobs.add(tuple(['hello-world',
rsp_headers['Location'].split('/')[-1]]))
# Cleanup
time.sleep(2) # Allow time for any outstanding async jobs
for _, job_id in cleanup_jobs:
rsp_headers, code, response = delete_job(api_, mock_api_request(),
job_id)
assert code == HTTPStatus.OK
def _execute_a_job(api_):
req_body_sync = {
'inputs': {
'name': 'Sync Test'
}
}
req = mock_api_request(data=req_body_sync)
rsp_headers, code, response = execute_process(api_, req, 'hello-world')
data = json.loads(response)
assert code == HTTPStatus.OK
assert 'Location' in rsp_headers
assert data['value'] == 'Hello Sync Test!'
job_id = rsp_headers['Location'].split('/')[-1]
return job_id
def test_delete_job(api_):
rsp_headers, code, response = delete_job(api_, mock_api_request(),
'does-not-exist')
assert code == HTTPStatus.NOT_FOUND
req_body_async = {
'inputs': {
'name': 'Async Test Deletion'
}
}
job_id = _execute_a_job(api_)
rsp_headers, code, response = delete_job(api_, mock_api_request(), job_id)
assert code == HTTPStatus.OK
rsp_headers, code, response = delete_job(api_, mock_api_request(), job_id)
assert code == HTTPStatus.NOT_FOUND
req = mock_api_request(data=req_body_async, HTTP_Prefer='respond-async')
rsp_headers, code, response = execute_process(api_, req, 'hello-world')
assert code == HTTPStatus.CREATED
assert 'Location' in rsp_headers
time.sleep(2) # Allow time for async execution to complete
job_id = rsp_headers['Location'].split('/')[-1]
rsp_headers, code, response = delete_job(api_, mock_api_request(), job_id)
assert code == HTTPStatus.OK
rsp_headers, code, response = delete_job(api_, mock_api_request(), job_id)
assert code == HTTPStatus.NOT_FOUND
def test_get_job_result(api_):
rsp_headers, code, response = get_job_result(
api_, mock_api_request(), 'not-exist',
)
assert code == HTTPStatus.NOT_FOUND
job_id = _execute_a_job(api_)
rsp_headers, code, response = get_job_result(api_, mock_api_request(),
job_id)
# default response is html
assert code == HTTPStatus.OK
assert rsp_headers['Content-Type'] == 'text/html'
assert 'Hello Sync Test!' in response
rsp_headers, code, response = get_job_result(
api_, mock_api_request({'f': 'json'}), job_id,
)
assert code == HTTPStatus.OK
assert rsp_headers['Content-Type'] == 'application/json'
assert json.loads(response)['value'] == "Hello Sync Test!"
+110
View File
@@ -0,0 +1,110 @@
# =================================================================
#
# Authors: Tom Kralidis <tomkralidis@gmail.com>
# John A Stevenson <jostev@bgs.ac.uk>
# Colin Blackburn <colb@bgs.ac.uk>
# Bernhard Mallinger <bernhard.mallinger@eox.at>
#
# Copyright (c) 2024 Tom Kralidis
# Copyright (c) 2022 John A Stevenson and Colin Blackburn
#
# 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 json
from http import HTTPStatus
from pygeoapi.api import FORMAT_TYPES, F_HTML
from pygeoapi.api.tiles import (
get_collection_tiles, tilematrixset, tilematrixsets,
)
from pygeoapi.models.provider.base import TileMatrixSetEnum
from tests.util import mock_api_request
def test_get_collection_tiles(config, api_):
req = mock_api_request()
rsp_headers, code, response = get_collection_tiles(api_, req, 'obs')
assert code == HTTPStatus.BAD_REQUEST
rsp_headers, code, response = get_collection_tiles(
api_, req, 'naturalearth/lakes')
assert code == HTTPStatus.OK
# Language settings should be ignored (return system default)
req = mock_api_request({'lang': 'fr'})
rsp_headers, code, response = get_collection_tiles(
api_, req, 'naturalearth/lakes')
assert rsp_headers['Content-Language'] == 'en-US'
content = json.loads(response)
assert len(content['links']) > 0
assert len(content['tilesets']) > 0
def test_tilematrixsets(config, api_):
req = mock_api_request()
rsp_headers, code, response = tilematrixsets(api_, req)
root = json.loads(response)
assert isinstance(root, dict)
assert 'tileMatrixSets' in root
assert len(root['tileMatrixSets']) == 2
assert 'http://www.opengis.net/def/tilematrixset/OGC/1.0/WorldCRS84Quad' \
in root['tileMatrixSets'][0]['uri']
assert 'http://www.opengis.net/def/tilematrixset/OGC/1.0/WebMercatorQuad' \
in root['tileMatrixSets'][1]['uri']
req = mock_api_request({'f': 'html'})
rsp_headers, code, response = tilematrixsets(api_, req)
assert rsp_headers['Content-Type'] == FORMAT_TYPES[F_HTML]
# No language requested: should be set to default from YAML
assert rsp_headers['Content-Language'] == 'en-US'
def test_tilematrixset(config, api_):
req = mock_api_request()
enums = [e.value for e in TileMatrixSetEnum]
enum = None
for e in enums:
enum = e.tileMatrixSet
rsp_headers, code, response = tilematrixset(api_, req, enum)
root = json.loads(response)
assert isinstance(root, dict)
assert 'id' in root
assert root['id'] == enum
assert 'tileMatrices' in root
assert len(root['tileMatrices']) == 30
rsp_headers, code, response = tilematrixset(api_, req, 'foo')
assert code == HTTPStatus.BAD_REQUEST
req = mock_api_request({'f': 'html'})
rsp_headers, code, response = tilematrixset(api_, req, enum)
assert rsp_headers['Content-Type'] == FORMAT_TYPES[F_HTML]
# No language requested: should be set to default from YAML
assert rsp_headers['Content-Language'] == 'en-US'
+52
View File
@@ -0,0 +1,52 @@
# =================================================================
#
# Authors: Bernhard Mallinger <bernhard.mallinger@eox.at>
#
# Copyright (c) 2024 Bernhard Mallinger
#
# 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 pytest
from pygeoapi.api import API
from pygeoapi.util import yaml_load
from tests.util import get_test_file_path
@pytest.fixture()
def config():
with open(get_test_file_path('pygeoapi-test-config.yml')) as fh:
return yaml_load(fh)
@pytest.fixture()
def openapi():
with open(get_test_file_path('pygeoapi-test-openapi.yml')) as fh:
return yaml_load(fh)
@pytest.fixture()
def api_(config, openapi):
return API(config, openapi)
-2262
View File
File diff suppressed because it is too large Load Diff
+26 -24
View File
@@ -4,7 +4,7 @@
# Authors: Tom Kralidis <tomkralidis@gmail.com>
#
# Copyright (c) 2019 Just van den Broecke
# Copyright (c) 2022 Tom Kralidis
# Copyright (c) 2024 Tom Kralidis
#
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation
@@ -35,8 +35,10 @@ import logging
import pytest
from pygeoapi.api import API
from pygeoapi.api.itemtypes import get_collection_item, get_collection_items
from pygeoapi.util import yaml_load, geojson_to_geom
from .util import get_test_file_path, mock_request
from .util import get_test_file_path, mock_api_request
LOGGER = logging.getLogger(__name__)
@@ -70,16 +72,16 @@ def test_get_collection_items_bbox_crs(config, api_):
COLLECTIONS = ['dutch_addresses_4326', 'dutch_addresses_28992']
for coll in COLLECTIONS:
# bbox-crs full extent
req = mock_request({'bbox': '5.670670, 52.042700, 5.829110, 52.123700', 'bbox-crs': 'http://www.opengis.net/def/crs/OGC/1.3/CRS84'}) # noqa
rsp_headers, code, response = api_.get_collection_items(req, coll) # noqa
req = mock_api_request({'bbox': '5.670670, 52.042700, 5.829110, 52.123700', 'bbox-crs': 'http://www.opengis.net/def/crs/OGC/1.3/CRS84'}) # noqa
rsp_headers, code, response = get_collection_items(api_, req, coll) # noqa
features = json.loads(response)['features']
assert len(features) == 10
# bbox-crs partial extent, 1 feature, request with multiple CRSs
for crs in CRS_BBOX_DICT:
req = mock_request({'bbox': CRS_BBOX_DICT[crs], 'bbox-crs': crs}) # noqa
rsp_headers, code, response = api_.get_collection_items(req, coll) # noqa
req = mock_api_request({'bbox': CRS_BBOX_DICT[crs], 'bbox-crs': crs}) # noqa
rsp_headers, code, response = get_collection_items(api_, req, coll) # noqa
features = json.loads(response)['features']
assert len(features) == 1
@@ -88,29 +90,29 @@ def test_get_collection_items_bbox_crs(config, api_):
assert properties['huisnummer'] == '2'
# bbox-crs outside extent
req = mock_request({'bbox': '5, 51.9, 5.1, 52.0', 'bbox-crs': 'http://www.opengis.net/def/crs/OGC/1.3/CRS84'}) # noqa
rsp_headers, code, response = api_.get_collection_items(req, coll) # noqa
req = mock_api_request({'bbox': '5, 51.9, 5.1, 52.0', 'bbox-crs': 'http://www.opengis.net/def/crs/OGC/1.3/CRS84'}) # noqa
rsp_headers, code, response = get_collection_items(api_, req, coll) # noqa
features = json.loads(response)['features']
assert len(features) == 0
# bbox-crs outside extent
req = mock_request({'bbox': '130000, 440000, 140000, 450000', 'bbox-crs': 'http://www.opengis.net/def/crs/EPSG/0/28992'}) # noqa
rsp_headers, code, response = api_.get_collection_items(req, coll) # noqa
req = mock_api_request({'bbox': '130000, 440000, 140000, 450000', 'bbox-crs': 'http://www.opengis.net/def/crs/EPSG/0/28992'}) # noqa
rsp_headers, code, response = get_collection_items(api_, req, coll) # noqa
features = json.loads(response)['features']
assert len(features) == 0
# bbox-crs outside extent - axis reversed CRS
req = mock_request({'bbox': '51.9, 5, 52.0, 5.1', 'bbox-crs': 'http://www.opengis.net/def/crs/EPSG/0/4326'}) # noqa
rsp_headers, code, response = api_.get_collection_items(req, coll) # noqa
req = mock_api_request({'bbox': '51.9, 5, 52.0, 5.1', 'bbox-crs': 'http://www.opengis.net/def/crs/EPSG/0/4326'}) # noqa
rsp_headers, code, response = get_collection_items(api_, req, coll) # noqa
features = json.loads(response)['features']
assert len(features) == 0
# bbox-crs full extent - axis reversed CRS
req = mock_request({'bbox': '52.042700, 5.670670, 52.123700, 5.829110', 'bbox-crs': 'http://www.opengis.net/def/crs/EPSG/0/4326'}) # noqa
rsp_headers, code, response = api_.get_collection_items(req, coll) # noqa
req = mock_api_request({'bbox': '52.042700, 5.670670, 52.123700, 5.829110', 'bbox-crs': 'http://www.opengis.net/def/crs/EPSG/0/4326'}) # noqa
rsp_headers, code, response = get_collection_items(api_, req, coll) # noqa
features = json.loads(response)['features']
assert len(features) == 10
@@ -135,8 +137,8 @@ def test_get_collection_items_crs(config, api_):
COLLECTIONS = ['dutch_addresses_4326', 'dutch_addresses_28992']
for coll in COLLECTIONS:
# crs full extent to get target feature
req = mock_request({}) # noqa
rsp_headers, code, response = api_.get_collection_items(req, coll) # noqa
req = mock_api_request({}) # noqa
rsp_headers, code, response = get_collection_items(api_, req, coll) # noqa
features = json.loads(response)['features']
assert len(features) == 10
@@ -145,13 +147,13 @@ def test_get_collection_items_crs(config, api_):
# request with multiple CRSs
for crs in CRS_DICT:
# Do for query (/items)
req = mock_request({'crs': crs}) # noqa
req = mock_api_request({'crs': crs}) # noqa
if crs == 'none':
# Test for default bbox CRS
req = mock_request({}) # noqa
req = mock_api_request({}) # noqa
crs = DEFAULT_CRS
rsp_headers, code, response = api_.get_collection_items(req, coll) # noqa
rsp_headers, code, response = get_collection_items(api_, req, coll) # noqa
features = json.loads(response)['features']
assert len(features) == 10
@@ -173,8 +175,8 @@ def test_get_collection_items_crs(config, api_):
assert test_geom.equals_exact(crs_geom, 1), f'coords not equal for CRS: {crs} {crs_geom} in COLL: {coll} {test_geom}' # noqa
# Same for single Feature 'get'
req = mock_request({'crs': crs}) # noqa
rsp_headers, code, response = api_.get_collection_item(req, coll, feature_id) # noqa
req = mock_api_request({'crs': crs}) # noqa
rsp_headers, code, response = get_collection_item(api_, req, coll, feature_id) # noqa
test_feature = json.loads(response)
assert test_feature['id'] == feature_id
@@ -195,13 +197,13 @@ def test_get_collection_items_crs(config, api_):
# Test combining BBOX and BBOX-CRS
for bbox_crs in CRS_BBOX_DICT:
# Do for query (/items)
req = mock_request({'crs': crs, 'bbox': CRS_BBOX_DICT[bbox_crs], 'bbox-crs': bbox_crs}) # noqa
req = mock_api_request({'crs': crs, 'bbox': CRS_BBOX_DICT[bbox_crs], 'bbox-crs': bbox_crs}) # noqa
if bbox_crs == 'none':
# Test for default bbox CRS
req = mock_request({'crs': crs, 'bbox': CRS_BBOX_DICT[bbox_crs]}) # noqa
req = mock_api_request({'crs': crs, 'bbox': CRS_BBOX_DICT[bbox_crs]}) # noqa
bbox_crs = DEFAULT_CRS
rsp_headers, code, response = api_.get_collection_items(req, coll) # noqa
rsp_headers, code, response = get_collection_items(api_, req, coll) # noqa
features = json.loads(response)['features']
assert len(features) == 1
+48 -51
View File
@@ -7,7 +7,7 @@
# Francesco Bartoli <xbartolone@gmail.com>
#
# Copyright (c) 2019 Just van den Broecke
# Copyright (c) 2023 Tom Kralidis
# Copyright (c) 2024 Tom Kralidis
# Copyright (c) 2022 John A Stevenson and Colin Blackburn
# Copyright (c) 2023 Francesco Bartoli
#
@@ -47,7 +47,9 @@ from http import HTTPStatus
from pygeofilter.parsers.ecql import parse
from pygeoapi.api import API
from pygeoapi.api.itemtypes import (
get_collection_items, get_collection_item, post_collection_items
)
from pygeoapi.provider.base import (
ProviderConnectionError,
ProviderItemNotFoundError,
@@ -59,7 +61,7 @@ import pygeoapi.provider.postgresql as postgresql_provider_module
from pygeoapi.util import (yaml_load, geojson_to_geom,
get_transform_from_crs, get_crs_from_uri)
from .util import get_test_file_path, mock_request
from .util import get_test_file_path, mock_api_request
PASSWORD = os.environ.get('POSTGRESQL_PASSWORD', 'postgres')
DEFAULT_CRS = 'http://www.opengis.net/def/crs/OGC/1.3/CRS84'
@@ -429,12 +431,12 @@ def test_get_collection_items_postgresql_cql(pg_api_):
expected_ids = [80835474, 80835483]
# Act
req = mock_request({
req = mock_api_request({
'filter-lang': 'cql-text',
'filter': cql_query
})
rsp_headers, code, response = pg_api_.get_collection_items(
req, 'hot_osm_waterways')
rsp_headers, code, response = get_collection_items(
pg_api_, req, 'hot_osm_waterways')
# Assert
assert code == HTTPStatus.OK
@@ -443,11 +445,11 @@ def test_get_collection_items_postgresql_cql(pg_api_):
assert ids == expected_ids
# Act, no filter-lang
req = mock_request({
req = mock_api_request({
'filter': cql_query
})
rsp_headers, code, response = pg_api_.get_collection_items(
req, 'hot_osm_waterways')
rsp_headers, code, response = get_collection_items(
pg_api_, req, 'hot_osm_waterways')
# Assert
assert code == HTTPStatus.OK
@@ -467,12 +469,12 @@ def test_get_collection_items_postgresql_cql_invalid_filter_language(pg_api_):
cql_query = 'osm_id BETWEEN 80800000 AND 80900000 AND name IS NULL'
# Act
req = mock_request({
req = mock_api_request({
'filter-lang': 'cql-json', # Only cql-text is valid for GET
'filter': cql_query
})
rsp_headers, code, response = pg_api_.get_collection_items(
req, 'hot_osm_waterways')
rsp_headers, code, response = get_collection_items(
pg_api_, req, 'hot_osm_waterways')
# Assert
assert code == HTTPStatus.BAD_REQUEST
@@ -494,11 +496,11 @@ def test_get_collection_items_postgresql_cql_bad_cql(pg_api_, bad_cql):
Test for bad cql
"""
# Act
req = mock_request({
req = mock_api_request({
'filter': bad_cql
})
rsp_headers, code, response = pg_api_.get_collection_items(
req, 'hot_osm_waterways')
rsp_headers, code, response = get_collection_items(
pg_api_, req, 'hot_osm_waterways')
# Assert
assert code == HTTPStatus.BAD_REQUEST
@@ -524,11 +526,11 @@ def test_post_collection_items_postgresql_cql(pg_api_):
expected_ids = [80835474, 80835483]
# Act
req = mock_request({
req = mock_api_request({
'filter-lang': 'cql-json'
}, data=cql, **headers)
rsp_headers, code, response = pg_api_.post_collection_items(
req, 'hot_osm_waterways')
rsp_headers, code, response = post_collection_items(
pg_api_, req, 'hot_osm_waterways')
# Assert
assert code == HTTPStatus.OK
@@ -550,11 +552,11 @@ def test_post_collection_items_postgresql_cql_invalid_filter_language(pg_api_):
headers = {'CONTENT_TYPE': 'application/query-cql-json'}
# Act
req = mock_request({
req = mock_api_request({
'filter-lang': 'cql-text' # Only cql-json is valid for POST
}, data=cql, **headers)
rsp_headers, code, response = pg_api_.post_collection_items(
req, 'hot_osm_waterways')
rsp_headers, code, response = post_collection_items(
pg_api_, req, 'hot_osm_waterways')
# Assert
assert code == HTTPStatus.BAD_REQUEST
@@ -580,11 +582,11 @@ def test_post_collection_items_postgresql_cql_bad_cql(pg_api_, bad_cql):
headers = {'CONTENT_TYPE': 'application/query-cql-json'}
# Act
req = mock_request({
req = mock_api_request({
'filter-lang': 'cql-json'
}, data=bad_cql, **headers)
rsp_headers, code, response = pg_api_.post_collection_items(
req, 'hot_osm_waterways')
rsp_headers, code, response = post_collection_items(
pg_api_, req, 'hot_osm_waterways')
# Assert
assert code == HTTPStatus.BAD_REQUEST
@@ -601,10 +603,9 @@ def test_get_collection_items_postgresql_crs(pg_api_):
crs_32735 = 'http://www.opengis.net/def/crs/EPSG/0/32735'
# Without CRS query parameter -> no coordinates transformation
req = mock_request({'bbox': '29.0,-2.85,29.05,-2.8'})
rsp_headers, code, response = pg_api_.get_collection_items(
req, 'hot_osm_waterways',
)
req = mock_api_request({'bbox': '29.0,-2.85,29.05,-2.8'})
rsp_headers, code, response = get_collection_items(
pg_api_, req, 'hot_osm_waterways')
assert code == HTTPStatus.OK
@@ -613,10 +614,10 @@ def test_get_collection_items_postgresql_crs(pg_api_):
# With CRS query parameter not resulting in coordinates transformation
# (i.e. 'crs' query parameter is the same as 'storage_crs')
req = mock_request({'crs': storage_crs, 'bbox': '29.0,-2.85,29.05,-2.8'})
rsp_headers, code, response = pg_api_.get_collection_items(
req, 'hot_osm_waterways',
)
req = mock_api_request(
{'crs': storage_crs, 'bbox': '29.0,-2.85,29.05,-2.8'})
rsp_headers, code, response = get_collection_items(
pg_api_, req, 'hot_osm_waterways')
assert code == HTTPStatus.OK
assert rsp_headers['Content-Crs'] == f'<{storage_crs}>'
@@ -624,10 +625,9 @@ def test_get_collection_items_postgresql_crs(pg_api_):
features_storage_crs = json.loads(response)
# With CRS query parameter resulting in coordinates transformation
req = mock_request({'crs': crs_32735, 'bbox': '29.0,-2.85,29.05,-2.8'})
rsp_headers, code, response = pg_api_.get_collection_items(
req, 'hot_osm_waterways',
)
req = mock_api_request({'crs': crs_32735, 'bbox': '29.0,-2.85,29.05,-2.8'})
rsp_headers, code, response = get_collection_items(
pg_api_, req, 'hot_osm_waterways')
assert code == HTTPStatus.OK
assert rsp_headers['Content-Crs'] == f'<{crs_32735}>'
@@ -688,10 +688,9 @@ def test_get_collection_item_postgresql_crs(pg_api_):
]
for fid in fid_list:
# Without CRS query parameter -> no coordinates transformation
req = mock_request({'f': 'json'})
rsp_headers, code, response = pg_api_.get_collection_item(
req, 'hot_osm_waterways', fid,
)
req = mock_api_request({'f': 'json'})
rsp_headers, code, response = get_collection_item(
pg_api_, req, 'hot_osm_waterways', fid)
assert code == HTTPStatus.OK
assert rsp_headers['Content-Crs'] == f'<{DEFAULT_CRS}>'
@@ -701,10 +700,9 @@ def test_get_collection_item_postgresql_crs(pg_api_):
# With CRS query parameter not resulting in coordinates transformation
# (i.e. 'crs' query parameter is the same as 'storage_crs')
req = mock_request({'f': 'json', 'crs': storage_crs})
rsp_headers, code, response = pg_api_.get_collection_item(
req, 'hot_osm_waterways', fid,
)
req = mock_api_request({'f': 'json', 'crs': storage_crs})
rsp_headers, code, response = get_collection_item(
pg_api_, req, 'hot_osm_waterways', fid)
assert code == HTTPStatus.OK
assert rsp_headers['Content-Crs'] == f'<{storage_crs}>'
@@ -716,10 +714,9 @@ def test_get_collection_item_postgresql_crs(pg_api_):
assert feat_orig['geometry'] == feat_storage_crs['geometry']
# With CRS query parameter resulting in coordinates transformation
req = mock_request({'f': 'json', 'crs': crs_32735})
rsp_headers, code, response = pg_api_.get_collection_item(
req, 'hot_osm_waterways', fid,
)
req = mock_api_request({'f': 'json', 'crs': crs_32735})
rsp_headers, code, response = get_collection_item(
pg_api_, req, 'hot_osm_waterways', fid)
assert code == HTTPStatus.OK
assert rsp_headers['Content-Crs'] == f'<{crs_32735}>'
@@ -741,9 +738,9 @@ def test_get_collection_items_postgresql_automap_naming_conflicts(pg_api_):
Test that PostgreSQLProvider can handle naming conflicts when automapping
classes and relationships from database schema.
"""
req = mock_request()
rsp_headers, code, response = pg_api_.get_collection_items(
req, 'dummy_naming_conflicts')
req = mock_api_request()
rsp_headers, code, response = get_collection_items(
pg_api_, req, 'dummy_naming_conflicts')
assert code == HTTPStatus.OK
features = json.loads(response).get('features')
+21
View File
@@ -40,6 +40,8 @@ from werkzeug.test import create_environ
from werkzeug.wrappers import Request
from werkzeug.datastructures import ImmutableMultiDict
from pygeoapi.api import APIRequest
LOGGER = logging.getLogger(__name__)
@@ -77,6 +79,25 @@ def mock_request(params: dict = None, data=None, **headers) -> Request:
return request
def mock_api_request(params: dict | None = None, data=None, **headers
) -> APIRequest:
"""
Mocks an APIRequest
:param params: Optional query parameter dict for the request.
Will be set to {} if omitted.
:param data: Optional data/body to send with the request.
Can be text/bytes or a JSON dictionary.
:param headers: Optional request HTTP headers to set.
:returns: APIRequest instance
"""
return APIRequest.from_flask(
mock_request(params=params, data=data, **headers),
# NOTE: could also read supported_locales from test config
supported_locales=['en-US', 'fr-CA'],
)
@contextmanager
def mock_flask(config_file: str = 'pygeoapi-test-config.yml',
openapi_file: str = 'pygeoapi-test-openapi.yml',