diff --git a/docs/source/publishing/ogcapi-maps.rst b/docs/source/publishing/ogcapi-maps.rst index e68b4eec1..ad326afb6 100644 --- a/docs/source/publishing/ogcapi-maps.rst +++ b/docs/source/publishing/ogcapi-maps.rst @@ -118,14 +118,14 @@ required. An optional style name can be defined via `options.style`. .. note:: According to the `Standard `_, OGC API - Maps - supports a `crs` parameter, expressed as an uri. Currently, this provider supports CRS84, WGS84 and Web Mercator; for a matter of convenience, they can be expressed in - a number of different ways, other than the uri format. - - - `EPSG:4326` - - `EPSG:3857` - - `4326` - - `3857`, - - `CRS84` + supports a `crs` and `bbox-crs` parameters, expressed as an uri or a CURIE. Currently, this provider supports CRS84 and various CRS from the EPSG namespace; for a matter of convenience, they can be expressed + also as unsafe CURIEs. + + - `http://www.opengis.net/def/crs/EPSG/0/4326` + - `[EPSG:4326]` + - `EPSG:4326` (unsafe) + - `CRS:84` (unsafe, for compatibility with WMS) + - `OGC:CRS84` (unsafe) If `crs` is not provided, the server will default to the `storage_crs`; in case it does not exist, the default is `CRS84`. If `crs-bbox` is not provided, it will default to `CRS84`. If the `bbox` is not provided, it will default to `-180, -90, 180, 90`. diff --git a/pygeoapi/api/maps.py b/pygeoapi/api/maps.py index 360673f18..23ce03ca1 100644 --- a/pygeoapi/api/maps.py +++ b/pygeoapi/api/maps.py @@ -44,7 +44,7 @@ import logging from typing import Tuple -from pygeoapi.crs import transform_bbox, DEFAULT_CRS +from pygeoapi.crs import transform_bbox, DEFAULT_CRS, get_uri from pygeoapi.formats import F_JSON, FORMAT_TYPES from pygeoapi.openapi import get_oas_30_parameters from pygeoapi.plugin import load_plugin @@ -64,18 +64,6 @@ DEFAULT_BBOX = [-180, -90, 180, 90] # CRS84 -CRS_CODES = { - '4326': 'http://www.opengis.net/def/crs/EPSG/0/4326', - '3857': 'http://www.opengis.net/def/crs/EPSG/0/3857', - 'CRS84': 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', - 'http://www.opengis.net/def/crs/EPSG/0/4326': 'http://www.opengis.net/def/crs/EPSG/0/4326', # noqa - 'http://www.opengis.net/def/crs/EPSG/0/3857': 'http://www.opengis.net/def/crs/EPSG/0/3857', # noqa - 'http://www.opengis.net/def/crs/OGC/1.3/CRS84': 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', # noqa - 'EPSG:4326': 'http://www.opengis.net/def/crs/EPSG/0/4326', - 'EPSG:3857': 'http://www.opengis.net/def/crs/EPSG/0/3857', - 'CRS:84': 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', -} - def get_collection_map(api: API, request: APIRequest, dataset: str, style: str | None = None @@ -123,11 +111,11 @@ def get_collection_map(api: API, request: APIRequest, # if it does not exist or is not supported, use CRS84. try: if 'crs' not in request.params: - query_args['crs'] = CRS_CODES.get(collection_def.get('storage_crs', - DEFAULT_CRS), DEFAULT_CRS) + query_args['crs'] = collection_def.get('storage_crs', + DEFAULT_CRS) else: - query_args['crs'] = CRS_CODES.get(request.params['crs'], - DEFAULT_CRS) + query_args['crs'] = get_uri(request.params['crs']) + except KeyError: query_args['crs'] = DEFAULT_CRS @@ -138,8 +126,7 @@ def get_collection_map(api: API, request: APIRequest, if 'bbox-crs' not in request.params: query_args['bbox-crs'] = DEFAULT_CRS else: - query_args['bbox-crs'] = CRS_CODES.get(request.params['bbox-crs'], - DEFAULT_CRS) + query_args['bbox-crs'] = get_uri(request.params['bbox-crs']) except KeyError: query_args['bbox-crs'] = DEFAULT_CRS @@ -190,9 +177,9 @@ def get_collection_map(api: API, request: APIRequest, # the transformer function expects the crs to be in a uri format if query_args['bbox-crs'] != query_args['crs']: - LOGGER.debug(f'Reprojecting bbox CRS: {query_args["crs"]}') bbox = transform_bbox(bbox, query_args['bbox-crs'], query_args['crs'], always_xy=True) + LOGGER.debug(f'Transformed bbox: {bbox}') query_args['bbox'] = bbox LOGGER.debug('Processing datetime parameter') diff --git a/pygeoapi/crs.py b/pygeoapi/crs.py index c66287b70..ca0a28a4d 100644 --- a/pygeoapi/crs.py +++ b/pygeoapi/crs.py @@ -3,6 +3,7 @@ # Authors: Tom Kralidis # Just van den Broecke # +# Copyright (c) 2026 Joana Simoes # Copyright (c) 2025 Tom Kralidis # Copyright (c) 2025 Just van den Broecke # @@ -47,6 +48,7 @@ shape as geojson_to_geom, mapping as geom_to_geojson ) +from urllib.parse import urlparse LOGGER = logging.getLogger(__name__) @@ -120,6 +122,96 @@ def get_supported_crs_list( return supported_crs_list +def get_uri(crs: str) -> str: + """ + Parse a uri from a uri or a curie + + :param crs: Uniform resource identifier of the coordinate + reference system. In accordance with + https://docs.ogc.org/pol/09-048r5.html#_naming_rule + Or a safe, or unsafe curie + https://docs.ogc.org/DRAFTS/20-024.html#conventions-curies + + :raises `CRSError`: Error raised if no CRS could be identified from the + URI. + + :returns: `crs uri` matching the input CRS. + """ + + try: + crs = crs.lower() + result = urlparse(crs) + + # If it is a uri, check if it is valid + LOGGER.debug(f'Attempt to parse a uri: {crs}') + + if result.scheme not in ['http', 'https']: + raise CRSError('Invalid uri scheme') + if result.netloc is None or result.netloc != 'www.opengis.net': + raise CRSError('Invalid uri prefix') + + path_el = [p for p in result.path.split('/') if p] + + # Check if the path uri contains the relevant fragments + if len(path_el + ) != 5 or path_el[0] != 'def' or path_el[1] != 'crs' or path_el[2] not in ['epsg', 'ogc']: # noqa + raise CRSError('Invalid uri fragments') + + return crs + + except CRSError: + try: + # Parse safe CURIE + curie = crs.strip('[]') + LOGGER.debug(f'Attempt to parse a curie: {curie}') + + # We support all EPSG codes and CRS84 + if curie not in ['crs:84', 'ogc:crs84']: + [curie_auth, curie_code] = curie.split(':') + if len(curie.split(':')) != 2 or (curie_auth != 'epsg'): + raise CRSError('Unsupported CRS') + + return f'http://www.opengis.net/def/crs/EPSG/0/{curie_code}' + else: + return 'http://www.opengis.net/def/crs/OGC/1.3/CRS84' + + except CRSError as e: + return e + + +def get_curie(crs: str) -> str: + """ + Get a WMS compatible CRS CURIE from a uri + + :param crs: Uniform resource identifier of the coordinate + reference system. In accordance with + https://docs.ogc.org/pol/09-048r5.html#_naming_rule + + :raises `CRSError`: Error raised if no CRS could be identified from the + URI. + + :returns: `WMS CURIE` matching the input uri. + """ + + try: + if not crs.startswith(("http://", "https://")): + raise CRSError('Not an uri') + + crs = crs.lower() + path_el = [p for p in crs.split('/') if p] + + # We support all EPSG codes and CRS84 + if path_el[4] == 'epsg': + return f'EPSG:{path_el[6]}' + elif path_el[6] != 'crs84': + raise CRSError('Unsupported CRS') + + return 'CRS:84' + + except CRSError as e: + return e + + def get_crs(crs: Union[str, pyproj.CRS]) -> pyproj.CRS: """ Get a `pyproj.CRS` instance from a CRS. diff --git a/pygeoapi/provider/wms_facade.py b/pygeoapi/provider/wms_facade.py index 4a69525eb..429950b07 100644 --- a/pygeoapi/provider/wms_facade.py +++ b/pygeoapi/provider/wms_facade.py @@ -31,6 +31,10 @@ import logging from urllib.parse import urlencode +from pyproj.exceptions import CRSError + +from pygeoapi.crs import get_curie + import pyproj import requests @@ -43,13 +47,7 @@ 'png': 'image/png' } -CRS_CODES = { - 'http://www.opengis.net/def/crs/EPSG/0/4326': 'EPSG:4326', - 'http://www.opengis.net/def/crs/EPSG/0/3857': 'EPSG:3857', - 'http://www.opengis.net/def/crs/OGC/1.3/CRS84': 'CRS:84' # noqa -} - -DEFAULT_CRS = 'CRS:84' +DEFAULT_WMS_CRS = 'CRS:84' class WMSFacadeProvider(BaseProvider): @@ -68,9 +66,9 @@ def __init__(self, provider_def): LOGGER.debug(f'pyproj version: {pyproj.__version__}') - def query(self, style=None, bbox=[-180, -90, 180, 90], width=500, - height=300, crs=DEFAULT_CRS, datetime_=None, transparent=True, - format_='png', **kwargs): + def query(self, style=None, bbox=[-180, -90, 180, 90], + width=500, height=300, crs=DEFAULT_WMS_CRS, datetime_=None, + transparent=True, format_='png', **kwargs): """ Generate map @@ -87,11 +85,18 @@ def query(self, style=None, bbox=[-180, -90, 180, 90], width=500, :returns: `bytes` of map image """ + LOGGER.debug(f'bbox: {bbox}') + self._transparent = 'TRUE' version = self.options.get('version', '1.3.0') - if version == '1.3.0' and CRS_CODES.get(crs) == 'EPSG:4326': + try: + wms_crs = get_curie(crs) + except CRSError: + wms_crs = DEFAULT_WMS_CRS + + if version == '1.3.0' and wms_crs == 'EPSG:4326': bbox = [bbox[1], bbox[0], bbox[3], bbox[2]] bbox2 = ','.join(map(str, bbox)) @@ -104,7 +109,7 @@ def query(self, style=None, bbox=[-180, -90, 180, 90], width=500, 'service': 'WMS', 'request': 'GetMap', 'bbox': bbox2, - crs_param: CRS_CODES.get(crs) or DEFAULT_CRS, + crs_param: wms_crs, 'layers': self.options['layer'], 'styles': self.options.get('style', 'default'), 'width': width, diff --git a/tests/provider/test_wms_facade_provider.py b/tests/provider/test_wms_facade_provider.py index dd829900e..6c99b755e 100644 --- a/tests/provider/test_wms_facade_provider.py +++ b/tests/provider/test_wms_facade_provider.py @@ -31,6 +31,7 @@ import pytest from pygeoapi.provider.wms_facade import WMSFacadeProvider +from pygeoapi.provider.base import ProviderQueryError @pytest.fixture() @@ -61,12 +62,9 @@ def test_crs_query(config): results1 = p.query(crs='http://www.opengis.net/def/crs/EPSG/0/4326') results2 = p.query(crs='http://www.opengis.net/def/crs/EPSG/0/3857') - print(results1) - check_is_PNG(results1) check_is_PNG(results2) - # An invalid crs should default to default crs - results3 = p.query(crs='http://0000') - - check_is_PNG(results3) + # An invalid uri triggers an error + with pytest.raises(ProviderQueryError): + p.query(crs='http://www.opengis.net/def/crs/FOO/0/9999')