Skip to content

Commit 76528fc

Browse files
doublebyte1doublebyte1
andauthored
Improve CRS support in OAMaps (#2358)
* - added functions for parsing wms codes from uris and curies, and for transforming wms codes in uris * - fixed flake8 errors * - Remove list of pre-defined crs and enable parsing uris from safe and unsafe curies * - fixed flake8 errors * - updated unit test * - fixed flake8 error * - updated docs * - switched to accept CRS:84 (for compatibility with WMS) and not CRS84 * - capitalised curie and crs * - capitalised crs, curie - replaced "str" variable name by "crs" * - reordered library import * - refactored condition logic for performance and clarity - store strip results directly on variables, since we do not need the full qualified array. * - renamed get_crs_uri and get_crs_curie for consistency with other function names * - added type hints on function arguments * - fixed flake8 errors --------- Co-authored-by: doublebyte1 <info@doublebyte.net>
1 parent a517343 commit 76528fc

5 files changed

Lines changed: 128 additions & 46 deletions

File tree

docs/source/publishing/ogcapi-maps.rst

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -118,14 +118,14 @@ required. An optional style name can be defined via `options.style`.
118118
119119
.. note::
120120
According to the `Standard <https://docs.ogc.org/is/20-058/20-058.html#_5df53b56-5468-4c9d-acac-6abfddd83ccf>`_, OGC API - Maps
121-
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
122-
a number of different ways, other than the uri format.
123-
124-
- `EPSG:4326`
125-
- `EPSG:3857`
126-
- `4326`
127-
- `3857`,
128-
- `CRS84`
121+
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
122+
also as unsafe CURIEs.
123+
124+
- `http://www.opengis.net/def/crs/EPSG/0/4326`
125+
- `[EPSG:4326]`
126+
- `EPSG:4326` (unsafe)
127+
- `CRS:84` (unsafe, for compatibility with WMS)
128+
- `OGC:CRS84` (unsafe)
129129

130130
If `crs` is not provided, the server will default to the `storage_crs`; in case it does not exist, the default is `CRS84`.
131131
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`.

pygeoapi/api/maps.py

Lines changed: 7 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@
4444
import logging
4545
from typing import Tuple
4646

47-
from pygeoapi.crs import transform_bbox, DEFAULT_CRS
47+
from pygeoapi.crs import transform_bbox, DEFAULT_CRS, get_uri
4848
from pygeoapi.formats import F_JSON, FORMAT_TYPES
4949
from pygeoapi.openapi import get_oas_30_parameters
5050
from pygeoapi.plugin import load_plugin
@@ -64,18 +64,6 @@
6464

6565
DEFAULT_BBOX = [-180, -90, 180, 90] # CRS84
6666

67-
CRS_CODES = {
68-
'4326': 'http://www.opengis.net/def/crs/EPSG/0/4326',
69-
'3857': 'http://www.opengis.net/def/crs/EPSG/0/3857',
70-
'CRS84': 'http://www.opengis.net/def/crs/OGC/1.3/CRS84',
71-
'http://www.opengis.net/def/crs/EPSG/0/4326': 'http://www.opengis.net/def/crs/EPSG/0/4326', # noqa
72-
'http://www.opengis.net/def/crs/EPSG/0/3857': 'http://www.opengis.net/def/crs/EPSG/0/3857', # noqa
73-
'http://www.opengis.net/def/crs/OGC/1.3/CRS84': 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', # noqa
74-
'EPSG:4326': 'http://www.opengis.net/def/crs/EPSG/0/4326',
75-
'EPSG:3857': 'http://www.opengis.net/def/crs/EPSG/0/3857',
76-
'CRS:84': 'http://www.opengis.net/def/crs/OGC/1.3/CRS84',
77-
}
78-
7967

8068
def get_collection_map(api: API, request: APIRequest,
8169
dataset: str, style: str | None = None
@@ -123,11 +111,11 @@ def get_collection_map(api: API, request: APIRequest,
123111
# if it does not exist or is not supported, use CRS84.
124112
try:
125113
if 'crs' not in request.params:
126-
query_args['crs'] = CRS_CODES.get(collection_def.get('storage_crs',
127-
DEFAULT_CRS), DEFAULT_CRS)
114+
query_args['crs'] = collection_def.get('storage_crs',
115+
DEFAULT_CRS)
128116
else:
129-
query_args['crs'] = CRS_CODES.get(request.params['crs'],
130-
DEFAULT_CRS)
117+
query_args['crs'] = get_uri(request.params['crs'])
118+
131119
except KeyError:
132120
query_args['crs'] = DEFAULT_CRS
133121

@@ -138,8 +126,7 @@ def get_collection_map(api: API, request: APIRequest,
138126
if 'bbox-crs' not in request.params:
139127
query_args['bbox-crs'] = DEFAULT_CRS
140128
else:
141-
query_args['bbox-crs'] = CRS_CODES.get(request.params['bbox-crs'],
142-
DEFAULT_CRS)
129+
query_args['bbox-crs'] = get_uri(request.params['bbox-crs'])
143130
except KeyError:
144131
query_args['bbox-crs'] = DEFAULT_CRS
145132

@@ -190,9 +177,9 @@ def get_collection_map(api: API, request: APIRequest,
190177

191178
# the transformer function expects the crs to be in a uri format
192179
if query_args['bbox-crs'] != query_args['crs']:
193-
LOGGER.debug(f'Reprojecting bbox CRS: {query_args["crs"]}')
194180
bbox = transform_bbox(bbox, query_args['bbox-crs'],
195181
query_args['crs'], always_xy=True)
182+
LOGGER.debug(f'Transformed bbox: {bbox}')
196183
query_args['bbox'] = bbox
197184

198185
LOGGER.debug('Processing datetime parameter')

pygeoapi/crs.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
# Authors: Tom Kralidis <tomkralidis@gmail.com>
44
# Just van den Broecke <justb4@gmail.com>
55
#
6+
# Copyright (c) 2026 Joana Simoes
67
# Copyright (c) 2025 Tom Kralidis
78
# Copyright (c) 2025 Just van den Broecke
89
#
@@ -47,6 +48,7 @@
4748
shape as geojson_to_geom,
4849
mapping as geom_to_geojson
4950
)
51+
from urllib.parse import urlparse
5052

5153
LOGGER = logging.getLogger(__name__)
5254

@@ -120,6 +122,96 @@ def get_supported_crs_list(
120122
return supported_crs_list
121123

122124

125+
def get_uri(crs: str) -> str:
126+
"""
127+
Parse a uri from a uri or a curie
128+
129+
:param crs: Uniform resource identifier of the coordinate
130+
reference system. In accordance with
131+
https://docs.ogc.org/pol/09-048r5.html#_naming_rule
132+
Or a safe, or unsafe curie
133+
https://docs.ogc.org/DRAFTS/20-024.html#conventions-curies
134+
135+
:raises `CRSError`: Error raised if no CRS could be identified from the
136+
URI.
137+
138+
:returns: `crs uri` matching the input CRS.
139+
"""
140+
141+
try:
142+
crs = crs.lower()
143+
result = urlparse(crs)
144+
145+
# If it is a uri, check if it is valid
146+
LOGGER.debug(f'Attempt to parse a uri: {crs}')
147+
148+
if result.scheme not in ['http', 'https']:
149+
raise CRSError('Invalid uri scheme')
150+
if result.netloc is None or result.netloc != 'www.opengis.net':
151+
raise CRSError('Invalid uri prefix')
152+
153+
path_el = [p for p in result.path.split('/') if p]
154+
155+
# Check if the path uri contains the relevant fragments
156+
if len(path_el
157+
) != 5 or path_el[0] != 'def' or path_el[1] != 'crs' or path_el[2] not in ['epsg', 'ogc']: # noqa
158+
raise CRSError('Invalid uri fragments')
159+
160+
return crs
161+
162+
except CRSError:
163+
try:
164+
# Parse safe CURIE
165+
curie = crs.strip('[]')
166+
LOGGER.debug(f'Attempt to parse a curie: {curie}')
167+
168+
# We support all EPSG codes and CRS84
169+
if curie not in ['crs:84', 'ogc:crs84']:
170+
[curie_auth, curie_code] = curie.split(':')
171+
if len(curie.split(':')) != 2 or (curie_auth != 'epsg'):
172+
raise CRSError('Unsupported CRS')
173+
174+
return f'http://www.opengis.net/def/crs/EPSG/0/{curie_code}'
175+
else:
176+
return 'http://www.opengis.net/def/crs/OGC/1.3/CRS84'
177+
178+
except CRSError as e:
179+
return e
180+
181+
182+
def get_curie(crs: str) -> str:
183+
"""
184+
Get a WMS compatible CRS CURIE from a uri
185+
186+
:param crs: Uniform resource identifier of the coordinate
187+
reference system. In accordance with
188+
https://docs.ogc.org/pol/09-048r5.html#_naming_rule
189+
190+
:raises `CRSError`: Error raised if no CRS could be identified from the
191+
URI.
192+
193+
:returns: `WMS CURIE` matching the input uri.
194+
"""
195+
196+
try:
197+
if not crs.startswith(("http://", "https://")):
198+
raise CRSError('Not an uri')
199+
200+
crs = crs.lower()
201+
path_el = [p for p in crs.split('/') if p]
202+
203+
# We support all EPSG codes and CRS84
204+
if path_el[4] == 'epsg':
205+
return f'EPSG:{path_el[6]}'
206+
elif path_el[6] != 'crs84':
207+
raise CRSError('Unsupported CRS')
208+
209+
return 'CRS:84'
210+
211+
except CRSError as e:
212+
return e
213+
214+
123215
def get_crs(crs: Union[str, pyproj.CRS]) -> pyproj.CRS:
124216
"""
125217
Get a `pyproj.CRS` instance from a CRS.

pygeoapi/provider/wms_facade.py

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@
3131
import logging
3232
from urllib.parse import urlencode
3333

34+
from pyproj.exceptions import CRSError
35+
36+
from pygeoapi.crs import get_curie
37+
3438
import pyproj
3539
import requests
3640

@@ -43,13 +47,7 @@
4347
'png': 'image/png'
4448
}
4549

46-
CRS_CODES = {
47-
'http://www.opengis.net/def/crs/EPSG/0/4326': 'EPSG:4326',
48-
'http://www.opengis.net/def/crs/EPSG/0/3857': 'EPSG:3857',
49-
'http://www.opengis.net/def/crs/OGC/1.3/CRS84': 'CRS:84' # noqa
50-
}
51-
52-
DEFAULT_CRS = 'CRS:84'
50+
DEFAULT_WMS_CRS = 'CRS:84'
5351

5452

5553
class WMSFacadeProvider(BaseProvider):
@@ -68,9 +66,9 @@ def __init__(self, provider_def):
6866

6967
LOGGER.debug(f'pyproj version: {pyproj.__version__}')
7068

71-
def query(self, style=None, bbox=[-180, -90, 180, 90], width=500,
72-
height=300, crs=DEFAULT_CRS, datetime_=None, transparent=True,
73-
format_='png', **kwargs):
69+
def query(self, style=None, bbox=[-180, -90, 180, 90],
70+
width=500, height=300, crs=DEFAULT_WMS_CRS, datetime_=None,
71+
transparent=True, format_='png', **kwargs):
7472
"""
7573
Generate map
7674
@@ -87,11 +85,18 @@ def query(self, style=None, bbox=[-180, -90, 180, 90], width=500,
8785
:returns: `bytes` of map image
8886
"""
8987

88+
LOGGER.debug(f'bbox: {bbox}')
89+
9090
self._transparent = 'TRUE'
9191

9292
version = self.options.get('version', '1.3.0')
9393

94-
if version == '1.3.0' and CRS_CODES.get(crs) == 'EPSG:4326':
94+
try:
95+
wms_crs = get_curie(crs)
96+
except CRSError:
97+
wms_crs = DEFAULT_WMS_CRS
98+
99+
if version == '1.3.0' and wms_crs == 'EPSG:4326':
95100
bbox = [bbox[1], bbox[0], bbox[3], bbox[2]]
96101
bbox2 = ','.join(map(str, bbox))
97102

@@ -104,7 +109,7 @@ def query(self, style=None, bbox=[-180, -90, 180, 90], width=500,
104109
'service': 'WMS',
105110
'request': 'GetMap',
106111
'bbox': bbox2,
107-
crs_param: CRS_CODES.get(crs) or DEFAULT_CRS,
112+
crs_param: wms_crs,
108113
'layers': self.options['layer'],
109114
'styles': self.options.get('style', 'default'),
110115
'width': width,

tests/provider/test_wms_facade_provider.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
import pytest
3232

3333
from pygeoapi.provider.wms_facade import WMSFacadeProvider
34+
from pygeoapi.provider.base import ProviderQueryError
3435

3536

3637
@pytest.fixture()
@@ -61,12 +62,9 @@ def test_crs_query(config):
6162
results1 = p.query(crs='http://www.opengis.net/def/crs/EPSG/0/4326')
6263
results2 = p.query(crs='http://www.opengis.net/def/crs/EPSG/0/3857')
6364

64-
print(results1)
65-
6665
check_is_PNG(results1)
6766
check_is_PNG(results2)
6867

69-
# An invalid crs should default to default crs
70-
results3 = p.query(crs='http://0000')
71-
72-
check_is_PNG(results3)
68+
# An invalid uri triggers an error
69+
with pytest.raises(ProviderQueryError):
70+
p.query(crs='http://www.opengis.net/def/crs/FOO/0/9999')

0 commit comments

Comments
 (0)