diff --git a/docs/source/data-publishing/ogcapi-tiles.rst b/docs/source/data-publishing/ogcapi-tiles.rst index a495fd6..579967c 100644 --- a/docs/source/data-publishing/ogcapi-tiles.rst +++ b/docs/source/data-publishing/ogcapi-tiles.rst @@ -8,7 +8,7 @@ Publishing map tiles to OGC API - Tiles pygeoapi can publish tiles from local or remote data sources (including cloud object storage). To integrate tiles from a local data source, it is assumed -that a directory tree of static tiles has been created on disk. Example of +that a directory tree of static tiles has been created on disk. Examples of tile generation software include (but are not limited to): - `MapProxy`_ diff --git a/pygeoapi/__init__.py b/pygeoapi/__init__.py index 7073c70..2f81c94 100644 --- a/pygeoapi/__init__.py +++ b/pygeoapi/__init__.py @@ -33,8 +33,10 @@ import click from pygeoapi.openapi import generate_openapi_document -cli = click.Group() -cli.version = __version__ +@click.group() +@click.version_option(version=__version__) +def cli(): + pass @cli.command() diff --git a/pygeoapi/api.py b/pygeoapi/api.py index 9590774..ed21350 100644 --- a/pygeoapi/api.py +++ b/pygeoapi/api.py @@ -52,7 +52,8 @@ from pygeoapi.provider.base import ( ProviderInvalidQueryError, ProviderNoDataError, ProviderQueryError, ProviderItemNotFoundError, ProviderTypeError) -from pygeoapi.provider.tile import (ProviderTileQueryError, +from pygeoapi.provider.tile import (ProviderTileNotFoundError, + ProviderTileQueryError, ProviderTilesetIdNotFoundError) from pygeoapi.util import (dategetter, filter_dict_by_key_value, get_provider_by_type, get_provider_default, @@ -1212,7 +1213,7 @@ class API: return headers_, 200, to_json(content, self.pretty_print) @jsonldify - def get_collection_coverage(self, headers_, args, dataset, + def get_collection_coverage(self, headers, args, dataset, pathinfo=None): """ Returns a subset of a collection coverage @@ -1225,6 +1226,7 @@ class API: :returns: tuple of headers, status code, content """ + headers_ = HEADERS.copy() query_args = {} format_ = 'json' @@ -1238,14 +1240,20 @@ class API: self.config['resources'][dataset]['providers'], 'coverage') p = load_plugin('provider', collection_def) + except KeyError: + exception = { + 'code': 'InvalidParameterValue', + 'description': 'collection does not exist' + } + LOGGER.error(exception) + return headers_, 404, to_json(exception, self.pretty_print) except ProviderTypeError: exception = { 'code': 'NoApplicableCode', 'description': 'invalid provider type' } LOGGER.error(exception) - return ({'Content-type': 'application/json'}, 400, - to_json(exception, self.pretty_print)) + return headers_, 400, to_json(exception, self.pretty_print) except ProviderConnectionError: exception = { 'code': 'NoApplicableCode', @@ -1270,11 +1278,10 @@ class API: 'description': 'Invalid field specified' } LOGGER.error(exception) - return ({'Content-type': 'application/json'}, 400, - to_json(exception, self.pretty_print)) + return headers_, 400, to_json(exception, self.pretty_print) if 'subset' in args: - LOGGER.debug('Processing subset parameters') + LOGGER.debug('Processing subset parameter') for s in args['subset'].split(','): try: if '"' not in s: @@ -1290,8 +1297,8 @@ class API: 'description': 'Invalid axis name' } LOGGER.error(exception) - return ({'Content-type': 'application/json'}, 400, - to_json(exception, self.pretty_print)) + return (headers_, 400, to_json(exception, + self.pretty_print)) subsets[subset_name] = list(map( get_typed_value, m.group(2, 3))) @@ -1315,43 +1322,40 @@ class API: 'description': 'query error: {}'.format(err), } LOGGER.error(exception) - return ({'Content-type': 'application/json'}, - 400, to_json(exception, self.pretty_print)) + return headers_, 400, to_json(exception, self.pretty_print) except ProviderNoDataError: exception = { 'code': 'NoApplicableCode', 'description': 'No data found' } LOGGER.debug(exception) - return ({'Content-type': 'application/json'}, - 204, to_json(exception, self.pretty_print)) + return headers_, 204, to_json(exception, self.pretty_print) except ProviderQueryError: exception = { 'code': 'NoApplicableCode', 'description': 'query error (check logs)' } LOGGER.error(exception) - return ({'Content-type': 'application/json'}, - 500, to_json(exception, self.pretty_print)) + return headers_, 500, to_json(exception, self.pretty_print) mt = collection_def['format']['name'] if format_ == mt: - return ({'Content-type': mt}, 200, data) + headers_['Content-type'] = mt + return headers_, 200, data elif format_ == 'json': - return ({'Content-type': 'application/prs.coverage+json'}, - 200, to_json(data, self.pretty_print)) + headers_['Content-type'] = 'application/prs.coverage+json' + return headers_, 200, to_json(data, self.pretty_print) else: exception = { 'code': 'InvalidParameterValue', 'description': 'invalid format parameter' } LOGGER.error(exception) - return ({'Content-type': 'application/json'}, - 400, to_json(exception, self.pretty_print)) + return headers_, 400, to_json(data, self.pretty_print) @jsonldify - def get_collection_coverage_domainset(self, headers_, args, dataset, + def get_collection_coverage_domainset(self, headers, args, dataset, pathinfo=None): """ Returns a collection coverage domainset @@ -1364,6 +1368,8 @@ class API: :returns: tuple of headers, status code, content """ + headers_ = HEADERS.copy() + format_ = check_format(args, headers_) if format_ is None: format_ = 'json' @@ -1376,14 +1382,20 @@ class API: p = load_plugin('provider', collection_def) data = p.get_coverage_domainset() + except KeyError: + exception = { + 'code': 'InvalidParameterValue', + 'description': 'collection does not exist' + } + LOGGER.error(exception) + return headers_, 404, to_json(exception, self.pretty_print) except ProviderTypeError: exception = { 'code': 'NoApplicableCode', 'description': 'invalid provider type' } LOGGER.error(exception) - return ({'Content-type': 'application/json'}, 400, - to_json(exception, self.pretty_print)) + return headers_, 400, to_json(exception, self.pretty_print) except ProviderConnectionError: exception = { 'code': 'NoApplicableCode', @@ -1393,25 +1405,24 @@ class API: return headers_, 500, to_json(exception, self.pretty_print) if format_ == 'json': - return ({'Content-type': 'application/json'}, - 200, to_json(data, self.pretty_print)) + return headers_, 200, to_json(data, self.pretty_print) elif format_ == 'html': data['id'] = dataset data['title'] = self.config['resources'][dataset]['title'] content = render_j2_template(self.config, 'domainset.html', data) - return {'Content-type': 'text/html'}, 200, content + headers_['Content-type'] = 'text/html' + return headers_, 200, content else: exception = { 'code': 'InvalidParameterValue', 'description': 'invalid format parameter' } LOGGER.error(exception) - return ({'Content-type': 'application/json'}, - 400, to_json(exception, self.pretty_print)) + return headers_, 400, to_json(exception, self.pretty_print) @jsonldify - def get_collection_coverage_rangetype(self, headers_, args, dataset, + def get_collection_coverage_rangetype(self, headers, args, dataset, pathinfo=None): """ Returns a collection coverage rangetype @@ -1424,7 +1435,8 @@ class API: :returns: tuple of headers, status code, content """ - format_ = check_format(args, headers_) + headers_ = HEADERS.copy() + format_ = check_format(args, headers) if format_ is None: format_ = 'json' @@ -1436,14 +1448,20 @@ class API: p = load_plugin('provider', collection_def) data = p.get_coverage_rangetype() + except KeyError: + exception = { + 'code': 'InvalidParameterValue', + 'description': 'collection does not exist' + } + LOGGER.error(exception) + return headers_, 404, to_json(exception, self.pretty_print) except ProviderTypeError: exception = { 'code': 'NoApplicableCode', 'description': 'invalid provider type' } LOGGER.error(exception) - return ({'Content-type': 'application/json'}, 400, - to_json(exception, self.pretty_print)) + return headers_, 400, to_json(exception, self.pretty_print) except ProviderConnectionError: exception = { 'code': 'NoApplicableCode', @@ -1460,15 +1478,15 @@ class API: data['title'] = self.config['resources'][dataset]['title'] content = render_j2_template(self.config, 'rangetype.html', data) - return {'Content-type': 'text/html'}, 200, content + headers_['Content-type'] = 'text/html' + return headers_, 200, content else: exception = { 'code': 'InvalidParameterValue', 'description': 'invalid format parameter' } LOGGER.error(exception) - return ({'Content-type': 'application/json'}, - 400, to_json(exception, self.pretty_print)) + return headers_, 400, to_json(exception, self.pretty_print) @pre_process @jsonldify @@ -1589,13 +1607,13 @@ tiles/{{{}}}/{{{}}}/{{{}}}/{{{}}}?f=mvt' return headers_, 200, to_json(tiles, self.pretty_print) @jsonldify - def get_collection_tiles_data(self, headers_, format_, dataset=None, + def get_collection_tiles_data(self, headers, format_, dataset=None, matrix_id=None, z_idx=None, y_idx=None, x_idx=None): """ Get collection items tiles - :param headers_: copy of HEADERS object + :param headers: copy of HEADERS object :param format_: format of requests, pre checked by pre_process decorator :param dataset: dataset name @@ -1607,6 +1625,9 @@ tiles/{{{}}}/{{{}}}/{{{}}}/{{{}}}?f=mvt' :returns: tuple of headers, status code, content """ + headers_ = HEADERS.copy() +# format_ = check_format({}, headers) + if format_ is None and format_ not in ['mvt']: exception = { 'code': 'InvalidParameterValue', @@ -1635,6 +1656,7 @@ tiles/{{{}}}/{{{}}}/{{{}}}/{{{}}}?f=mvt' p = load_plugin('provider', t) format_ = p.format_type + headers_['Content-Type'] = format_ LOGGER.debug('Fetching tileset id {} and tile {}/{}/{}'.format( matrix_id, z_idx, y_idx, x_idx)) @@ -1648,7 +1670,7 @@ tiles/{{{}}}/{{{}}}/{{{}}}/{{{}}}?f=mvt' LOGGER.error(exception) return headers_, 404, to_json(exception) else: - return headers_, 200, content + return headers_, 202, content # @TODO: figure out if the spec requires to return json errors except KeyError: exception = { @@ -1678,6 +1700,13 @@ tiles/{{{}}}/{{{}}}/{{{}}}/{{{}}}?f=mvt' } LOGGER.error(err) return headers_, 500, to_json(exception) + except ProviderTileNotFoundError as err: + exception = { + 'code': 'NoMatch', + 'description': 'tile not found (check logs)' + } + LOGGER.error(err) + return headers_, 404, to_json(exception) except ProviderGenericError as err: exception = { 'code': 'NoApplicableCode', diff --git a/pygeoapi/openapi.py b/pygeoapi/openapi.py index be47689..87eefbb 100644 --- a/pygeoapi/openapi.py +++ b/pygeoapi/openapi.py @@ -34,6 +34,7 @@ import os import click import yaml +from pygeoapi import __version__ from pygeoapi.plugin import load_plugin from pygeoapi.provider.base import ProviderTypeError from pygeoapi.util import (filter_dict_by_key_value, get_provider_by_type, @@ -145,7 +146,7 @@ def get_oas_30(cfg): 'name': cfg['metadata']['license']['name'], 'url': cfg['metadata']['license']['url'] }, - 'version': '3.0.2' + 'version': __version__ } oas['info'] = info @@ -575,7 +576,9 @@ def get_oas_30(cfg): LOGGER.debug('setting up tiles endpoints') tile_extension = filter_providers_by_type( collections[k]['providers'], 'tile') + if tile_extension: + tp = load_plugin('provider', tile_extension) oas['components']['responses'].update({ 'Tiles': { 'description': 'Retrieves the tiles description for this collection', # noqa @@ -652,18 +655,38 @@ def get_oas_30(cfg): 'summary': 'Get a {} tile'.format(v['title']), 'description': v['description'], 'tags': [k], - 'operationId': 'describe{}Tiles'.format(k.capitalize()), - 'parameters': [ - items_f, - ], + 'operationId': 'get{}Tiles'.format(k.capitalize()), + 'parameters': [{ + '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': { - '200': {'$ref': '#/components/responses/Tiles'}, # noqa # TODO: fix response format '400': {'$ref': '{}#/components/responses/InvalidParameter'.format(OPENAPI_YAML['oapif'])}, # noqa '404': {'$ref': '{}#/components/responses/NotFound'.format(OPENAPI_YAML['oapif'])}, # noqa '500': {'$ref': '{}#/components/responses/ServerError'.format(OPENAPI_YAML['oapif'])} # noqa } } } + mimetype = tile_extension['format']['mimetype'] + paths[tiles_data_path]['get']['responses']['200'] = { + 'content': { + mimetype: { + 'schema': { + 'type': 'string', + 'format': 'binary' + } + } + } + } LOGGER.debug('setting up STAC') stac_collections = filter_dict_by_key_value(cfg['resources'], diff --git a/pygeoapi/provider/filesystem.py b/pygeoapi/provider/filesystem.py index 44c65cc..00de684 100644 --- a/pygeoapi/provider/filesystem.py +++ b/pygeoapi/provider/filesystem.py @@ -140,7 +140,9 @@ class FileSystemProvider(BaseProvider): return fh.read() elif resource_type == 'directory': - for dc in os.listdir(data_path): + dirpath2 = os.listdir(data_path) + dirpath2.sort() + for dc in dirpath2: # @TODO: handle a generic directory for tiles if dc == "tiles": continue diff --git a/pygeoapi/provider/mvt.py b/pygeoapi/provider/mvt.py index a40b19b..c9e6c88 100644 --- a/pygeoapi/provider/mvt.py +++ b/pygeoapi/provider/mvt.py @@ -34,7 +34,8 @@ from pathlib import Path from urllib.parse import urlparse, urljoin from pygeoapi.util import is_url -from pygeoapi.provider.tile import BaseTileProvider +from pygeoapi.provider.tile import ( + BaseTileProvider, ProviderTileNotFoundError) from pygeoapi.provider.base import ProviderConnectionError @@ -204,10 +205,13 @@ class MVTProvider(BaseTileProvider): LOGGER.error(msg) raise ProviderConnectionError(msg) else: - with open(self.service_url.joinpath( - '{z}/{y}/{x}.{f}'.format( - z=z, y=y, x=x, f=format_)), 'rb') as tile: - return tile.read() + try: + with open(self.service_url.joinpath( + '{z}/{y}/{x}.{f}'.format( + z=z, y=y, x=x, f=format_)), 'rb') as tile: + return tile.read() + except FileNotFoundError as err: + raise ProviderTileNotFoundError(err) def get_metadata(self, dataset, server_url, layer=None, tileset=None, tilejson=True): diff --git a/pygeoapi/provider/tile.py b/pygeoapi/provider/tile.py index 27f108f..cfac7d2 100644 --- a/pygeoapi/provider/tile.py +++ b/pygeoapi/provider/tile.py @@ -125,6 +125,11 @@ class ProviderTileQueryError(ProviderGenericError): pass +class ProviderTileNotFoundError(ProviderGenericError): + """provider tile not found error""" + pass + + class ProviderTilesetIdNotFoundError(ProviderTileQueryError): """provider tileset matrix query error""" pass diff --git a/tests/test_api.py b/tests/test_api.py index 8935a1d..929953e 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -697,15 +697,13 @@ def test_get_collection_coverage(config, api_): assert code == 204 -def test_get_collection_tiles_invalid(config, api_): +def test_get_collection_tiles(config, api_): req_headers = make_lakes_req_headers() rsp_headers, code, response = api_.get_collection_tiles( req_headers, {}, 'obs') assert code == 400 - -def test_get_collection_tiles(config, api_): req_headers = make_lakes_req_headers() rsp_headers, code, response = api_.get_collection_tiles( req_headers, {}, 'lakes')