diff --git a/docs/source/configuration.rst b/docs/source/configuration.rst index cb50aba24..6f8e3f3ac 100644 --- a/docs/source/configuration.rst +++ b/docs/source/configuration.rst @@ -48,11 +48,15 @@ For more information related to API design rules (the ``api_rules`` property in encoding: utf-8 # default server encoding language: en-US # default server language locale_dir: /path/to/translations - gzip: false # default server config to gzip/compress responses to requests with gzip in the Accept-Encoding header + ogc_schemas_location: /opt/schemas.opengis.net # optional local copy of https://schemas.opengis.net + gzip: false # default server config to gzip/compress responses to requests with gzip in the Accept-Encoding header cors: true # boolean on whether server should support CORS pretty_print: true # whether JSON responses should be pretty-printed + admin: false # whether to enable the Admin API - limits: # server limits on number of items to return. This property can also be defined at the resource level to override global server settings + # server limits on number of items to return. + # overridable when redefined in resource level configuration + limits: default_items: 50 max_items: 1000 max_distance_x: 25 @@ -60,25 +64,27 @@ For more information related to API design rules (the ``api_rules`` property in max_distance_units: m on_exceed: throttle # throttle or error (default=throttle) - admin: false # whether to enable the Admin API - - # optional configuration to specify a different set of templates for HTML pages. Recommend using absolute paths. Omit this to use the default provided templates - # This property can also be defined at the resource level to override global server settings for specific datasets - templates: # optional configuration to specify a different set of templates for HTML pages. Recommend using absolute paths. Omit this to use the default provided templates + # configuration to specify directory tree for HTML page templates + # omit this to use the default pygeoapi templates + # overridable when redefined in resource level configuration + templates: + # recommended to use absolute paths path: /path/to/jinja2/templates/folder # path to templates folder containing the Jinja2 template HTML files static: /path/to/static/folder # path to static folder containing css, js, images and other static files referenced by the template - map: # leaflet map setup for HTML pages + # leaflet map setup for HTML template rendering + map: url: https://tile.openstreetmap.org/{z}/{x}/{y}.png attribution: '© OpenStreetMap contributors' - ogc_schemas_location: /opt/schemas.opengis.net # local copy of https://schemas.opengis.net - manager: # optional OGC API - Processes asynchronous job management + # optional OGC API - Processes asynchronous job management configuration + manager: name: TinyDB # plugin name (see pygeoapi.plugin for supported process_manager's) connection: /tmp/pygeoapi-process-manager.db # connection info to store jobs (e.g. filepath) output_dir: /tmp/ # temporary file area for storing job results (files) - api_rules: # optional API design rules to which pygeoapi should adhere + # optional API design rules to which pygeoapi should adhere + api_rules: api_version: 1.2.3 # omit to use pygeoapi's software version strict_slashes: true # trailing slashes will not be allowed and result in a 404 url_prefix: 'v{api_major}' # adds a /v1 prefix to all URL paths @@ -95,8 +101,8 @@ The ``logging`` section provides directives for logging messages which are usefu logging: level: ERROR # the logging level (see https://docs.python.org/3/library/logging.html#logging-levels) logfile: /path/to/pygeoapi.log # the full file path to the logfile - logformat: # example for milliseconds:'[%(asctime)s.%(msecs)03d] {%(pathname)s:%(lineno)d} %(levelname)s - %(message)s' - dateformat: # example for milliseconds:'%Y-%m-%dT%H:%M:%S' + logformat: # example for milliseconds:'[%(asctime)s.%(msecs)03d] {%(pathname)s:%(lineno)d} %(levelname)s - %(message)s' + dateformat: # example for milliseconds:'%Y-%m-%dT%H:%M:%S' .. note:: If ``level`` is defined and ``logfile`` is undefined, logging messages are output to the server's ``stdout``. @@ -112,11 +118,12 @@ The ``rotation`` supports rotation of disk log files. The ``logfile`` file is op logging: logfile: /path/to/pygeoapi.log # the full file path to the logfile rotation: - mode: # [time|size] - when: # [s|m|h|d|w0-w6|midnight] + mode: # [time|size] + when: # [s|m|h|d|w0-w6|midnight] interval: max_bytes: backup_count: + .. note:: Rotation block is not mandatory and defined only when needed. The ``mode`` can be defined by size or time. For RotatingFileHandler_ set mode size and parameters max_bytes and backup_count. @@ -198,13 +205,13 @@ default. keywords: # list of related keywords - observations - monitoring - linked-data: # linked data configuration (see Linked Data section) + linked-data: # linked data configuration (see Linked Data section) context: - datetime: https://schema.org/DateTime - vocab: https://example.com/vocab# stn_id: "vocab:stn_id" value: "vocab:value" - links: # list of 1..n related links + links: # list of 1..n related links - type: text/csv # MIME type rel: canonical # link relations per https://www.iana.org/assignments/link-relations/link-relations.xhtml title: data # title @@ -219,38 +226,37 @@ default. end: 2007-10-30T08:57:29Z # end datetime in RFC3339 trs: http://www.opengis.net/def/uom/ISO-8601/0/Gregorian # TRS providers: # list of 1..n required connections information - # provider name - # see pygeoapi.plugin for supported providers - # for custom built plugins, use the import path (e.g. mypackage.provider.MyProvider) - # see Plugins section for more information - - type: feature # underlying data geospatial type: (allowed values are: feature, coverage, record, tile, edr) - default: true # optional: if not specified, the first provider definition is considered the default - name: CSV + - type: feature # underlying data geospatial type. Allowed values are: feature, coverage, record, tile, edr + name: CSV # required: plugin name or import path. See Plugins section for more information. data: tests/data/obs.csv # required: the data filesystem path or URL, depending on plugin setup id_field: id # required for vector data, the field corresponding to the ID - uri_field: uri # optional field corresponding to the Uniform Resource Identifier (see Linked Data section) - time_field: datetimestamp # optional field corresponding to the temporal property of the dataset - title_field: foo # optional field of which property to display as title/label on HTML pages - properties: # optional: only return the following properties, in order + + # optional fields + uri_field: uri # field corresponding to the Uniform Resource Identifier (see Linked Data section) + time_field: datetimestamp # field corresponding to the temporal property of the dataset + title_field: foo # field of which property to display as title/label on HTML pages + default: true # if not specified, the first provider definition is considered the default + properties: # if specified, return only the following properties, in order - stn_id - value + format: # default format + name: GeoJSON # required: format name + mimetype: application/json # required: format mimetype + options: # optional options to pass to provider (i.e. GDAL creation) + option_name: option_value + include_extra_query_parameters: false # include extra query parameters that are not part of the collection properties (default: false) # editable transactions: DO NOT ACTIVATE unless you have setup access control beyond pygeoapi editable: true # optional: if backend is writable, default is false # coordinate reference systems (CRS) section is optional # default CRSs are http://www.opengis.net/def/crs/OGC/1.3/CRS84 (coordinates without height) # and http://www.opengis.net/def/crs/OGC/1.3/CRS84h (coordinates with ellipsoidal height) - crs: # supported coordinate reference systems (CRS) for 'crs' and 'bbox-crs' query parameters + crs: # supported coordinate reference systems (CRS) for 'crs' and 'bbox-crs' query parameters - 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 - storage_crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 # optional CRS in which data is stored, default: as 'crs' field - storage_crs_coordinate_epoch: : 2017.23 # optional, if storage_crs is a dynamic coordinate reference system - format: # optional default format - name: GeoJSON # required: format name - mimetype: application/json # required: format mimetype - options: # optional options to pass to provider (i.e. GDAL creation) - option_name: option_value - include_extra_query_parameters: false # include extra query parameters that are not part of the collection properties (default: false) + storage_crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 # optional CRS in which data is stored, default: as 'crs' field + storage_crs_coordinate_epoch: 2017.23 # optional, if storage_crs is a dynamic coordinate reference system + always_xy: false # optional should CRS respect axis ordering hello-world: # name of process type: process # REQUIRED (collection, process, or stac-collection) diff --git a/docs/source/crs.rst b/docs/source/crs.rst index 5c299b854..72b3c06ba 100644 --- a/docs/source/crs.rst +++ b/docs/source/crs.rst @@ -3,42 +3,98 @@ CRS support =========== -pygeoapi supports the complete specification: `OGC API - Features - Part 2: Coordinate Reference Systems by Reference corrigendum`_. -The specified CRS-related capabilities are available for all Feature data Providers. +pygeoapi supports multiple Coordinate Reference Systems (CRS). +This enables the import and export of any data according to dedicated projections. +A "projection" is specified with a CRS identifier. +In particular CRS support allows for the following: + +* to specify the CRS in which the data is stored, in pygeoapi the `storage_crs` config option +* to specify the list of valid CRS representations, in pygeoapi the `crs` config option +* to publish these in the collection metadata +* the `bbox-crs=` query parameter to indicate that the `bbox=` parameter is encoded in that CRS +* the `crs=` query parameter for a collection or collection item +* the HTTP response header `Content-Crs` denotes the CRS of the Feature(s) in the data returned + +Although GeoJSON mandates WGS84 in longitude, latitude order, the client and server may still agree on other CRSs. + + +Background +---------- + +pygeoapi implements the `OGC API - Features - Part 2: Coordinate Reference Systems by Reference`_ specification. + +Under the hood, pygeoapi uses the `pyproj`_ Python package. + +.. note:: + + For more information on implementing CRS on custom plugins, see `Implementation`_. + +CRS support exists for the following OGC APIs: + +.. csv-table:: + :header: OGC API, bbox-crs, filter-crs, crs + :align: left + + :ref:`OGC API - Features`,✅,✅,✅ + :ref:`OGC API - Maps`,✅,❌,❌ + :ref:`OGC API - Coverages`,✅,❌,❌ Configuration ------------- -For details visit the :ref:`configuration` section for Feature Providers. At this moment only the 'URI' CRS notation format is supported. +The CRS of a collection is defined in the provider block of your resource. +The configuration controls how the `crs` related query parameters behave. + + +* ``crs`` - list of CRSs supported +* ``storage_crs`` - CRS in which the data is stored (must be in `crs` list) +* ``storage_crs_coordinate_epoch`` - epoch of `storage_crs` for a dynamic coordinate reference system +* ``always_xy`` - CRS should ignore authority on axis order, disobeying `ISO-19111`_ (default: false) + +.. note:: + bbox-crs and filter-crs are used to convert the request geometry to the configured ``storage_crs``. + An error will be returned for any interaction with CRS not included in the configured ``crs`` list. +The per-Provider configuration fields are all optional, +with the following as default configuration: -* `crs` - list of CRSs supported -* `storage_crs` - CRS in which the data is stored (must be in `crs` list) -* `storage_crs_coordinate_epoch` - epoch of `storage_crs` for a dynamic coordinate reference system +.. code-block:: yaml + crs: + - http://www.opengis.net/def/crs/OGC/1.3/CRS84 + - http://www.opengis.net/def/crs/OGC/1.3/CRS84h + storage_crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 -These per-Provider configuration fields are all optional. Default for CRS-values is `http://www.opengis.net/def/crs/OGC/1.3/CRS84`, so "WGS84" with lon/lat axis ordering. -If the storage CRS of the spatial feature collection is a dynamic coordinate reference system, -`storage_crs_coordinate_epoch` configures the coordinate epoch of the coordinates. +.. note:: + Configuration is done with URI formats like http://www.opengis.net/def/crs/OGC/1.3/CRS84. + Both `URI` and `URN` CRS notation format are supported. + The `EPSG:` format like EPSG:4326 is outside the scope of the OGC standard. -There is also support for CRSs that support height like `http://www.opengis.net/def/crs/OGC/1.3/CRS84h`. In that case -bbox parameters (see below) may contain 6 coordinates. Metadata -------- -The conformance class `http://www.opengis.net/spec/ogcapi-features-2/1.0/conf/crs` is present as a `conformsTo` field -in the root landing page response. +The conformance class http://www.opengis.net/spec/ogcapi-features-2/1.0/conf/crs is +present as a `conformsTo` field in the root landing page response. The configured CRSs, or their defaults, `crs` and `storageCrs` and optionally `storageCrsCoordinateEpoch` will be present in the "Describe Collection" response. +.. note:: + If the storage CRS of the spatial feature collection is a dynamic coordinate reference system, + `storage_crs_coordinate_epoch` configures the coordinate epoch of the coordinates. + +.. note:: + There is also support for CRSs that support height like `http://www.opengis.net/def/crs/OGC/1.3/CRS84h`. In that case + bbox parameters (see below) may contain 6 coordinates. + Parameters ---------- The `items` query supports the following parameters: -* `crs` - the CRS in which Features coordinates should be returned, also for the 'get single item' request -* `bbox-crs` - the CRS of the `bbox` parameter (only for Providers that support the `bbox` parameter) +* ``crs`` - the CRS in which Features coordinates should be returned, also for the 'get single item' request +* ``bbox-crs`` - the CRS of the `bbox` parameter (for Providers that support the `bbox` parameter) +* ``filter-crs`` - the CRS of the CQL filter expression (for Providers that support `CQL` filters) If any or both of these parameters are specified, their CRS-value should be from the advertised CRS-list in the Collection metadata (see above). @@ -52,21 +108,29 @@ Note that the values of these parameters may need to be URL-encoded. Implementation -------------- -CRS and BBOX CRS support is implemented for all Feature Providers. Some details may help understanding (performance) implications. +CRS and BBOX CRS support is implemented for all Feature Providers. +Some details may help understanding (performance) implications. -BBOX CRS Parameter +bbox-crs Parameter ^^^^^^^^^^^^^^^^^^ -The `bbox-crs` parameter is handled at the common level of pygeoapi, thus transparent for Feature Providers. -Obviously the Provider should support `bbox`. -A transformation of the `bbox` parameter is performed +The ``bbox-crs`` parameter is handled at the common level of pygeoapi. +A transformation of the request `bbox` parameter is performed according to the `storage_crs` configuration. Then the (transformed) `bbox` is passed with the other query parameters to the Provider instance. -CRS Parameter +filter-crs Parameter +^^^^^^^^^^^^^^^^^^^^ + +The ``filter-crs`` parameter is handled at the common level of pygeoapi. +A transformation of the request `CQL` filter is performed +according to the `storage_crs` configuration. Then the (transformed) `filter` is passed with the +other query parameters to the Provider instance. + +crs Parameter ^^^^^^^^^^^^^ -When the value of the `crs` parameter differs from the Provider data Storage CRS, the response Feature coordinates +When the value of the ``crs`` parameter differs from the Provider data Storage CRS, the response Feature coordinates need to be transformed to that CRS. As some Feature Providers like PostgreSQL or OGR may support native coordinate transformation, pygeoapi delegates transformation to those Providers, passing the `crs` with the other query parameters. @@ -245,4 +309,6 @@ Or you may specify both `crs` and `bbox-crs` and thus `bbox` in that CRS `http:/ . . -.. _`OGC API - Features - Part 2: Coordinate Reference Systems by Reference corrigendum`: https://docs.opengeospatial.org/is/18-058r1/18-058r1.html +.. _`ISO-19111`: https://docs.ogc.org/as/18-005r5/18-005r5.html +.. _`OGC API - Features - Part 2: Coordinate Reference Systems by Reference`: https://docs.ogc.org/is/18-058r1/18-058r1.html +.. _`pyproj`: https://pyproj4.github.io/pyproj \ No newline at end of file diff --git a/docs/source/plugins.rst b/docs/source/plugins.rst index ac17ee951..dcaa48435 100644 --- a/docs/source/plugins.rst +++ b/docs/source/plugins.rst @@ -7,6 +7,9 @@ In this section we will explain how pygeoapi provides plugin architecture for da Plugin development requires knowledge of how to program in Python as well as Python's package/module system. +.. seealso:: + :ref:`publishing` for configuration of default plugins. + Overview -------- diff --git a/docs/source/publishing/ogcapi-coverages.rst b/docs/source/publishing/ogcapi-coverages.rst index 8c486ab4e..f277f7c5b 100644 --- a/docs/source/publishing/ogcapi-coverages.rst +++ b/docs/source/publishing/ogcapi-coverages.rst @@ -77,7 +77,7 @@ The `Xarray`_ provider plugin reads and extracts `NetCDF`_ and `Zarr`_ data. time_field: time # optionally specify the coordinate reference system of your dataset # else pygeoapi assumes it is WGS84 (EPSG:4326). - storage_crs: 4326 + storage_crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 format: name: netcdf mimetype: application/x-netcdf diff --git a/docs/source/publishing/ogcapi-edr.rst b/docs/source/publishing/ogcapi-edr.rst index 33da90507..edd205d17 100644 --- a/docs/source/publishing/ogcapi-edr.rst +++ b/docs/source/publishing/ogcapi-edr.rst @@ -59,7 +59,7 @@ The `xarray-edr`_ provider plugin reads and extracts `NetCDF`_ and `Zarr`_ data time_field: time # optionally specify the coordinate reference system of your dataset # else pygeoapi assumes it is WGS84 (EPSG:4326). - storage_crs: 4326 + storage_crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 format: name: netcdf mimetype: application/x-netcdf @@ -94,11 +94,6 @@ The `xarray-edr`_ provider plugin reads and extracts `NetCDF`_ and `Zarr`_ data S3 URL. Any parameters required to open the dataset using fsspec can be added to the config file under `options` and `s3`, as shown above. -.. note:: - When providing a `storage_crs` value in the EDR configuration, specify the - coordinate reference system using any valid input for - `pyproj.CRS.from_user_input`_. - SensorThingsEDR ^^^^^^^^^^^^^^^ @@ -147,5 +142,4 @@ Data access examples .. _`xarray`: https://docs.xarray.dev/en/stable/ .. _`NetCDF`: https://en.wikipedia.org/wiki/NetCDF .. _`Zarr`: https://zarr.readthedocs.io/en/stable -.. _`pyproj.CRS.from_user_input`: https://pyproj4.github.io/pyproj/stable/api/crs/coordinate_system.html#pyproj.crs.CoordinateSystem.from_user_input .. _`OGC Environmental Data Retrieval (EDR) (API)`: https://ogcapi.ogc.org/edr diff --git a/docs/source/publishing/ogcapi-features.rst b/docs/source/publishing/ogcapi-features.rst index 04969bcce..3a1c2e9e5 100644 --- a/docs/source/publishing/ogcapi-features.rst +++ b/docs/source/publishing/ogcapi-features.rst @@ -37,19 +37,13 @@ parameters. `TinyDB`_,✅/✅,results/hits,✅,✅,✅,✅,✅,❌,✅,✅ .. note:: - - * All Providers that support `bbox` also support the `bbox-crs` parameter. `bbox-crs` is handled within pygeoapi core. - * All Providers support the `crs` parameter to reproject (transform) response data. Some, like PostgreSQL and OGR, perform this natively. + For more information on CRS transformations, see :ref:`crs`. Connection examples ------------------- Below are specific connection examples based on supported providers. -To support `crs` on queries, one needs to configure both a list of supported CRSs, and a 'Storage CRS'. -See also :ref:`crs` and :ref:`configuration`. When no CRS information is configured the -default CRS/'Storage CRS' value http://www.opengis.net/def/crs/OGC/1.3/CRS84 is assumed. -That is: WGS84 with lon,lat axis-ordering as in standard GeoJSON. CSV ^^^ diff --git a/pygeoapi/api/__init__.py b/pygeoapi/api/__init__.py index 9dfa83a9e..34ddcd3ec 100644 --- a/pygeoapi/api/__init__.py +++ b/pygeoapi/api/__init__.py @@ -49,13 +49,14 @@ import logging import re import sys -from typing import Any, Tuple, Union, Optional, Self +from typing import Any, Tuple, Union, Self from babel import Locale from dateutil.parser import parse as dateparse import pytz from pygeoapi import __version__, l10n +from pygeoapi.crs import DEFAULT_STORAGE_CRS, get_supported_crs_list from pygeoapi.linked_data import jsonldify, jsonldify_collection from pygeoapi.log import setup_logger from pygeoapi.plugin import load_plugin @@ -64,11 +65,10 @@ ProviderConnectionError, ProviderGenericError, ProviderTypeError) from pygeoapi.util import ( - CrsTransformSpec, TEMPLATESDIR, UrlPrefetcher, dategetter, + TEMPLATESDIR, UrlPrefetcher, dategetter, filter_dict_by_key_value, filter_providers_by_type, get_api_rules, get_base_url, get_provider_by_type, get_provider_default, get_typed_value, - get_crs_from_uri, get_supported_crs_list, render_j2_template, to_json, - get_choice_from_headers, get_from_headers + render_j2_template, to_json, get_choice_from_headers, get_from_headers ) LOGGER = logging.getLogger(__name__) @@ -115,14 +115,6 @@ OGC_RELTYPES_BASE = 'http://www.opengis.net/def/rel/ogc/1.0' -DEFAULT_CRS_LIST = [ - 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', - 'http://www.opengis.net/def/crs/OGC/1.3/CRS84h', -] - -DEFAULT_CRS = 'http://www.opengis.net/def/crs/OGC/1.3/CRS84' -DEFAULT_STORAGE_CRS = DEFAULT_CRS - def all_apis() -> dict: """ @@ -639,99 +631,6 @@ def get_dataset_templates(self, dataset: str) -> dict: return templates or self.tpl_config['server']['templates'] - @staticmethod - def _create_crs_transform_spec( - config: dict, - query_crs_uri: Optional[str] = None, - ) -> Union[None, CrsTransformSpec]: - """Create a `CrsTransformSpec` instance based on provider config and - *crs* query parameter. - - :param config: Provider config dictionary. - :type config: dict - :param query_crs_uri: Uniform resource identifier of the coordinate - reference system (CRS) specified in query parameter (if specified). - :type query_crs_uri: str, optional - - :raises ValueError: Error raised if the CRS specified in the query - parameter is not in the list of supported CRSs of the provider. - :raises `CRSError`: Error raised if no CRS could be identified from the - query *crs* parameter (URI). - - :returns: `CrsTransformSpec` instance if the CRS specified in query - parameter differs from the storage CRS, else `None`. - :rtype: Union[None, CrsTransformSpec] - """ - # Get storage/default CRS for Collection. - storage_crs_uri = config.get('storage_crs', DEFAULT_STORAGE_CRS) - - if not query_crs_uri: - if storage_crs_uri in DEFAULT_CRS_LIST: - # Could be that storageCrs is - # http://www.opengis.net/def/crs/OGC/1.3/CRS84h - query_crs_uri = storage_crs_uri - else: - query_crs_uri = DEFAULT_CRS - LOGGER.debug(f'no crs parameter, using default: {query_crs_uri}') - - supported_crs_list = get_supported_crs_list(config, DEFAULT_CRS_LIST) - # Check that the crs specified by the query parameter is supported. - if query_crs_uri not in supported_crs_list: - raise ValueError( - f'CRS {query_crs_uri!r} not supported for this ' - 'collection. List of supported CRSs: ' - f'{", ".join(supported_crs_list)}.' - ) - crs_out = get_crs_from_uri(query_crs_uri) - - storage_crs = get_crs_from_uri(storage_crs_uri) - # Check if the crs specified in query parameter differs from the - # storage crs. - if str(storage_crs) != str(crs_out): - LOGGER.debug( - f'CRS transformation: {storage_crs} -> {crs_out}' - ) - return CrsTransformSpec( - source_crs_uri=storage_crs_uri, - source_crs_wkt=storage_crs.to_wkt(), - target_crs_uri=query_crs_uri, - target_crs_wkt=crs_out.to_wkt(), - ) - else: - LOGGER.debug('No CRS transformation') - return None - - @staticmethod - def _set_content_crs_header( - headers: dict, - config: dict, - query_crs_uri: Optional[str] = None, - ): - """Set the *Content-Crs* header in responses from providers of Feature - type. - - :param headers: Response headers dictionary. - :type headers: dict - :param config: Provider config dictionary. - :type config: dict - :param query_crs_uri: Uniform resource identifier of the coordinate - reference system specified in query parameter (if specified). - :type query_crs_uri: str, optional - """ - if query_crs_uri: - content_crs_uri = query_crs_uri - else: - # If empty use default CRS - storage_crs_uri = config.get('storage_crs', DEFAULT_STORAGE_CRS) - if storage_crs_uri in DEFAULT_CRS_LIST: - # Could be that storageCrs is one of the defaults like - # http://www.opengis.net/def/crs/OGC/1.3/CRS84h - content_crs_uri = storage_crs_uri - else: - content_crs_uri = DEFAULT_CRS - - headers['Content-Crs'] = f'<{content_crs_uri}>' - @jsonldify def landing_page(api: API, @@ -1145,7 +1044,7 @@ def describe_collections(api: API, request: APIRequest, # OAPIF Part 2 - list supported CRSs and StorageCRS if collection_data_type in ['edr', 'feature']: - collection['crs'] = get_supported_crs_list(collection_data, DEFAULT_CRS_LIST) # noqa + collection['crs'] = get_supported_crs_list(collection_data) collection['storageCrs'] = collection_data.get('storage_crs', DEFAULT_STORAGE_CRS) # noqa if 'storage_crs_coordinate_epoch' in collection_data: collection['storageCrsCoordinateEpoch'] = collection_data.get('storage_crs_coordinate_epoch') # noqa diff --git a/pygeoapi/api/itemtypes.py b/pygeoapi/api/itemtypes.py index 3207b6f1a..a1e1bda16 100644 --- a/pygeoapi/api/itemtypes.py +++ b/pygeoapi/api/itemtypes.py @@ -40,7 +40,7 @@ from datetime import datetime from http import HTTPStatus import logging -from typing import Any, Tuple, Union, Optional +from typing import Any, Tuple, Union import urllib.parse from pygeofilter.parsers.ecql import parse as parse_ecql_text @@ -49,17 +49,19 @@ from pygeoapi import l10n from pygeoapi.api import evaluate_limit +from pygeoapi.crs import (DEFAULT_CRS, DEFAULT_STORAGE_CRS, + create_crs_transform_spec, get_supported_crs_list, + modify_pygeofilter, transform_bbox, + set_content_crs_header) from pygeoapi.formatter.base import FormatterSerializationError from pygeoapi.linked_data import geojson2jsonld from pygeoapi.plugin import load_plugin, PLUGINS from pygeoapi.provider.base import ( ProviderGenericError, ProviderTypeError, SchemaType) -from pygeoapi.util import (CrsTransformSpec, filter_providers_by_type, - filter_dict_by_key_value, get_crs_from_uri, - get_provider_by_type, get_supported_crs_list, - modify_pygeofilter, render_j2_template, str2bool, - to_json, transform_bbox) +from pygeoapi.util import (filter_providers_by_type, to_json, + filter_dict_by_key_value, str2bool, + get_provider_by_type, render_j2_template) from . import ( APIRequest, API, SYSTEM_LOCALE, F_JSON, FORMAT_TYPES, F_HTML, F_JSONLD, @@ -70,13 +72,6 @@ OGC_RELTYPES_BASE = 'http://www.opengis.net/def/rel/ogc/1.0' -DEFAULT_CRS_LIST = [ - 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', - 'http://www.opengis.net/def/crs/OGC/1.3/CRS84h', -] - -DEFAULT_CRS = 'http://www.opengis.net/def/crs/OGC/1.3/CRS84' -DEFAULT_STORAGE_CRS = DEFAULT_CRS CONFORMANCE_CLASSES_FEATURES = [ 'http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/core', @@ -365,7 +360,7 @@ def get_collection_items( query_crs_uri = request.params.get('crs') try: crs_transform_spec = create_crs_transform_spec( - provider_def, query_crs_uri, + provider_def, query_crs_uri ) except (ValueError, CRSError) as err: msg = str(err) @@ -390,7 +385,7 @@ def get_collection_items( HTTPStatus.BAD_REQUEST, headers, request.format, 'NoApplicableCode', msg) - supported_crs_list = get_supported_crs_list(provider_def, DEFAULT_CRS_LIST) # noqa + supported_crs_list = get_supported_crs_list(provider_def) if bbox_crs not in supported_crs_list: msg = f'bbox-crs {bbox_crs} not supported for this collection' return api.get_exception( @@ -398,14 +393,14 @@ def get_collection_items( 'NoApplicableCode', msg) elif len(bbox) > 0: # bbox but no bbox-crs param: assume bbox is in default CRS - bbox_crs = DEFAULT_CRS + bbox_crs = DEFAULT_STORAGE_CRS - # Transform bbox to storageCrs - # when bbox-crs different from storageCrs. + # Transform bbox to storage_crs + # when bbox-crs different from storage_crs. if len(bbox) > 0: try: # Get a pyproj CRS instance for the Collection's Storage CRS - storage_crs = provider_def.get('storage_crs', DEFAULT_STORAGE_CRS) # noqa + storage_crs = provider_def.get('storage_crs', DEFAULT_STORAGE_CRS) # Do the (optional) Transform to the Storage CRS bbox = transform_bbox(bbox, bbox_crs, storage_crs) @@ -872,7 +867,7 @@ def get_collection_item(api: API, request: APIRequest, query_crs_uri = request.params.get('crs') try: crs_transform_spec = create_crs_transform_spec( - provider_def, query_crs_uri, + provider_def, query_crs_uri ) except (ValueError, CRSError) as err: msg = str(err) @@ -988,100 +983,6 @@ def get_collection_item(api: API, request: APIRequest, return headers, HTTPStatus.OK, to_json(content, api.pretty_print) -def create_crs_transform_spec( - config: dict, - query_crs_uri: Optional[str] = None) -> Union[None, CrsTransformSpec]: # noqa - """ - Create a `CrsTransformSpec` instance based on provider config and - *crs* query parameter. - - :param config: Provider config dictionary. - :type config: dict - :param query_crs_uri: Uniform resource identifier of the coordinate - reference system (CRS) specified in query parameter (if specified). - :type query_crs_uri: str, optional - - :raises ValueError: Error raised if the CRS specified in the query - parameter is not in the list of supported CRSs of the provider. - :raises `CRSError`: Error raised if no CRS could be identified from the - query *crs* parameter (URI). - - :returns: `CrsTransformSpec` instance if the CRS specified in query - parameter differs from the storage CRS, else `None`. - :rtype: Union[None, CrsTransformSpec] - """ - - # Get storage/default CRS for Collection. - storage_crs_uri = config.get('storage_crs', DEFAULT_STORAGE_CRS) - - if not query_crs_uri: - if storage_crs_uri in DEFAULT_CRS_LIST: - # Could be that storageCrs is - # http://www.opengis.net/def/crs/OGC/1.3/CRS84h - query_crs_uri = storage_crs_uri - else: - query_crs_uri = DEFAULT_CRS - LOGGER.debug(f'no crs parameter, using default: {query_crs_uri}') - - supported_crs_list = get_supported_crs_list(config, DEFAULT_CRS_LIST) - # Check that the crs specified by the query parameter is supported. - if query_crs_uri not in supported_crs_list: - raise ValueError( - f'CRS {query_crs_uri!r} not supported for this ' - 'collection. List of supported CRSs: ' - f'{", ".join(supported_crs_list)}.' - ) - crs_out = get_crs_from_uri(query_crs_uri) - - storage_crs = get_crs_from_uri(storage_crs_uri) - # Check if the crs specified in query parameter differs from the - # storage crs. - if str(storage_crs) != str(crs_out): - LOGGER.debug( - f'CRS transformation: {storage_crs} -> {crs_out}' - ) - return CrsTransformSpec( - source_crs_uri=storage_crs_uri, - source_crs_wkt=storage_crs.to_wkt(), - target_crs_uri=query_crs_uri, - target_crs_wkt=crs_out.to_wkt(), - ) - else: - LOGGER.debug('No CRS transformation') - return None - - -def set_content_crs_header( - headers: dict, config: dict, query_crs_uri: Optional[str] = None): - """ - Set the *Content-Crs* header in responses from providers of Feature type. - - :param headers: Response headers dictionary. - :type headers: dict - :param config: Provider config dictionary. - :type config: dict - :param query_crs_uri: Uniform resource identifier of the coordinate - reference system specified in query parameter (if specified). - :type query_crs_uri: str, optional - - :returns: None - """ - - if query_crs_uri: - content_crs_uri = query_crs_uri - else: - # If empty use default CRS - storage_crs_uri = config.get('storage_crs', DEFAULT_STORAGE_CRS) - if storage_crs_uri in DEFAULT_CRS_LIST: - # Could be that storageCrs is one of the defaults like - # http://www.opengis.net/def/crs/OGC/1.3/CRS84h - content_crs_uri = storage_crs_uri - else: - content_crs_uri = DEFAULT_CRS - - headers['Content-Crs'] = f'<{content_crs_uri}>' - - def get_oas_30(cfg: dict, locale: str) -> tuple[list[dict[str, str]], dict[str, dict]]: # noqa """ Get OpenAPI fragments diff --git a/pygeoapi/api/maps.py b/pygeoapi/api/maps.py index 3feae5533..d8df6d354 100644 --- a/pygeoapi/api/maps.py +++ b/pygeoapi/api/maps.py @@ -43,12 +43,13 @@ import logging from typing import Tuple +from pygeoapi.crs import transform_bbox 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, transform_bbox + filter_dict_by_key_value ) from . import APIRequest, API, validate_datetime @@ -76,9 +77,7 @@ def get_collection_map(api: API, request: APIRequest, :returns: tuple of headers, status code, content """ - query_args = { - 'crs': 'CRS84' - } + query_args = {} format_ = request.format or 'png' headers = request.get_response_headers(**api.api_headers) diff --git a/pygeoapi/crs.py b/pygeoapi/crs.py new file mode 100644 index 000000000..efeab27fa --- /dev/null +++ b/pygeoapi/crs.py @@ -0,0 +1,518 @@ +# ================================================================= +# +# Authors: Tom Kralidis +# Just van den Broecke +# +# Copyright (c) 2025 Tom Kralidis +# Copyright (c) 2025 Just van den Broecke +# +# 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. +# +# ================================================================= + +"""Generic CRS functions used in the code""" + +from copy import deepcopy +import functools +from functools import partial +from dataclasses import dataclass +import logging +from typing import Union, Optional, Callable + +import pyproj +import pygeofilter.ast +import pygeofilter.values +from pyproj.exceptions import CRSError +from shapely import ops, Geometry +from shapely.geometry import ( + shape as geojson_to_geom, + mapping as geom_to_geojson +) + +LOGGER = logging.getLogger(__name__) + +DEFAULT_CRS_LIST = [ + 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', + 'http://www.opengis.net/def/crs/OGC/1.3/CRS84h', +] + +DEFAULT_CRS = 'http://www.opengis.net/def/crs/OGC/1.3/CRS84' +DEFAULT_STORAGE_CRS = DEFAULT_CRS + + +@dataclass +class CrsTransformSpec: + source_crs_uri: str + source_crs_wkt: str + target_crs_uri: str + target_crs_wkt: str + always_xy: bool = False + + +def get_srid(crs: Union[str, pyproj.CRS]) -> Union[int, None]: + """ + Helper function to attempt to exctract an ESPG SRID from + a `pyproj.CRS` object. + + :param crs: `pyproj.CRS` object + + :returns: int of EPSG SRID, if found + """ + if isinstance(crs, str): + crs = get_crs(crs) + + if crs.to_epsg(): + return crs.to_epsg() + + try: + return pyproj.CRS(crs.to_proj4()).to_epsg() + except KeyError: + LOGGER.debug('Unable to extract SRID from proj4 string') + + +def get_supported_crs_list( + provider_def: dict, default_crs_list: list = DEFAULT_CRS_LIST +) -> list: + """ + Helper function to get a complete list of supported CRSs + from a (Provider) config dict. Result should always include + a default CRS according to OAPIF Part 2 OGC Standard. + This will be the default when no CRS list in config or + added when (partially) missing in config. + + Author: @justb4 + + :param provider_def: dictionary with or without a list of CRSs + :param default_crs_list: default CRS alternatives, first is default + + :returns: list of supported CRSs + """ + supported_crs_list = provider_def.get('crs', list()) + contains_default = False + for uri in supported_crs_list: + if uri in default_crs_list: + contains_default = True + break + + # A default CRS is missing: add the first which is the default + if not contains_default: + supported_crs_list.append(default_crs_list[0]) + + return supported_crs_list + + +def get_crs(crs: Union[str, pyproj.CRS]) -> pyproj.CRS: + """ + Get a `pyproj.CRS` instance from a CRS. + Author: @MTachon + + :param crs: Uniform resource identifier of the coordinate + reference system. In accordance with + https://docs.ogc.org/pol/09-048r5.html#_naming_rule + URIs can take either the form of a URL or a URN + or `pyproj.CRS` object + :raises `CRSError`: Error raised if no CRS could be identified from the + URI. + + :returns: `pyproj.CRS` instance matching the input URI. + """ + + if isinstance(crs, pyproj.CRS): + return crs + + # normalize the input `uri` to a URL first + uri = str(crs) + url = uri.replace( + 'urn:ogc:def:crs', 'http://www.opengis.net/def/crs' + ).replace(':', '/') + try: + authority, code = url.rsplit('/', maxsplit=3)[1::2] + crs = pyproj.CRS.from_authority(authority, code) + except ValueError: + msg = ( + f'CRS could not be identified from URI {uri!r}. CRS URIs must ' + 'follow one of two formats: ' + '"http://www.opengis.net/def/crs/{authority}/{version}/{code}" or ' + '"urn:ogc:def:crs:{authority}:{version}:{code}" ' + '(see https://docs.opengeospatial.org/is/18-058r1/18-058r1.html#crs-overview).' # noqa + ) + LOGGER.error(msg) + raise CRSError(msg) + except CRSError: + msg = f"CRS could not be identified from URI {uri!r}" + LOGGER.error(msg) + raise CRSError(msg) + + return crs + + +def get_transform_from_spec( + crs_transform_spec: CrsTransformSpec +) -> Callable[[Geometry], Geometry]: + """ Get transformation function from a `CrsTransformSpec` instance. + + :param crs_transform_spec: `CrsTransformSpec` + + :returns: `callable` Function to transform the coordinates of a `Geometry`. + """ + if crs_transform_spec is None: + LOGGER.warning('No transform spec found') + return None + + return get_transform_from_crs( + pyproj.CRS.from_wkt(crs_transform_spec.source_crs_wkt), + pyproj.CRS.from_wkt(crs_transform_spec.target_crs_wkt), + crs_transform_spec.always_xy + ) + + +def get_transform_from_crs( + crs_in: pyproj.CRS, crs_out: pyproj.CRS, always_xy: bool = False +) -> Callable[[Geometry], Geometry]: + """ Get transformation function from two `pyproj.CRS` instances. + + Get function to transform the coordinates of a Shapely geometrical object + from one coordinate reference system to another. + + :param crs_in: `pyproj.CRS` Input Coordinate Reference System + :param crs_out: `pyproj.CRS` Output Coordinate Reference System + :param always_xy: 'bool' should axis order be forced to x,y (lon, lat) + even if CRSdeclares y,x (lat,lon) + + :returns: `callable` Function to transform the coordinates of a `Geometry`. + """ + crs_transform = pyproj.Transformer.from_crs( + crs_in, crs_out, always_xy=always_xy, + ).transform + return partial(ops.transform, crs_transform) + + +def crs_transform(func): + """Decorator that transforms the geometry's/geometries' coordinates of a + Feature/FeatureCollection. + + This function can be used to decorate another function which returns either + a Feature or a FeatureCollection (GeoJSON-like `dict`). For a + FeatureCollection, the Features are stored in a ´list´ available at the + 'features' key of the returned `dict`. For each Feature, the geometry is + available at the 'geometry' key. The decorated function may take a + 'crs_transform_spec' parameter, which accepts a `CrsTransformSpec` instance + as value. If the `CrsTransformSpec` instance represents a coordinates + transformation between two different CRSs, the coordinates of the + Feature's/FeatureCollection's geometry/geometries will be transformed + before returning the Feature/FeatureCollection. If the 'crs_transform_spec' + parameter is not given, passed `None` or passed a `CrsTransformSpec` + instance which does not represent a coordinates transformation, the + Feature/FeatureCollection is returned unchanged. This decorator can for + example be use to help supporting coordinates transformation of + Feature/FeatureCollection `dict` objects returned by the `get` and `query` + methods of (new or with no native support for transformations) providers of + type 'feature'. + + :param func: Function to decorate. + + :returns: Decorated function. + """ + @functools.wraps(func) + def get_geojsonf(*args, **kwargs): + crs_transform_spec = kwargs.get('crs_transform_spec') + result = func(*args, **kwargs) + if crs_transform_spec is None: + # No coordinates transformation for feature(s) returned by the + # decorated function. + LOGGER.debug('crs_transform: NOT applying coordinate transforms') + return result + # Create transformation function and transform the output feature(s)' + # coordinates before returning them. + transform_func = get_transform_from_spec(crs_transform_spec) + LOGGER.debug(f'crs_transform: transforming features CRS ' + f'from {crs_transform_spec.source_crs_uri} ' + f'to {crs_transform_spec.target_crs_uri}') + + features = result.get('features') + # Decorated function returns a single Feature + if features is None: + # Transform the feature's coordinates + crs_transform_feature(result, transform_func) + # Decorated function returns a FeatureCollection + else: + # Transform all features' coordinates + for feature in features: + crs_transform_feature(feature, transform_func) + return result + return get_geojsonf + + +def crs_transform_feature(feature: dict, transform_func: Callable): + """Transform the coordinates of a Feature. + + :param feature: Feature (GeoJSON-like `dict`) to transform. + :param transform_func: Function that transforms the coordinates of a + `Geometry` instance. + + :returns: None + """ + json_geometry = feature.get('geometry') + if json_geometry is not None: + feature['geometry'] = geom_to_geojson( + transform_func(geojson_to_geom(json_geometry)) + ) + + +def transform_bbox(bbox: list, from_crs: str, to_crs: str) -> list: + """ + helper function to transform a bounding box (bbox) from + a source to a target CRS. CRSs in URI str format. + Uses pyproj Transformer. + + :param bbox: list of coordinates in 'from_crs' projection + :param from_crs: CRS to transform from + :param to_crs: CRSto transform to + :raises `CRSError`: Error raised if no CRS could be identified from an + URI. + + :returns: list of 4 or 6 coordinates + """ + + from_crs_obj = get_crs(from_crs) + to_crs_obj = get_crs(to_crs) + transform_func = pyproj.Transformer.from_crs( + from_crs_obj, to_crs_obj).transform + + n_dims = len(bbox) // 2 + return list(transform_func(*bbox[:n_dims]) + transform_func( + *bbox[n_dims:])) + + +def modify_pygeofilter( + ast_tree: pygeofilter.ast.Node, + *, + filter_crs_uri: str, + storage_crs_uri: Optional[str] = None, + geometry_column_name: Optional[str] = None +) -> pygeofilter.ast.Node: + """ + Modifies the input pygeofilter with information from the provider. + + :param ast_tree: `pygeofilter.ast.Node` representing the + already parsed pygeofilter expression + :param filter_crs_uri: URI of the CRS being used in the filtering + expression + :param storage_crs_uri: An optional string containing the URI of + the provider's storage CRS + :param geometry_column_name: An optional string containing the + actual name of the provider's geometry field + :returns: A new pygeofilter.ast.Node, with the modified filter + expression + + This function modifies the parsed pygeofilter that contains the raw + filter expression provided by an external client. It performs the + following modifications: + + - if the filter includes any spatial coordinates and they are being + provided in a different CRS from the provider's storage CRS, the + corresponding geometries are transformed into the storage CRS + + - if the filter includes the generic 'geometry' name as a reference to + the actual geometry of features, it is replaced by the actual name + of the geometry field, as specified by the provider + + """ + new_tree = deepcopy(ast_tree) + if storage_crs_uri: + _inplace_transform_filter_geometries( + new_tree, get_crs(filter_crs_uri), get_crs(storage_crs_uri) + ) + if geometry_column_name: + _inplace_replace_geometry_filter_name(new_tree, geometry_column_name) + return new_tree + + +def _inplace_transform_filter_geometries( + node: pygeofilter.ast.Node, + filter_crs: pyproj.CRS, + storage_crs: pyproj.CRS +) -> None: + """ + Recursively traverse node tree and convert coordinates to the storage CRS. + + This function modifies nodes in the already-parsed filter in order to find + any geometry literals that may be used in the filter and, if necessary, + proceeds to convert spatial coordinates to the CRS used by the provider. + """ + try: + sub_nodes = node.get_sub_nodes() + except AttributeError: + pass + else: + for sub_node in sub_nodes: + is_geometry_node = isinstance( + sub_node, pygeofilter.values.Geometry) + if is_geometry_node: + # NOTE1: To be flexible, and since pygeofilter + # already supports it, in addition to supporting + # the `filter-crs` parameter, we also support having a + # geometry defined in EWKT, meaning the CRS is provided + # inline, like this `SRID=;` - If provided, + # this overrides the value of `filter-crs`. This enables + # supporting, for example, an exotic filter expression with + # multiple geometries specified in different CRSs + + # NOTE2: We specify a default CRS using a URI of type URN + # because this is what pygeofilter uses internally too + + crs_urn_provided_in_ewkt = sub_node.geometry.get( + 'crs', {}).get('properties', {}).get('name') + if crs_urn_provided_in_ewkt is not None: + crs = get_crs(crs_urn_provided_in_ewkt) + else: + crs = filter_crs + if crs != storage_crs: + # convert geometry coordinates to storage crs + geom = geojson_to_geom(sub_node.geometry) + coord_transformer = pyproj.Transformer.from_crs( + crs_from=crs, crs_to=storage_crs).transform + transformed_geom = ops.transform(coord_transformer, geom) + sub_node.geometry = geom_to_geojson(transformed_geom) + # ensure the crs is encoded in the sub-node, otherwise + # pygeofilter will assign it its own default CRS + authority, code = storage_crs.to_authority() + sub_node.geometry['crs'] = { + 'properties': { + 'name': f'urn:ogc:def:crs:{authority}::{code}' + } + } + else: + _inplace_transform_filter_geometries( + sub_node, filter_crs, storage_crs) + + +def _inplace_replace_geometry_filter_name( + node: pygeofilter.ast.Node, + geometry_column_name: str +) -> None: + """Recursively traverse node tree and rename nodes of type ``Attribute``. + + Nodes of type ``Attribute`` named ``geometry`` are renamed to the value of + the ``geometry_column_name`` parameter. + """ + try: + sub_nodes = node.get_sub_nodes() + except AttributeError: + pass + else: + for sub_node in sub_nodes: + is_attribute_node = isinstance(sub_node, pygeofilter.ast.Attribute) + if is_attribute_node and sub_node.name == "geometry": + sub_node.name = geometry_column_name + else: + _inplace_replace_geometry_filter_name( + sub_node, geometry_column_name) + + +def create_crs_transform_spec( + provider_def: dict, query_crs_uri: Optional[str] = None +) -> Union[None, CrsTransformSpec]: + """ + Create a `CrsTransformSpec` instance based on provider config and + *crs* query parameter. + + :param provider_def: Provider config dictionary. + :param query_crs_uri: Uniform resource identifier of the coordinate + reference system (CRS) specified in query parameter (if specified). + + :raises ValueError: Error raised if the CRS specified in the query + parameter is not in the list of supported CRSs of the provider. + :raises `CRSError`: Error raised if no CRS could be identified from the + query *crs* parameter (URI). + + :returns: `CrsTransformSpec` instance if the CRS specified in query + parameter differs from the storage CRS, else `None`. + """ + + # Get storage/default CRS for Collection. + always_xy = provider_def.get('always_xy', False) + storage_crs_uri = provider_def.get('storage_crs', DEFAULT_STORAGE_CRS) + storage_crs = get_crs(storage_crs_uri) + + if not query_crs_uri: + if storage_crs_uri in DEFAULT_CRS_LIST: + # Could be that storage_crs is + # http://www.opengis.net/def/crs/OGC/1.3/CRS84h + query_crs_uri = storage_crs_uri + else: + query_crs_uri = DEFAULT_CRS + LOGGER.debug(f'no crs parameter, using default: {query_crs_uri}') + + supported_crs_list = get_supported_crs_list(provider_def) + # Check that the crs specified by the query parameter is supported. + if query_crs_uri not in supported_crs_list: + raise ValueError( + f'CRS {query_crs_uri!r} not supported for this ' + 'collection. List of supported CRSs: ' + f'{", ".join(supported_crs_list)}.' + ) + crs_out = get_crs(query_crs_uri) + + # Check if the crs specified in query parameter differs from the + # storage crs. + if storage_crs == crs_out: + LOGGER.debug('No CRS transformation') + return None + + LOGGER.debug( + f'CRS transformation: {storage_crs} -> {crs_out}' + ) + return CrsTransformSpec( + source_crs_uri=storage_crs_uri, + source_crs_wkt=storage_crs.to_wkt(), + target_crs_uri=query_crs_uri, + target_crs_wkt=crs_out.to_wkt(), + always_xy=always_xy + ) + + +def set_content_crs_header( + headers: dict, config: dict, query_crs_uri: Optional[str] = None, +) -> None: + """Set the *Content-Crs* header in responses from providers of Feature + type. + + :param headers: Response headers dictionary. + :param config: Provider config dictionary. + :param query_crs_uri: Uniform resource identifier of the coordinate + reference system specified in query parameter (if specified). + + :returns: None + """ + + if query_crs_uri: + content_crs_uri = query_crs_uri + else: + # If empty use default CRS + storage_crs_uri = config.get('storage_crs', DEFAULT_STORAGE_CRS) + if storage_crs_uri in DEFAULT_CRS_LIST: + content_crs_uri = storage_crs_uri + else: + content_crs_uri = DEFAULT_CRS + + headers['Content-Crs'] = f'<{content_crs_uri}>' diff --git a/pygeoapi/provider/base.py b/pygeoapi/provider/base.py index 4fc94302f..0c58d90cd 100644 --- a/pygeoapi/provider/base.py +++ b/pygeoapi/provider/base.py @@ -32,6 +32,7 @@ from enum import Enum from http import HTTPStatus +from pygeoapi.crs import DEFAULT_STORAGE_CRS, get_crs from pygeoapi.error import GenericError LOGGER = logging.getLogger(__name__) @@ -78,6 +79,10 @@ def __init__(self, provider_def): self._fields = {} self.filename = None + # CRS properties + storage_crs_uri = provider_def.get('storage_crs', DEFAULT_STORAGE_CRS) + self.storage_crs = get_crs(storage_crs_uri) + # for coverage providers self.axes = [] self.crs = None diff --git a/pygeoapi/provider/csv_.py b/pygeoapi/provider/csv_.py index 9e3aee5fc..e560685ca 100644 --- a/pygeoapi/provider/csv_.py +++ b/pygeoapi/provider/csv_.py @@ -34,10 +34,11 @@ from shapely.geometry import box, Point +from pygeoapi.crs import crs_transform from pygeoapi.provider.base import (BaseProvider, ProviderInvalidQueryError, ProviderItemNotFoundError, ProviderQueryError) -from pygeoapi.util import get_typed_value, crs_transform +from pygeoapi.util import get_typed_value LOGGER = logging.getLogger(__name__) diff --git a/pygeoapi/provider/csw_facade.py b/pygeoapi/provider/csw_facade.py index 22e7483f4..bfd77735a 100644 --- a/pygeoapi/provider/csw_facade.py +++ b/pygeoapi/provider/csw_facade.py @@ -34,11 +34,12 @@ from owslib.csw import CatalogueServiceWeb from owslib.ows import ExceptionReport +from pygeoapi.crs import crs_transform from pygeoapi.provider.base import (BaseProvider, ProviderConnectionError, ProviderInvalidQueryError, ProviderItemNotFoundError, ProviderQueryError) -from pygeoapi.util import bbox2geojsongeometry, crs_transform, get_typed_value +from pygeoapi.util import bbox2geojsongeometry, get_typed_value LOGGER = logging.getLogger(__name__) diff --git a/pygeoapi/provider/elasticsearch_.py b/pygeoapi/provider/elasticsearch_.py index 2af3ac885..faff311a0 100644 --- a/pygeoapi/provider/elasticsearch_.py +++ b/pygeoapi/provider/elasticsearch_.py @@ -40,10 +40,10 @@ from elasticsearch_dsl import Search +from pygeoapi.crs import crs_transform from pygeoapi.provider.base import (BaseProvider, ProviderConnectionError, ProviderQueryError, ProviderItemNotFoundError) -from pygeoapi.util import crs_transform LOGGER = logging.getLogger(__name__) diff --git a/pygeoapi/provider/erddap.py b/pygeoapi/provider/erddap.py index 2fc71c064..27e5b4115 100644 --- a/pygeoapi/provider/erddap.py +++ b/pygeoapi/provider/erddap.py @@ -49,9 +49,9 @@ import requests +from pygeoapi.crs import crs_transform from pygeoapi.provider.base import ( BaseProvider, ProviderNotFoundError, ProviderQueryError) -from pygeoapi.util import crs_transform LOGGER = logging.getLogger(__name__) diff --git a/pygeoapi/provider/esri.py b/pygeoapi/provider/esri.py index 47d74e2b9..85cc69d9e 100644 --- a/pygeoapi/provider/esri.py +++ b/pygeoapi/provider/esri.py @@ -32,9 +32,10 @@ import logging from requests import Session, codes +from pygeoapi.crs import crs_transform, get_srid from pygeoapi.provider.base import (BaseProvider, ProviderConnectionError, ProviderTypeError, ProviderQueryError) -from pygeoapi.util import format_datetime, crs_transform +from pygeoapi.util import format_datetime LOGGER = logging.getLogger(__name__) @@ -59,7 +60,7 @@ def __init__(self, provider_def): super().__init__(provider_def) self.url = f'{self.data}/query' - self.crs = provider_def.get('crs', '4326') + self.crs = get_srid(self.storage_crs) self.username = provider_def.get('username') self.password = provider_def.get('password') self.token_url = provider_def.get('token_service', ARCGIS_URL) diff --git a/pygeoapi/provider/geojson.py b/pygeoapi/provider/geojson.py index 9d7ee30c2..ddbca7da6 100644 --- a/pygeoapi/provider/geojson.py +++ b/pygeoapi/provider/geojson.py @@ -35,8 +35,8 @@ from shapely.geometry import box, shape +from pygeoapi.crs import crs_transform from pygeoapi.provider.base import BaseProvider, ProviderItemNotFoundError -from pygeoapi.util import crs_transform LOGGER = logging.getLogger(__name__) diff --git a/pygeoapi/provider/mongo.py b/pygeoapi/provider/mongo.py index ca258018c..6c1bab729 100644 --- a/pygeoapi/provider/mongo.py +++ b/pygeoapi/provider/mongo.py @@ -35,8 +35,9 @@ from pymongo import GEOSPHERE from pymongo import ASCENDING, DESCENDING from pymongo.collection import ObjectId + +from pygeoapi.crs import crs_transform from pygeoapi.provider.base import BaseProvider, ProviderItemNotFoundError -from pygeoapi.util import crs_transform LOGGER = logging.getLogger(__name__) diff --git a/pygeoapi/provider/mvt_postgresql.py b/pygeoapi/provider/mvt_postgresql.py index 792f7ec1a..0823fc304 100644 --- a/pygeoapi/provider/mvt_postgresql.py +++ b/pygeoapi/provider/mvt_postgresql.py @@ -41,13 +41,14 @@ from sqlalchemy.sql import select from sqlalchemy.orm import Session +from pygeoapi.crs import get_crs from pygeoapi.models.provider.base import ( TileSetMetadata, TileMatrixSetEnum, LinkType) from pygeoapi.provider.base import ProviderConnectionError from pygeoapi.provider.base_mvt import BaseMVTProvider from pygeoapi.provider.sql import PostgreSQLProvider from pygeoapi.provider.tile import ProviderTileNotFoundError -from pygeoapi.util import url_join, get_crs_from_uri +from pygeoapi.util import url_join LOGGER = logging.getLogger(__name__) @@ -153,15 +154,13 @@ def get_tiles(self, layer='default', tileset=None, LOGGER.warning(f'Tile {z}/{x}/{y} not found') raise ProviderTileNotFoundError - storage_srid = get_crs_from_uri(self.storage_crs).to_string() - out_srid = get_crs_from_uri(tileset_schema.crs).to_string() envelope = self.get_envelope(z, y, x, tileset) - geom_column = getattr(self.table_model, self.geom) geom_filter = geom_column.intersects( - ST_Transform(envelope, storage_srid) + ST_Transform(envelope, self.storage_crs.to_string()) ) + out_srid = get_crs(tileset_schema.crs).to_string() mvtgeom = ( ST_AsMVTGeom( ST_Transform(ST_CurveToLine(geom_column), out_srid), diff --git a/pygeoapi/provider/ogr.py b/pygeoapi/provider/ogr.py index 78ca999c2..7fee6bb24 100644 --- a/pygeoapi/provider/ogr.py +++ b/pygeoapi/provider/ogr.py @@ -41,13 +41,12 @@ from osgeo import ogr as osgeo_ogr from osgeo import osr as osgeo_osr +from pygeoapi.crs import get_crs from pygeoapi.provider.base import ( BaseProvider, ProviderGenericError, ProviderQueryError, ProviderConnectionError, ProviderItemNotFoundError) -from pygeoapi.util import get_crs_from_uri - LOGGER = logging.getLogger(__name__) @@ -86,8 +85,6 @@ def __init__(self, provider_def): data: source_type: WFS source: WFS:https://service.pdok.nl/kadaster/rdinfo/wfs/v1_0? - source_srs: EPSG:28992 - target_srs: EPSG:4326 source_capabilities: paging: True source_options: @@ -143,29 +140,10 @@ def __init__(self, provider_def): self.source_capabilities = self.data_def.get('source_capabilities', {'paging': False}) - # self.source_srs = int(self.data_def.get('source_srs', - # 'EPSG:4326').split(':')[1]) - # self.target_srs = int(self.data_def.get('target_srs', - # 'EPSG:4326').split(':')[1]) - if self.data_def.get('source_srs') is not None \ - or self.data_def.get('target_srs') is not None: + if self.data_def.get('source_srs') or self.data_def.get('target_srs'): LOGGER.warning('source/target_srs no longer supported in OGRProvider') # noqa LOGGER.warning('Use crs and storage_crs in config, see docs') - # Optional coordinate transformation inward (requests) and - # outward (responses) when the source layers and - # OGC API - Features collections differ in EPSG-codes. - self.transform_in = None - self.transform_out = None - # if self.source_srs != self.target_srs: - # source = self._get_spatial_ref_from_epsg(self.source_srs) - # target = self._get_spatial_ref_from_epsg(self.target_srs) - # - # self.transform_in = \ - # osgeo_osr.CoordinateTransformation(target, source) - # self.transform_out = \ - # osgeo_osr.CoordinateTransformation(source, target) - self._load_source_helper(self.data_def['source_type']) self.geom_field = provider_def.get('geom_field') @@ -335,9 +313,6 @@ def query(self, offset=0, limit=10, resulttype='results', f"{maxx} {miny},{minx} {miny}))" polygon = self.ogr.CreateGeometryFromWkt(wkt) - # if self.transform_in: - # polygon.Transform(self.transform_in) - layer.SetSpatialFilter(polygon) # layer.SetSpatialFilterRect( @@ -402,7 +377,7 @@ def _get_spatial_ref_from_uri(self, crs_uri): epsg_code = 4326 force_auth_comply = False else: - pyproj_crs = get_crs_from_uri(crs_uri) + pyproj_crs = get_crs(crs_uri) epsg_code = int(pyproj_crs.srs.split(':')[1]) force_auth_comply = True return self._get_spatial_ref_from_epsg( @@ -431,9 +406,6 @@ def get(self, identifier, crs_transform_spec=None, **kwargs): result = None crs_transform_out = self._get_crs_transform(crs_transform_spec) - # Keep support for source_srs/target_srs - # if crs_transform_out is None: - # crs_transform_out = self.transform_out try: LOGGER.debug(f'Fetching identifier {identifier}') layer = self._get_layer() @@ -566,9 +538,6 @@ def _response_feature_collection( layer.ResetReading() crs_transform_out = self._get_crs_transform(crs_transform_spec) - # Keep support for source_srs/target_srs - # if crs_transform_out is None: - # crs_transform_out = self.transform_out try: # Ignore gdal error ogr_feature = _ignore_gdal_error(layer, 'GetNextFeature') diff --git a/pygeoapi/provider/opensearch_.py b/pygeoapi/provider/opensearch_.py index d10cba5af..1b46f6af4 100644 --- a/pygeoapi/provider/opensearch_.py +++ b/pygeoapi/provider/opensearch_.py @@ -40,11 +40,10 @@ from pygeofilter.backends.opensearch import to_filter +from pygeoapi.crs import crs_transform from pygeoapi.provider.base import (BaseProvider, ProviderConnectionError, ProviderQueryError, ProviderItemNotFoundError) -from pygeoapi.util import crs_transform - LOGGER = logging.getLogger(__name__) diff --git a/pygeoapi/provider/oracle.py b/pygeoapi/provider/oracle.py index 75fe1aa84..8a26d32db 100644 --- a/pygeoapi/provider/oracle.py +++ b/pygeoapi/provider/oracle.py @@ -38,8 +38,7 @@ import oracledb import pyproj -from pygeoapi.api import DEFAULT_STORAGE_CRS - +from pygeoapi.crs import get_srid from pygeoapi.provider.base import ( BaseProvider, ProviderConnectionError, @@ -49,8 +48,6 @@ ProviderQueryError, ) -from pygeoapi.util import get_crs_from_uri - LOGGER = logging.getLogger(__name__) @@ -403,7 +400,6 @@ def __init__(self, provider_def): # Table properties self.table = provider_def["table"] - self.id_field = provider_def["id_field"] self.conn_dic = provider_def["data"] self.geom = provider_def["geom_field"] self.properties = [item.lower() for item in self.properties] @@ -416,14 +412,6 @@ def __init__(self, provider_def): "sql_manipulator_options" ) - # CRS properties - storage_crs_uri = provider_def.get("storage_crs", DEFAULT_STORAGE_CRS) - self.storage_crs = get_crs_from_uri(storage_crs_uri) - - # TODO See Issue #1393 - # default_crs_uri = provider_def.get("default_crs", DEFAULT_CRS) - # self.default_crs = get_crs_from_uri(default_crs_uri) - # SDO properties self.sdo_param = provider_def.get("sdo_param") self.sdo_operator = provider_def.get("sdo_operator", "sdo_filter") @@ -491,7 +479,7 @@ def _get_where_clauses( sdo_param = "mask=anyinteract" bbox_dict["properties"] = { - "srid": self._get_srid_from_crs(bbox_crs), + "srid": get_srid(bbox_crs), "minx": bbox[0], "miny": bbox[1], "maxx": bbox[2], @@ -522,7 +510,7 @@ def _get_where_clauses( else: bbox_dict["properties"] = { - "srid": self._get_srid_from_crs(bbox_crs), + "srid": get_srid(bbox_crs), "minx": bbox[0], "miny": bbox[1], "maxx": bbox[2], @@ -599,20 +587,6 @@ def _output_type_handler( oracledb.DB_TYPE_LONG_RAW, arraysize=cursor.arraysize ) - def _get_srid_from_crs(self, crs): - """ - Works only for EPSG codes! - Anything else is hard coded! - """ - if crs == "OGC:CRS84": - srid = 4326 - elif crs == "OGC:CRS84h": - srid = 4326 - else: - srid = crs.to_epsg() - - return srid - def _process_query_with_sql_manipulator_sup( self, db, sql_query, bind_variables, extra_params, **query_args ): @@ -795,21 +769,16 @@ def query( source_crs = pyproj.CRS.from_wkt( crs_transform_spec.source_crs_wkt ) - source_srid = self._get_srid_from_crs(source_crs) + source_srid = get_srid(source_crs) target_crs = pyproj.CRS.from_wkt( crs_transform_spec.target_crs_wkt ) - target_srid = self._get_srid_from_crs(target_crs) + target_srid = get_srid(target_crs) else: - source_srid = self._get_srid_from_crs(self.storage_crs) + source_srid = get_srid(self.storage_crs) target_srid = source_srid - # TODO See Issue #1393 - # target_srid = self._get_srid_from_crs(self.default_crs) - # If issue is not accepted, this block can be merged with - # the following block. - LOGGER.debug(f"source_srid: {source_srid}") LOGGER.debug(f"target_srid: {target_srid}") @@ -961,22 +930,17 @@ def get(self, identifier, crs_transform_spec=None, **kwargs): source_crs = pyproj.CRS.from_wkt( crs_transform_spec.source_crs_wkt ) - source_srid = self._get_srid_from_crs(source_crs) + source_srid = get_srid(source_crs) target_crs = pyproj.CRS.from_wkt( crs_transform_spec.target_crs_wkt ) - target_srid = self._get_srid_from_crs(target_crs) + target_srid = get_srid(target_crs) else: - source_srid = self._get_srid_from_crs(self.storage_crs) + source_srid = get_srid(self.storage_crs) target_srid = source_srid - # TODO See Issue #1393 - # target_srid = self._get_srid_from_crs(self.default_crs) - # If issue is not accepted, this block can be merged with - # the following block. - LOGGER.debug(f"source_srid: {source_srid}") LOGGER.debug(f"target_srid: {target_srid}") @@ -1150,7 +1114,7 @@ def filter_binds(pair): **bind_variables, "out_id": out_id, "in_geometry": json.dumps(in_geometry), - "srid": self._get_srid_from_crs(self.storage_crs), + "srid": get_srid(self.storage_crs), } # SQL manipulation plugin @@ -1245,7 +1209,7 @@ def filter_binds(pair): **bind_variables, "in_id": identifier, "in_geometry": in_geometry, - "srid": self._get_srid_from_crs(self.storage_crs), + "srid": get_srid(self.storage_crs), } # SQL manipulation plugin diff --git a/pygeoapi/provider/parquet.py b/pygeoapi/provider/parquet.py index 00d39e07a..0f4ab3de1 100644 --- a/pygeoapi/provider/parquet.py +++ b/pygeoapi/provider/parquet.py @@ -38,6 +38,7 @@ import pyarrow.dataset import s3fs +from pygeoapi.crs import crs_transform from pygeoapi.provider.base import ( BaseProvider, ProviderConnectionError, @@ -45,7 +46,6 @@ ProviderItemNotFoundError, ProviderQueryError, ) -from pygeoapi.util import crs_transform LOGGER = logging.getLogger(__name__) diff --git a/pygeoapi/provider/sensorthings.py b/pygeoapi/provider/sensorthings.py index c0f8f7a28..cd444f407 100644 --- a/pygeoapi/provider/sensorthings.py +++ b/pygeoapi/provider/sensorthings.py @@ -36,12 +36,12 @@ from urllib.parse import urlparse from pygeoapi.config import get_config +from pygeoapi.crs import crs_transform from pygeoapi.provider.base import ( BaseProvider, ProviderQueryError, ProviderConnectionError, ProviderInvalidDataError) from pygeoapi.util import ( - url_join, get_provider_default, crs_transform, get_base_url, - get_typed_value) + url_join, get_provider_default, get_base_url, get_typed_value) LOGGER = logging.getLogger(__name__) diff --git a/pygeoapi/provider/socrata.py b/pygeoapi/provider/socrata.py index d8440cb05..4ffdd4f58 100644 --- a/pygeoapi/provider/socrata.py +++ b/pygeoapi/provider/socrata.py @@ -35,9 +35,10 @@ from sodapy import Socrata import logging +from pygeoapi.crs import crs_transform from pygeoapi.provider.base import (BaseProvider, ProviderQueryError, ProviderConnectionError) -from pygeoapi.util import format_datetime, crs_transform +from pygeoapi.util import format_datetime LOGGER = logging.getLogger(__name__) diff --git a/pygeoapi/provider/sql.py b/pygeoapi/provider/sql.py index f436353a1..cba5abaea 100644 --- a/pygeoapi/provider/sql.py +++ b/pygeoapi/provider/sql.py @@ -63,7 +63,6 @@ from geoalchemy2.functions import ST_MakeEnvelope, ST_Intersects from geoalchemy2.shape import to_shape, from_shape from pygeofilter.backends.sqlalchemy.evaluate import to_filter -import pyproj import shapely from sqlalchemy.sql import func from sqlalchemy import ( @@ -84,6 +83,7 @@ from sqlalchemy.orm import Session, load_only from sqlalchemy.sql.expression import and_ +from pygeoapi.crs import get_transform_from_spec, get_srid from pygeoapi.provider.base import ( BaseProvider, ProviderConnectionError, @@ -91,8 +91,6 @@ ProviderQueryError, ProviderItemNotFoundError ) -from pygeoapi.util import get_transform_from_crs, get_crs_from_uri - LOGGER = logging.getLogger(__name__) @@ -134,12 +132,6 @@ def __init__( LOGGER.debug(f'Table: {self.table}') LOGGER.debug(f'ID field: {self.id_field}') LOGGER.debug(f'Geometry field: {self.geom}') - - # conforming to the docs: - # https://docs.pygeoapi.io/en/latest/data-publishing/ogcapi-features.html#connection-examples # noqa - self.storage_crs = provider_def.get( - 'storage_crs', 'https://www.opengis.net/def/crs/OGC/0/CRS84' - ) LOGGER.debug(f'Configured Storage CRS: {self.storage_crs}') # Read table information from database @@ -235,7 +227,7 @@ def query( if resulttype == 'hits' or not results: return response - crs_transform_out = self._get_crs_transform(crs_transform_spec) + crs_transform_out = get_transform_from_spec(crs_transform_spec) for item in ( results.order_by(*order_by_clauses).offset(offset).limit(limit) @@ -334,7 +326,7 @@ def get(self, identifier, crs_transform_spec=None, **kwargs): if item is None: msg = f'No such item: {self.id_field}={identifier}.' raise ProviderItemNotFoundError(msg) - crs_transform_out = self._get_crs_transform(crs_transform_spec) + crs_transform_out = get_transform_from_spec(crs_transform_spec) feature = self._sqlalchemy_to_feature(item, crs_transform_out) # Drop non-defined properties @@ -496,10 +488,7 @@ def _feature_to_sqlalchemy(self, json_data, identifier=None): attributes.pop('identifier', None) attributes[self.geom] = from_shape( shapely.geometry.shape(json_data['geometry']), - # NOTE: for some reason, postgis in the github action requires - # explicit crs information. i think it's valid to assume 4326: - # https://portal.ogc.org/files/108198#feature-crs - srid=pyproj.CRS.from_user_input(self.storage_crs).to_epsg() + srid=get_srid(self.storage_crs) ) attributes[self.id_field] = identifier @@ -610,16 +599,6 @@ def _select_properties_clause(self, select_properties, skip_geometry): return selected_properties_clause - def _get_crs_transform(self, crs_transform_spec=None): - if crs_transform_spec is not None: - crs_transform = get_transform_from_crs( - pyproj.CRS.from_wkt(crs_transform_spec.source_crs_wkt), - pyproj.CRS.from_wkt(crs_transform_spec.target_crs_wkt) - ) - else: - crs_transform = None - return crs_transform - @functools.cache def get_engine( @@ -751,9 +730,8 @@ def _get_bbox_filter(self, bbox: list[float]): if not bbox: return True # Let everything through if no bbox - # Since this provider uses postgis, we can use ST_MakeEnvelope - storage_srid = get_crs_from_uri(self.storage_crs).to_epsg() - envelope = ST_MakeEnvelope(*bbox, storage_srid or 4326) + storage_srid = get_srid(self.storage_crs) + envelope = ST_MakeEnvelope(*bbox, storage_srid) geom_column = getattr(self.table_model, self.geom) bbox_filter = ST_Intersects(envelope, geom_column) diff --git a/pygeoapi/provider/sqlite.py b/pygeoapi/provider/sqlite.py index d0257609f..340071af2 100644 --- a/pygeoapi/provider/sqlite.py +++ b/pygeoapi/provider/sqlite.py @@ -35,10 +35,11 @@ import logging import os import json + +from pygeoapi.crs import crs_transform from pygeoapi.plugin import InvalidPluginError from pygeoapi.provider.base import (BaseProvider, ProviderConnectionError, ProviderItemNotFoundError) -from pygeoapi.util import crs_transform LOGGER = logging.getLogger(__name__) diff --git a/pygeoapi/provider/tinydb_.py b/pygeoapi/provider/tinydb_.py index a563e2654..f2fbba228 100644 --- a/pygeoapi/provider/tinydb_.py +++ b/pygeoapi/provider/tinydb_.py @@ -36,10 +36,11 @@ from shapely.geometry import shape from tinydb import TinyDB, Query, where +from pygeoapi.crs import crs_transform from pygeoapi.provider.base import (BaseProvider, ProviderConnectionError, ProviderInvalidQueryError, ProviderItemNotFoundError) -from pygeoapi.util import crs_transform, get_typed_value +from pygeoapi.util import get_typed_value LOGGER = logging.getLogger(__name__) diff --git a/pygeoapi/provider/xarray_.py b/pygeoapi/provider/xarray_.py index 1cdb39a62..6dbd9060f 100644 --- a/pygeoapi/provider/xarray_.py +++ b/pygeoapi/provider/xarray_.py @@ -38,15 +38,13 @@ import fsspec import numpy as np import pyproj -from pyproj.exceptions import CRSError - -from pygeoapi.api import DEFAULT_STORAGE_CRS +from pygeoapi.crs import DEFAULT_CRS, DEFAULT_STORAGE_CRS, get_crs, get_srid from pygeoapi.provider.base import (BaseProvider, ProviderConnectionError, ProviderNoDataError, ProviderQueryError) -from pygeoapi.util import get_crs_from_uri, read_data +from pygeoapi.util import read_data LOGGER = logging.getLogger(__name__) @@ -94,7 +92,9 @@ def __init__(self, provider_def): else: raise err - self.storage_crs = self._parse_storage_crs(provider_def) + if provider_def.get('storage_crs') is None: + self.storage_crs = self._parse_storage_crs() + self._coverage_properties = self._get_coverage_properties() self.axes = self._coverage_properties['axes'] @@ -449,7 +449,7 @@ def _get_coverage_properties(self): self._data.coords[self.x_field].values[-1], self._data.coords[self.y_field].values[-1], ], - 'bbox_crs': 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', + 'bbox_crs': DEFAULT_CRS, 'crs_type': 'GeographicCRS', 'x_axis_label': self.x_field, 'y_axis_label': self.y_field, @@ -481,12 +481,9 @@ def _get_coverage_properties(self): properties['restime'] = self.get_time_resolution() # Update properties based on the xarray's CRS - epsg_code = self.storage_crs.to_epsg() + epsg_code = get_srid(self.storage_crs) LOGGER.debug(f'{epsg_code}') - if epsg_code == 4326 or self.storage_crs == 'OGC:CRS84': - pass - LOGGER.debug('Confirmed default of WGS 84') - else: + if epsg_code != 4326: properties['bbox_crs'] = \ f'https://www.opengis.net/def/crs/EPSG/0/{epsg_code}' properties['inverse_flattening'] = \ @@ -586,50 +583,24 @@ def _parse_grid_mapping(self): LOGGER.debug('No grid mapping information found.') return grid_mapping_name - def _parse_storage_crs( - self, - provider_def: dict - ) -> pyproj.CRS: + def _parse_storage_crs(self) -> pyproj.CRS: """ Parse the storage CRS from an xarray dataset. - :param provider_def: provider definition - :returns: `pyproj.CRS` instance parsed from dataset """ - storage_crs = None + grid_mapping = self._parse_grid_mapping() try: - storage_crs = provider_def['storage_crs'] - crs_function = pyproj.CRS.from_user_input - except KeyError as err: - LOGGER.debug(err) - LOGGER.debug('No storage_crs found. Attempting to parse the CRS.') - - if storage_crs is None: - grid_mapping = self._parse_grid_mapping() if grid_mapping is not None: - storage_crs = self._data[grid_mapping].attrs - crs_function = pyproj.CRS.from_cf + return pyproj.CRS.from_cf(self._data[grid_mapping].attrs) elif 'crs' in self._data.variables.keys(): - storage_crs = self._data['crs'].attrs - crs_function = pyproj.CRS.from_dict - else: - storage_crs = DEFAULT_STORAGE_CRS - crs_function = get_crs_from_uri - LOGGER.debug('Failed to parse dataset CRS. Assuming WGS84.') - - LOGGER.debug(f'Parsing CRS {storage_crs} with {crs_function}') - try: - crs = crs_function(storage_crs) - except CRSError as err: + return pyproj.CRS.from_dict(self._data['crs'].attrs) + except pyproj.exceptions.CRSError as err: LOGGER.debug(f'Unable to parse projection with pyproj: {err}') LOGGER.debug('Assuming default WGS84.') - crs = get_crs_from_uri(DEFAULT_STORAGE_CRS) - - LOGGER.debug(crs) - return crs + return get_crs(DEFAULT_STORAGE_CRS) def _to_datetime_string(datetime_obj): diff --git a/pygeoapi/util.py b/pygeoapi/util.py index 4db52582f..30a18de57 100644 --- a/pygeoapi/util.py +++ b/pygeoapi/util.py @@ -30,10 +30,7 @@ """Generic util functions used in the code""" import base64 -from copy import deepcopy from filelock import FileLock -import functools -from functools import partial from dataclasses import dataclass from datetime import date, datetime, time, timezone from decimal import Decimal @@ -46,7 +43,7 @@ import pathlib from pathlib import Path import re -from typing import Any, IO, Union, List, Optional, Callable +from typing import Any, IO, Union, List, Optional from urllib.parse import urlparse from urllib.request import urlopen import uuid @@ -55,24 +52,11 @@ from babel.support import Translations from jinja2 import Environment, FileSystemLoader, select_autoescape from jinja2.exceptions import TemplateNotFound -import pyproj -import pygeofilter.ast -import pygeofilter.values -from pyproj.exceptions import CRSError from requests import Session from requests.structures import CaseInsensitiveDict -from shapely import ops from shapely.geometry import ( box, - GeometryCollection, - LinearRing, - LineString, - MultiLineString, - MultiPoint, - MultiPolygon, Polygon, - Point, - shape as geojson_to_geom, mapping as geom_to_geojson, ) import yaml @@ -94,27 +78,6 @@ SCHEMASDIR = RESOURCESDIR / 'schemas' -# Type for Shapely geometrical objects. -GeomObject = Union[ - GeometryCollection, - LinearRing, - LineString, - MultiLineString, - MultiPoint, - MultiPolygon, - Point, - Polygon, -] - - -@dataclass -class CrsTransformSpec: - source_crs_uri: str - source_crs_wkt: str - target_crs_uri: str - target_crs_wkt: str - - mimetypes.add_type('text/plain', '.yaml') mimetypes.add_type('text/plain', '.yml') @@ -696,205 +659,6 @@ def get_envelope(coords_list: List[List[float]]) -> list: [bounds[2], bounds[1]]] -def get_supported_crs_list(config: dict, default_crs_list: list) -> list: - """ - Helper function to get a complete list of supported CRSs - from a (Provider) config dict. Result should always include - a default CRS according to OAPIF Part 2 OGC Standard. - This will be the default when no CRS list in config or - added when (partially) missing in config. - - Author: @justb4 - - :param config: dictionary with or without a list of CRSs - :param default_crs_list: default CRS alternatives, first is default - :returns: list of supported CRSs - """ - supported_crs_list = config.get('crs', list()) - contains_default = False - for uri in supported_crs_list: - if uri in default_crs_list: - contains_default = True - break - - # A default CRS is missing: add the first which is the default - if not contains_default: - supported_crs_list.append(default_crs_list[0]) - return supported_crs_list - - -def get_crs_from_uri(uri: str) -> pyproj.CRS: - """ - Get a `pyproj.CRS` instance from a CRS URI. - Author: @MTachon - - :param uri: Uniform resource identifier of the coordinate - reference system. In accordance with - https://docs.ogc.org/pol/09-048r5.html#_naming_rule URIs can - take either the form of a URL or a URN - :raises `CRSError`: Error raised if no CRS could be identified from the - URI. - - :returns: `pyproj.CRS` instance matching the input URI. - :rtype: `pyproj.CRS` - """ - - # normalize the input `uri` to a URL first - url = uri.replace( - "urn:ogc:def:crs", - "http://www.opengis.net/def/crs" - ).replace(":", "/") - try: - authority, code = url.rsplit("/", maxsplit=3)[1::2] - crs = pyproj.CRS.from_authority(authority, code) - except ValueError: - msg = ( - f"CRS could not be identified from URI {uri!r}. CRS URIs must " - "follow one of two formats: " - "'http://www.opengis.net/def/crs/{authority}/{version}/{code}' or " - "'urn:ogc:def:crs:{authority}:{version}:{code}' " - "(see https://docs.opengeospatial.org/is/18-058r1/18-058r1.html#crs-overview)." # noqa - ) - LOGGER.error(msg) - raise CRSError(msg) - except CRSError: - msg = f"CRS could not be identified from URI {uri!r}" - LOGGER.error(msg) - raise CRSError(msg) - else: - return crs - - -def get_transform_from_crs( - crs_in: pyproj.CRS, crs_out: pyproj.CRS, always_xy: bool = False -) -> Callable[[GeomObject], GeomObject]: - """ Get transformation function from two `pyproj.CRS` instances. - - Get function to transform the coordinates of a Shapely geometrical object - from one coordinate reference system to another. - - :param crs_in: Coordinate Reference System of the input geometrical object. - :type crs_in: `pyproj.CRS` - :param crs_out: Coordinate Reference System of the output geometrical - object. - :type crs_out: `pyproj.CRS` - :param always_xy: should axis order be forced to x,y (lon, lat) even if CRS - declares y,x (lat,lon) - :type always_xy: `bool` - - :returns: Function to transform the coordinates of a `GeomObject`. - :rtype: `callable` - """ - crs_transform = pyproj.Transformer.from_crs( - crs_in, crs_out, always_xy=always_xy, - ).transform - return partial(ops.transform, crs_transform) - - -def crs_transform(func): - """Decorator that transforms the geometry's/geometries' coordinates of a - Feature/FeatureCollection. - - This function can be used to decorate another function which returns either - a Feature or a FeatureCollection (GeoJSON-like `dict`). For a - FeatureCollection, the Features are stored in a ´list´ available at the - 'features' key of the returned `dict`. For each Feature, the geometry is - available at the 'geometry' key. The decorated function may take a - 'crs_transform_spec' parameter, which accepts a `CrsTransformSpec` instance - as value. If the `CrsTransformSpec` instance represents a coordinates - transformation between two different CRSs, the coordinates of the - Feature's/FeatureCollection's geometry/geometries will be transformed - before returning the Feature/FeatureCollection. If the 'crs_transform_spec' - parameter is not given, passed `None` or passed a `CrsTransformSpec` - instance which does not represent a coordinates transformation, the - Feature/FeatureCollection is returned unchanged. This decorator can for - example be use to help supporting coordinates transformation of - Feature/FeatureCollection `dict` objects returned by the `get` and `query` - methods of (new or with no native support for transformations) providers of - type 'feature'. - - :param func: Function to decorate. - :type func: `callable` - - :returns: Decorated function. - :rtype: `callable` - """ - @functools.wraps(func) - def get_geojsonf(*args, **kwargs): - crs_transform_spec = kwargs.get('crs_transform_spec') - result = func(*args, **kwargs) - if crs_transform_spec is None: - # No coordinates transformation for feature(s) returned by the - # decorated function. - LOGGER.debug('crs_transform: NOT applying coordinate transforms') - return result - # Create transformation function and transform the output feature(s)' - # coordinates before returning them. - transform_func = get_transform_from_crs( - pyproj.CRS.from_wkt(crs_transform_spec.source_crs_wkt), - pyproj.CRS.from_wkt(crs_transform_spec.target_crs_wkt), - ) - - LOGGER.debug(f'crs_transform: transforming features CRS ' - f'from {crs_transform_spec.source_crs_uri} ' - f'to {crs_transform_spec.target_crs_uri}') - - features = result.get('features') - # Decorated function returns a single Feature - if features is None: - # Transform the feature's coordinates - crs_transform_feature(result, transform_func) - # Decorated function returns a FeatureCollection - else: - # Transform all features' coordinates - for feature in features: - crs_transform_feature(feature, transform_func) - return result - return get_geojsonf - - -def crs_transform_feature(feature, transform_func): - """Transform the coordinates of a Feature. - - :param feature: Feature (GeoJSON-like `dict`) to transform. - :type feature: `dict` - :param transform_func: Function that transforms the coordinates of a - `GeomObject` instance. - :type transform_func: `callable` - - :returns: None - """ - json_geometry = feature.get('geometry') - if json_geometry is not None: - feature['geometry'] = geom_to_geojson( - transform_func(geojson_to_geom(json_geometry)) - ) - - -def transform_bbox(bbox: list, from_crs: str, to_crs: str) -> list: - """ - helper function to transform a bounding box (bbox) from - a source to a target CRS. CRSs in URI str format. - Uses pyproj Transformer. - - :param bbox: list of coordinates in 'from_crs' projection - :param from_crs: CRS URI to transform from - :param to_crs: CRS URI to transform to - :raises `CRSError`: Error raised if no CRS could be identified from an - URI. - - :returns: list of 4 or 6 coordinates - """ - - from_crs_obj = get_crs_from_uri(from_crs) - to_crs_obj = get_crs_from_uri(to_crs) - transform_func = pyproj.Transformer.from_crs( - from_crs_obj, to_crs_obj).transform - n_dims = len(bbox) // 2 - return list(transform_func(*bbox[:n_dims]) + transform_func( - *bbox[n_dims:])) - - class UrlPrefetcher: """ Prefetcher to get HTTP headers for specific URLs. Allows a maximum of 1 redirect by default. @@ -934,132 +698,6 @@ def bbox2geojsongeometry(bbox: list) -> dict: return geom_to_geojson(b) -def modify_pygeofilter( - ast_tree: pygeofilter.ast.Node, - *, - filter_crs_uri: str, - storage_crs_uri: Optional[str] = None, - geometry_column_name: Optional[str] = None -) -> pygeofilter.ast.Node: - """ - Modifies the input pygeofilter with information from the provider. - - :param ast_tree: `pygeofilter.ast.Node` representing the - already parsed pygeofilter expression - :param filter_crs_uri: URI of the CRS being used in the filtering - expression - :param storage_crs_uri: An optional string containing the URI of - the provider's storage CRS - :param geometry_column_name: An optional string containing the - actual name of the provider's geometry field - :returns: A new pygeofilter.ast.Node, with the modified filter - expression - - This function modifies the parsed pygeofilter that contains the raw - filter expression provided by an external client. It performs the - following modifications: - - - if the filter includes any spatial coordinates and they are being - provided in a different CRS from the provider's storage CRS, the - corresponding geometries are transformed into the storage CRS - - - if the filter includes the generic 'geometry' name as a reference to - the actual geometry of features, it is replaced by the actual name - of the geometry field, as specified by the provider - - """ - new_tree = deepcopy(ast_tree) - if storage_crs_uri: - storage_crs = get_crs_from_uri(storage_crs_uri) - filter_crs = get_crs_from_uri(filter_crs_uri) - _inplace_transform_filter_geometries(new_tree, filter_crs, storage_crs) - if geometry_column_name: - _inplace_replace_geometry_filter_name(new_tree, geometry_column_name) - return new_tree - - -def _inplace_transform_filter_geometries( - node: pygeofilter.ast.Node, - filter_crs: pyproj.CRS, - storage_crs: pyproj.CRS -): - """ - Recursively traverse node tree and convert coordinates to the storage CRS. - - This function modifies nodes in the already-parsed filter in order to find - any geometry literals that may be used in the filter and, if necessary, - proceeds to convert spatial coordinates to the CRS used by the provider. - """ - try: - sub_nodes = node.get_sub_nodes() - except AttributeError: - pass - else: - for sub_node in sub_nodes: - is_geometry_node = isinstance( - sub_node, pygeofilter.values.Geometry) - if is_geometry_node: - # NOTE1: To be flexible, and since pygeofilter - # already supports it, in addition to supporting - # the `filter-crs` parameter, we also support having a - # geometry defined in EWKT, meaning the CRS is provided - # inline, like this `SRID=;` - If provided, - # this overrides the value of `filter-crs`. This enables - # supporting, for example, an exotic filter expression with - # multiple geometries specified in different CRSs - - # NOTE2: We specify a default CRS using a URI of type URN - # because this is what pygeofilter uses internally too - - crs_urn_provided_in_ewkt = sub_node.geometry.get( - 'crs', {}).get('properties', {}).get('name') - if crs_urn_provided_in_ewkt is not None: - crs = get_crs_from_uri(crs_urn_provided_in_ewkt) - else: - crs = filter_crs - if crs != storage_crs: - # convert geometry coordinates to storage crs - geom = geojson_to_geom(sub_node.geometry) - coord_transformer = pyproj.Transformer.from_crs( - crs_from=crs, crs_to=storage_crs).transform - transformed_geom = ops.transform(coord_transformer, geom) - sub_node.geometry = geom_to_geojson(transformed_geom) - # ensure the crs is encoded in the sub-node, otherwise - # pygeofilter will assign it its own default CRS - authority, code = storage_crs.to_authority() - sub_node.geometry['crs'] = { - 'properties': { - 'name': f'urn:ogc:def:crs:{authority}::{code}' - } - } - else: - _inplace_transform_filter_geometries( - sub_node, filter_crs, storage_crs) - - -def _inplace_replace_geometry_filter_name( - node: pygeofilter.ast.Node, - geometry_column_name: str -): - """Recursively traverse node tree and rename nodes of type ``Attribute``. - - Nodes of type ``Attribute`` named ``geometry`` are renamed to the value of - the ``geometry_column_name`` parameter. - """ - try: - sub_nodes = node.get_sub_nodes() - except AttributeError: - pass - else: - for sub_node in sub_nodes: - is_attribute_node = isinstance(sub_node, pygeofilter.ast.Attribute) - if is_attribute_node and sub_node.name == "geometry": - sub_node.name = geometry_column_name - else: - _inplace_replace_geometry_filter_name( - sub_node, geometry_column_name) - - def get_from_headers(headers: dict, header_name: str) -> str: """ Gets case insensitive value from dictionary. diff --git a/tests/api/test_itemtypes.py b/tests/api/test_itemtypes.py index ecd89ba21..a601a5259 100644 --- a/tests/api/test_itemtypes.py +++ b/tests/api/test_itemtypes.py @@ -45,7 +45,8 @@ 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 pygeoapi.crs import get_crs +from pygeoapi.util import yaml_load from tests.util import get_test_file_path, mock_api_request @@ -459,7 +460,7 @@ def test_get_collection_items_crs(config, api_): assert code == HTTPStatus.OK assert rsp_headers['Content-Crs'] == f'<{crs}>' - # With CRS query parameter, using storageCrs + # With CRS query parameter, using storage_crs req = mock_api_request({'crs': storage_crs}) rsp_headers, code, response = get_collection_items( api_, req, 'norway_pop') @@ -507,7 +508,7 @@ def test_get_collection_items_crs(config, api_): # 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), + get_crs(default_crs), always_xy=False, ).transform for feat_orig in features_4258['features']: diff --git a/tests/load_tinydb_records.py b/tests/load_tinydb_records.py index fea6dced0..4c01fedf1 100644 --- a/tests/load_tinydb_records.py +++ b/tests/load_tinydb_records.py @@ -190,8 +190,6 @@ def get_anytext(bag: Union[list, str]) -> str: if isinstance(contact, CI_ResponsibleParty): providers.append(contact2party(contact)) - bbox_crs = 'http://www.opengis.net/def/crs/OGC/1.3/CRS84' - try: minx = float(m.identification[0].bbox.minx) miny = float(m.identification[0].bbox.miny) diff --git a/tests/other/test_crs.py b/tests/other/test_crs.py new file mode 100644 index 000000000..70ec0a46b --- /dev/null +++ b/tests/other/test_crs.py @@ -0,0 +1,373 @@ +# ================================================================= +# +# Authors: Tom Kralidis +# +# Copyright (c) 2025 Tom Kralidis +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation +# files (the "Software"), to deal in the Software without +# restriction, including without limitation the rights to use, +# copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following +# conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. +# +# ================================================================= + +from contextlib import nullcontext as does_not_raise + +import operator +import pytest +from pyproj.exceptions import CRSError +import pygeofilter.ast +from pygeofilter.parsers.ecql import parse +from pygeofilter.values import Geometry +from shapely.geometry import Point + +from pygeoapi import crs + + +def geojson_point(): + """Valid GeoJSON item for testing.""" + return { + 'type': 'Feature', + 'id': 'test_id', + 'geometry': { + 'type': 'Point', + 'coordinates': [77.037913, 38.928012] + }, + 'properties': {'name': 'Test Feature'} + } + + +def test_get_transform_from_crs(): + crs_in = crs.get_crs( + 'http://www.opengis.net/def/crs/EPSG/0/4258' + ) + crs_out = crs.get_crs( + 'http://www.opengis.net/def/crs/EPSG/0/25833' + ) + transform_func = crs.get_transform_from_crs(crs_in, crs_out) + p_in = Point((67.278972, 14.394493)) + p_out = Point((473901.6105, 7462606.8762)) + assert p_out.equals_exact(transform_func(p_in), 1e-3) + + +def test_get_supported_crs_list(): + DUTCH_CRS = 'http://www.opengis.net/def/crs/EPSG/0/28992' + + # Make various combinations of configs + CONFIGS = \ + [ + dict(), + {'crs': ['http://www.opengis.net/def/crs/OGC/1.3/CRS84']}, + {'crs': ['http://www.opengis.net/def/crs/OGC/1.3/CRS84h']}, + {'crs': ['http://www.opengis.net/def/crs/EPSG/0/4326', + 'http://www.opengis.net/def/crs/OGC/1.3/CRS84']}, + {'crs': ['http://www.opengis.net/def/crs/EPSG/0/4326', + DUTCH_CRS]}, + ] + # Apply all configs to util function + for config in CONFIGS: + crs_list = crs.get_supported_crs_list(config, crs.DEFAULT_CRS_LIST) + + # Whatever config: a default should be present + contains_default = False + for crs_ in crs_list: + if crs_ in crs.DEFAULT_CRS_LIST: + contains_default = True + assert contains_default + + # Extra CRSs supplied should also be present + if DUTCH_CRS in config: + assert DUTCH_CRS in crs_list + + +@pytest.mark.parametrize('uri, expected_raise, expected', [ + pytest.param('http://www.opengis.net/not/a/valid/crs/uri', pytest.raises(CRSError), None), # noqa + pytest.param('http://www.opengis.net/def/crs/EPSG/0/0', pytest.raises(CRSError), None), # noqa + pytest.param('http://www.opengis.net/def/crs/OGC/1.3/CRS84', does_not_raise(), 'OGC:CRS84'), # noqa + pytest.param('http://www.opengis.net/def/crs/OGC/1.3/CRS83', does_not_raise(), 'OGC:CRS83'), # noqa + pytest.param('http://www.opengis.net/def/crs/EPSG/0/4326', does_not_raise(), 'EPSG:4326'), # noqa + pytest.param('http://www.opengis.net/def/crs/EPSG/0/4269', does_not_raise(), 'EPSG:4269'), # noqa + pytest.param('http://www.opengis.net/def/crs/EPSG/0/28992', does_not_raise(), 'EPSG:28992'), # noqa + pytest.param('urn:ogc:def:crs:not:a:valid:crs:urn', pytest.raises(CRSError), None), # noqa + pytest.param('urn:ogc:def:crs:epsg:0:0', pytest.raises(CRSError), None), + pytest.param('urn:ogc:def:crs:epsg::0', pytest.raises(CRSError), None), + pytest.param('urn:ogc:def:crs:OGC::0', pytest.raises(CRSError), None), + pytest.param('urn:ogc:def:crs:OGC:0:0', pytest.raises(CRSError), None), + pytest.param('urn:ogc:def:crs:OGC:0:CRS84', does_not_raise(), 'OGC:CRS84'), + pytest.param('urn:ogc:def:crs:OGC::CRS84', does_not_raise(), 'OGC:CRS84'), + pytest.param('urn:ogc:def:crs:EPSG:0:4326', does_not_raise(), 'EPSG:4326'), + pytest.param('urn:ogc:def:crs:EPSG::4326', does_not_raise(), 'EPSG:4326'), + pytest.param('urn:ogc:def:crs:epsg:0:4326', does_not_raise(), 'EPSG:4326'), + pytest.param('urn:ogc:def:crs:epsg:0:28992', does_not_raise(), 'EPSG:28992'), # noqa +]) +def test_get_crs(uri, expected_raise, expected): + with expected_raise: + crs_ = crs.get_crs(uri) + assert crs_.srs.upper() == expected + + +@pytest.mark.parametrize('uri, expected_raise, expected', [ + pytest.param('http://www.opengis.net/not/a/valid/crs/uri', pytest.raises(CRSError), None), # noqa + pytest.param('http://www.opengis.net/def/crs/EPSG/0/0', pytest.raises(CRSError), None), # noqa + pytest.param('http://www.opengis.net/def/crs/OGC/1.3/CRS84', does_not_raise(), 4326), # noqa + pytest.param('http://www.opengis.net/def/crs/OGC/1.3/CRS83', does_not_raise(), 4269), # noqa + pytest.param('http://www.opengis.net/def/crs/EPSG/0/4326', does_not_raise(), 4326), # noqa + pytest.param('http://www.opengis.net/def/crs/EPSG/0/4269', does_not_raise(), 4269), # noqa + pytest.param('http://www.opengis.net/def/crs/EPSG/0/28992', does_not_raise(), 28992), # noqa + pytest.param('urn:ogc:def:crs:not:a:valid:crs:urn', pytest.raises(CRSError), None), # noqa + pytest.param('urn:ogc:def:crs:epsg:0:0', pytest.raises(CRSError), None), + pytest.param('urn:ogc:def:crs:epsg::0', pytest.raises(CRSError), None), + pytest.param('urn:ogc:def:crs:OGC::0', pytest.raises(CRSError), None), + pytest.param('urn:ogc:def:crs:OGC:0:0', pytest.raises(CRSError), None), + pytest.param('urn:ogc:def:crs:OGC:0:CRS84', does_not_raise(), 4326), + pytest.param('urn:ogc:def:crs:OGC::CRS84', does_not_raise(), 4326), + pytest.param('urn:ogc:def:crs:EPSG:0:4326', does_not_raise(), 4326), + pytest.param('urn:ogc:def:crs:EPSG::4326', does_not_raise(), 4326), + pytest.param('urn:ogc:def:crs:epsg:0:4326', does_not_raise(), 4326), + pytest.param('urn:ogc:def:crs:epsg:0:28992', does_not_raise(), 28992), +]) +def test_get_srid(uri, expected_raise, expected): + with expected_raise: + srid_ = crs.get_srid(uri) + assert srid_ == expected + + +@pytest.mark.parametrize('uri, expected_axis_order', [ + pytest.param('http://www.opengis.net/def/crs/OGC/1.3/CRS83', operator.eq), + pytest.param('http://www.opengis.net/def/crs/EPSG/0/4326', operator.ne), + pytest.param('http://www.opengis.net/def/crs/EPSG/0/4269', operator.ne), + pytest.param('http://www.opengis.net/def/crs/EPSG/0/28992', operator.eq), + pytest.param('http://www.opengis.net/def/crs/EPSG/0/4289', operator.ne) +]) +def test_always_xy(uri, expected_axis_order): + # pyproj respect URI Authority on axis order + provider_def = { + 'crs': [uri], + 'always_xy': False + } + transform_func = crs.get_transform_from_spec( + crs.create_crs_transform_spec(provider_def, uri) + ) + + feature = geojson_point() + crs.crs_transform_feature(feature, transform_func) + + # pyproj use always_xy, see: + # https://proj.org/en/stable/faq.html#why-is-the-axis-ordering-in-proj-not-consistent + provider_def['always_xy'] = True + feature_always_xy = geojson_point() + + transform_func_always_xy = crs.get_transform_from_spec( + crs.create_crs_transform_spec(provider_def, uri) + ) + crs.crs_transform_feature(feature_always_xy, transform_func_always_xy) + + assert expected_axis_order(feature, feature_always_xy) + + +def test_transform_bbox(): + # Use rounded values as fractions may differ + result = [59742, 446645, 129005, 557074] + + bbox = [4, 52, 5, 53] + from_crs = 'http://www.opengis.net/def/crs/OGC/1.3/CRS84' + to_crs = 'http://www.opengis.net/def/crs/EPSG/0/28992' + bbox_trans = crs.transform_bbox(bbox, from_crs, to_crs) + for n in range(4): + assert round(bbox_trans[n]) == result[n] + + bbox = [52, 4, 53, 5] + from_crs = 'http://www.opengis.net/def/crs/EPSG/0/4326' + bbox_trans = crs.transform_bbox(bbox, from_crs, to_crs) + for n in range(4): + assert round(bbox_trans[n]) == result[n] + + +@pytest.mark.parametrize('original_filter, filter_crs, storage_crs, geometry_colum_name, expected', [ # noqa + pytest.param( + 'INTERSECTS(geometry, POINT(1 1))', + 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', + None, + None, + pygeofilter.ast.GeometryIntersects( + pygeofilter.ast.Attribute(name='geometry'), + Geometry({'type': 'Point', 'coordinates': (1, 1)}) + ), + id='passthrough' + ), + pytest.param( + 'INTERSECTS(geometry, POINT(1 1))', + 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', + None, + 'custom_geom_name', + pygeofilter.ast.GeometryIntersects( + pygeofilter.ast.Attribute(name='custom_geom_name'), + Geometry({'type': 'Point', 'coordinates': (1, 1)}) + ), + id='unnested-geometry-name' + ), + pytest.param( + 'some_attribute = 10 AND INTERSECTS(geometry, POINT(1 1))', + 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', + None, + 'custom_geom_name', + pygeofilter.ast.And( + pygeofilter.ast.Equal( + pygeofilter.ast.Attribute(name='some_attribute'), 10), + pygeofilter.ast.GeometryIntersects( + pygeofilter.ast.Attribute(name='custom_geom_name'), + Geometry({'type': 'Point', 'coordinates': (1, 1)}) + ), + ), + id='nested-geometry-name' + ), + pytest.param( + '(some_attribute = 10 AND INTERSECTS(geometry, POINT(1 1))) OR ' + 'DWITHIN(geometry, POINT(2 2), 10, meters)', + 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', + None, + 'custom_geom_name', + pygeofilter.ast.Or( + pygeofilter.ast.And( + pygeofilter.ast.Equal( + pygeofilter.ast.Attribute(name='some_attribute'), 10), + pygeofilter.ast.GeometryIntersects( + pygeofilter.ast.Attribute(name='custom_geom_name'), + Geometry({'type': 'Point', 'coordinates': (1, 1)}) + ), + ), + pygeofilter.ast.DistanceWithin( + pygeofilter.ast.Attribute(name='custom_geom_name'), + Geometry({'type': 'Point', 'coordinates': (2, 2)}), + distance=10, + units='meters', + ) + ), + id='complex-filter-name' + ), + pytest.param( + 'INTERSECTS(geometry, POINT(12.512829 41.896698))', + 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', + 'http://www.opengis.net/def/crs/EPSG/0/3004', + None, + pygeofilter.ast.GeometryIntersects( + pygeofilter.ast.Attribute(name='geometry'), + Geometry({'type': 'Point', 'coordinates': (2313682.387730346, 4641308.550187246)}) # noqa + ), + id='unnested-geometry-transformed-coords' + ), + pytest.param( + 'some_attribute = 10 AND INTERSECTS(geometry, POINT(12.512829 41.896698))', # noqa + 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', + 'http://www.opengis.net/def/crs/EPSG/0/3004', + None, + pygeofilter.ast.And( + pygeofilter.ast.Equal( + pygeofilter.ast.Attribute(name='some_attribute'), 10), + pygeofilter.ast.GeometryIntersects( + pygeofilter.ast.Attribute(name='geometry'), + Geometry({'type': 'Point', 'coordinates': (2313682.387730346, 4641308.550187246)}) # noqa + ), + ), + id='nested-geometry-transformed-coords' + ), + pytest.param( + '(some_attribute = 10 AND INTERSECTS(geometry, POINT(12.512829 41.896698))) OR ' # noqa + 'DWITHIN(geometry, POINT(12 41), 10, meters)', + 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', + 'http://www.opengis.net/def/crs/EPSG/0/3004', + None, + pygeofilter.ast.Or( + pygeofilter.ast.And( + pygeofilter.ast.Equal( + pygeofilter.ast.Attribute(name='some_attribute'), 10), + pygeofilter.ast.GeometryIntersects( + pygeofilter.ast.Attribute(name='geometry'), + Geometry({'type': 'Point', 'coordinates': (2313682.387730346, 4641308.550187246)}) # noqa + ), + ), + pygeofilter.ast.DistanceWithin( + pygeofilter.ast.Attribute(name='geometry'), + Geometry({'type': 'Point', 'coordinates': (2267681.8892602, 4543101.513292163)}), # noqa + distance=10, + units='meters', + ) + ), + id='complex-filter-transformed-coords' + ), + pytest.param( + 'INTERSECTS(geometry, SRID=3857;POINT(1392921 5145517))', + 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', + 'http://www.opengis.net/def/crs/EPSG/0/3004', + None, + pygeofilter.ast.GeometryIntersects( + pygeofilter.ast.Attribute(name='geometry'), + Geometry({'type': 'Point', 'coordinates': (2313681.808628421, 4641307.939955416), 'crs': {'properties': {'name': 'urn:ogc:def:crs:EPSG::3004'}}}) # noqa + ), + id='unnested-geometry-transformed-coords-explicit-input-crs-ewkt' + ), + pytest.param( + 'INTERSECTS(geometry, POINT(1392921 5145517))', + 'http://www.opengis.net/def/crs/EPSG/0/3857', + 'http://www.opengis.net/def/crs/EPSG/0/3004', + None, + pygeofilter.ast.GeometryIntersects( + pygeofilter.ast.Attribute(name='geometry'), + Geometry({'type': 'Point', 'coordinates': (2313681.808628421, 4641307.939955416), 'crs': {'properties': {'name': 'urn:ogc:def:crs:EPSG::3004'}}}) # noqa + ), + id='unnested-geometry-transformed-coords-explicit-input-crs-filter-crs' + ), + pytest.param( + 'INTERSECTS(geometry, SRID=3857;POINT(1392921 5145517))', + 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', + 'http://www.opengis.net/def/crs/EPSG/0/3004', + None, + pygeofilter.ast.GeometryIntersects( + pygeofilter.ast.Attribute(name='geometry'), + Geometry({'type': 'Point', 'coordinates': (2313681.808628421, 4641307.939955416), 'crs': {'properties': {'name': 'urn:ogc:def:crs:EPSG::3004'}}}) # noqa + ), + id='unnested-geometry-transformed-coords-ewkt-crs-overrides-filter-crs' + ), + pytest.param( + 'INTERSECTS(geometry, POINT(12.512829 41.896698))', + 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', + 'http://www.opengis.net/def/crs/EPSG/0/3004', + 'custom_geom_name', + pygeofilter.ast.GeometryIntersects( + pygeofilter.ast.Attribute(name='custom_geom_name'), + Geometry({'type': 'Point', 'coordinates': (2313682.387730346, 4641308.550187246)}) # noqa + ), + id='unnested-geometry-name-and-transformed-coords' + ), +]) +def test_modify_pygeofilter( + original_filter, + filter_crs, + storage_crs, + geometry_colum_name, + expected +): + parsed_filter = parse(original_filter) + result = crs.modify_pygeofilter( + parsed_filter, + filter_crs_uri=filter_crs, + storage_crs_uri=storage_crs, + geometry_column_name=geometry_colum_name + ) + assert result == expected diff --git a/tests/other/test_util.py b/tests/other/test_util.py index e7570b756..6d3d70aa6 100644 --- a/tests/other/test_util.py +++ b/tests/other/test_util.py @@ -29,18 +29,12 @@ from datetime import datetime, date, time from decimal import Decimal -from contextlib import nullcontext as does_not_raise from copy import deepcopy from io import StringIO from unittest import mock import uuid import pytest -from pyproj.exceptions import CRSError -import pygeofilter.ast -from pygeofilter.parsers.ecql import parse -from pygeofilter.values import Geometry -from shapely.geometry import Point from pygeoapi import util from pygeoapi.api import __version__ @@ -270,95 +264,6 @@ def test_get_api_rules(config, config_with_rules): assert rules.get_url_prefix('django') == r'^test/' -def test_get_transform_from_crs(): - crs_in = util.get_crs_from_uri( - 'http://www.opengis.net/def/crs/EPSG/0/4258' - ) - crs_out = util.get_crs_from_uri( - 'http://www.opengis.net/def/crs/EPSG/0/25833' - ) - transform_func = util.get_transform_from_crs(crs_in, crs_out) - p_in = Point((67.278972, 14.394493)) - p_out = Point((473901.6105, 7462606.8762)) - assert p_out.equals_exact(transform_func(p_in), 1e-3) - - -def test_get_supported_crs_list(): - DEFAULT_CRS_LIST = [ - 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', - 'http://www.opengis.net/def/crs/OGC/1.3/CRS84h' - ] - DUTCH_CRS = 'http://www.opengis.net/def/crs/EPSG/0/28992' - - # Make various combinations of configs - CONFIGS = \ - [ - dict(), - {'crs': ['http://www.opengis.net/def/crs/OGC/1.3/CRS84']}, - {'crs': ['http://www.opengis.net/def/crs/OGC/1.3/CRS84h']}, - {'crs': ['http://www.opengis.net/def/crs/EPSG/0/4326', - 'http://www.opengis.net/def/crs/OGC/1.3/CRS84']}, - {'crs': ['http://www.opengis.net/def/crs/EPSG/0/4326', - DUTCH_CRS]}, - ] - # Apply all configs to util function - for config in CONFIGS: - crs_list = util.get_supported_crs_list(config, DEFAULT_CRS_LIST) - - # Whatever config: a default should be present - contains_default = False - for crs in crs_list: - if crs in DEFAULT_CRS_LIST: - contains_default = True - assert contains_default - - # Extra CRSs supplied should also be present - if DUTCH_CRS in config: - assert DUTCH_CRS in crs_list - - -@pytest.mark.parametrize('uri, expected_raise, expected', [ - pytest.param('http://www.opengis.net/not/a/valid/crs/uri', pytest.raises(CRSError), None), # noqa - pytest.param('http://www.opengis.net/def/crs/EPSG/0/0', pytest.raises(CRSError), None), # noqa - pytest.param('http://www.opengis.net/def/crs/OGC/1.3/CRS84', does_not_raise(), 'OGC:CRS84'), # noqa - pytest.param('http://www.opengis.net/def/crs/EPSG/0/4326', does_not_raise(), 'EPSG:4326'), # noqa - pytest.param('http://www.opengis.net/def/crs/EPSG/0/28992', does_not_raise(), 'EPSG:28992'), # noqa - pytest.param('urn:ogc:def:crs:not:a:valid:crs:urn', pytest.raises(CRSError), None), # noqa - pytest.param('urn:ogc:def:crs:epsg:0:0', pytest.raises(CRSError), None), - pytest.param('urn:ogc:def:crs:epsg::0', pytest.raises(CRSError), None), - pytest.param('urn:ogc:def:crs:OGC::0', pytest.raises(CRSError), None), - pytest.param('urn:ogc:def:crs:OGC:0:0', pytest.raises(CRSError), None), - pytest.param('urn:ogc:def:crs:OGC:0:CRS84', does_not_raise(), "OGC:CRS84"), - pytest.param('urn:ogc:def:crs:OGC::CRS84', does_not_raise(), "OGC:CRS84"), - pytest.param('urn:ogc:def:crs:EPSG:0:4326', does_not_raise(), "EPSG:4326"), - pytest.param('urn:ogc:def:crs:EPSG::4326', does_not_raise(), "EPSG:4326"), - pytest.param('urn:ogc:def:crs:epsg:0:4326', does_not_raise(), "EPSG:4326"), - pytest.param('urn:ogc:def:crs:epsg:0:28992', does_not_raise(), "EPSG:28992"), # noqa -]) -def test_get_crs_from_uri(uri, expected_raise, expected): - with expected_raise: - crs = util.get_crs_from_uri(uri) - assert crs.srs.upper() == expected - - -def test_transform_bbox(): - # Use rounded values as fractions may differ - result = [59742, 446645, 129005, 557074] - - bbox = [4, 52, 5, 53] - from_crs = 'http://www.opengis.net/def/crs/OGC/1.3/CRS84' - to_crs = 'http://www.opengis.net/def/crs/EPSG/0/28992' - bbox_trans = util.transform_bbox(bbox, from_crs, to_crs) - for n in range(4): - assert round(bbox_trans[n]) == result[n] - - bbox = [52, 4, 53, 5] - from_crs = 'http://www.opengis.net/def/crs/EPSG/0/4326' - bbox_trans = util.transform_bbox(bbox, from_crs, to_crs) - for n in range(4): - assert round(bbox_trans[n]) == result[n] - - def test_prefetcher(): prefetcher = util.UrlPrefetcher() assert prefetcher.get_headers('bad_url') == {} @@ -377,180 +282,6 @@ def test_prefetcher(): assert headers.get('content-type') == 'image/png' -@pytest.mark.parametrize('original_filter, filter_crs, storage_crs, geometry_colum_name, expected', [ # noqa - pytest.param( - 'INTERSECTS(geometry, POINT(1 1))', - 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', - None, - None, - pygeofilter.ast.GeometryIntersects( - pygeofilter.ast.Attribute(name='geometry'), - Geometry({'type': 'Point', 'coordinates': (1, 1)}) - ), - id='passthrough' - ), - pytest.param( - "INTERSECTS(geometry, POINT(1 1))", - 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', - None, - 'custom_geom_name', - pygeofilter.ast.GeometryIntersects( - pygeofilter.ast.Attribute(name='custom_geom_name'), - Geometry({'type': 'Point', 'coordinates': (1, 1)}) - ), - id='unnested-geometry-name' - ), - pytest.param( - "some_attribute = 10 AND INTERSECTS(geometry, POINT(1 1))", - 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', - None, - 'custom_geom_name', - pygeofilter.ast.And( - pygeofilter.ast.Equal( - pygeofilter.ast.Attribute(name='some_attribute'), 10), - pygeofilter.ast.GeometryIntersects( - pygeofilter.ast.Attribute(name='custom_geom_name'), - Geometry({'type': 'Point', 'coordinates': (1, 1)}) - ), - ), - id='nested-geometry-name' - ), - pytest.param( - "(some_attribute = 10 AND INTERSECTS(geometry, POINT(1 1))) OR " - "DWITHIN(geometry, POINT(2 2), 10, meters)", - 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', - None, - 'custom_geom_name', - pygeofilter.ast.Or( - pygeofilter.ast.And( - pygeofilter.ast.Equal( - pygeofilter.ast.Attribute(name='some_attribute'), 10), - pygeofilter.ast.GeometryIntersects( - pygeofilter.ast.Attribute(name='custom_geom_name'), - Geometry({'type': 'Point', 'coordinates': (1, 1)}) - ), - ), - pygeofilter.ast.DistanceWithin( - pygeofilter.ast.Attribute(name='custom_geom_name'), - Geometry({'type': 'Point', 'coordinates': (2, 2)}), - distance=10, - units='meters', - ) - ), - id='complex-filter-name' - ), - pytest.param( - "INTERSECTS(geometry, POINT(12.512829 41.896698))", - 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', - 'http://www.opengis.net/def/crs/EPSG/0/3004', - None, - pygeofilter.ast.GeometryIntersects( - pygeofilter.ast.Attribute(name='geometry'), - Geometry({'type': 'Point', 'coordinates': (2313682.387730346, 4641308.550187246)}) # noqa - ), - id='unnested-geometry-transformed-coords' - ), - pytest.param( - "some_attribute = 10 AND INTERSECTS(geometry, POINT(12.512829 41.896698))", # noqa - 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', - 'http://www.opengis.net/def/crs/EPSG/0/3004', - None, - pygeofilter.ast.And( - pygeofilter.ast.Equal( - pygeofilter.ast.Attribute(name='some_attribute'), 10), - pygeofilter.ast.GeometryIntersects( - pygeofilter.ast.Attribute(name='geometry'), - Geometry({'type': 'Point', 'coordinates': (2313682.387730346, 4641308.550187246)}) # noqa - ), - ), - id='nested-geometry-transformed-coords' - ), - pytest.param( - "(some_attribute = 10 AND INTERSECTS(geometry, POINT(12.512829 41.896698))) OR " # noqa - "DWITHIN(geometry, POINT(12 41), 10, meters)", - 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', - 'http://www.opengis.net/def/crs/EPSG/0/3004', - None, - pygeofilter.ast.Or( - pygeofilter.ast.And( - pygeofilter.ast.Equal( - pygeofilter.ast.Attribute(name='some_attribute'), 10), - pygeofilter.ast.GeometryIntersects( - pygeofilter.ast.Attribute(name='geometry'), - Geometry({'type': 'Point', 'coordinates': (2313682.387730346, 4641308.550187246)}) # noqa - ), - ), - pygeofilter.ast.DistanceWithin( - pygeofilter.ast.Attribute(name='geometry'), - Geometry({'type': 'Point', 'coordinates': (2267681.8892602, 4543101.513292163)}), # noqa - distance=10, - units='meters', - ) - ), - id='complex-filter-transformed-coords' - ), - pytest.param( - "INTERSECTS(geometry, SRID=3857;POINT(1392921 5145517))", - 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', - 'http://www.opengis.net/def/crs/EPSG/0/3004', - None, - pygeofilter.ast.GeometryIntersects( - pygeofilter.ast.Attribute(name='geometry'), - Geometry({'type': 'Point', 'coordinates': (2313681.808628421, 4641307.939955416), 'crs': {'properties': {'name': 'urn:ogc:def:crs:EPSG::3004'}}}) # noqa - ), - id='unnested-geometry-transformed-coords-explicit-input-crs-ewkt' - ), - pytest.param( - "INTERSECTS(geometry, POINT(1392921 5145517))", - 'http://www.opengis.net/def/crs/EPSG/0/3857', - 'http://www.opengis.net/def/crs/EPSG/0/3004', - None, - pygeofilter.ast.GeometryIntersects( - pygeofilter.ast.Attribute(name='geometry'), - Geometry({'type': 'Point', 'coordinates': (2313681.808628421, 4641307.939955416), 'crs': {'properties': {'name': 'urn:ogc:def:crs:EPSG::3004'}}}) # noqa - ), - id='unnested-geometry-transformed-coords-explicit-input-crs-filter-crs' - ), - pytest.param( - "INTERSECTS(geometry, SRID=3857;POINT(1392921 5145517))", - 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', - 'http://www.opengis.net/def/crs/EPSG/0/3004', - None, - pygeofilter.ast.GeometryIntersects( - pygeofilter.ast.Attribute(name='geometry'), - Geometry({'type': 'Point', 'coordinates': (2313681.808628421, 4641307.939955416), 'crs': {'properties': {'name': 'urn:ogc:def:crs:EPSG::3004'}}}) # noqa - ), - id='unnested-geometry-transformed-coords-ewkt-crs-overrides-filter-crs' - ), - pytest.param( - "INTERSECTS(geometry, POINT(12.512829 41.896698))", - 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', - 'http://www.opengis.net/def/crs/EPSG/0/3004', - 'custom_geom_name', - pygeofilter.ast.GeometryIntersects( - pygeofilter.ast.Attribute(name='custom_geom_name'), - Geometry({'type': 'Point', 'coordinates': (2313682.387730346, 4641308.550187246)}) # noqa - ), - id='unnested-geometry-name-and-transformed-coords' - ), -]) -def test_modify_pygeofilter( - original_filter, - filter_crs, - storage_crs, - geometry_colum_name, - expected -): - parsed_filter = parse(original_filter) - result = util.modify_pygeofilter( - parsed_filter, - filter_crs_uri=filter_crs, - storage_crs_uri=storage_crs, - geometry_column_name=geometry_colum_name - ) - assert result == expected - - def test_get_choice_from_headers(): _headers = { 'accept': 'text/html;q=0.5,application/ld+json', diff --git a/tests/provider/test_api_ogr_provider.py b/tests/provider/test_api_ogr_provider.py index 253356673..aa9718c6b 100644 --- a/tests/provider/test_api_ogr_provider.py +++ b/tests/provider/test_api_ogr_provider.py @@ -33,10 +33,11 @@ import logging import pytest +from shapely.geometry import shape as geojson_to_geom 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 pygeoapi.util import yaml_load from ..util import get_test_file_path, mock_api_request diff --git a/tests/provider/test_esri_provider.py b/tests/provider/test_esri_provider.py index 2138cdc08..b90b6cf1e 100644 --- a/tests/provider/test_esri_provider.py +++ b/tests/provider/test_esri_provider.py @@ -81,7 +81,7 @@ def test_geometry(config): results = p.query(skip_geometry=True) assert results['features'][0]['geometry'] is None - config['crs'] = 3857 + config['storage_crs'] = 'http://www.opengis.net/def/crs/EPSG/0/3857' p = ESRIServiceProvider(config) results = p.query() geometry = results['features'][0]['geometry'] diff --git a/tests/provider/test_ogr_gpkg_provider.py b/tests/provider/test_ogr_gpkg_provider.py index 87ad4f30f..4217ba525 100644 --- a/tests/provider/test_ogr_gpkg_provider.py +++ b/tests/provider/test_ogr_gpkg_provider.py @@ -50,8 +50,6 @@ def config_poi_portugal(): 'data': { 'source_type': 'GPKG', 'source': './tests/data/poi_portugal.gpkg', - # 'source_srs': 'EPSG:4326', - # 'target_srs': 'EPSG:4326', 'source_capabilities': { 'paging': True }, @@ -105,8 +103,6 @@ def config_gpkg_4326(): 'source_type': 'GPKG', 'source': './tests/data/dutch_addresses_4326.gpkg', - # 'source_srs': 'EPSG:4326', - # 'target_srs': 'EPSG:4326', 'source_capabilities': { 'paging': True }, @@ -126,8 +122,6 @@ def config_gpkg_28992(): 'source_type': 'GPKG', 'source': './tests/data/dutch_addresses_28992.gpkg', - # 'source_srs': 'EPSG:28992', - # 'target_srs': 'EPSG:4326', 'source_capabilities': { 'paging': True }, @@ -136,7 +130,7 @@ def config_gpkg_28992(): 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', 'http://www.opengis.net/def/crs/EPSG/0/28992' ], - 'storageCrs': 'http://www.opengis.net/def/crs/EPSG/0/28992', + 'storage_crs': 'http://www.opengis.net/def/crs/EPSG/0/28992', 'id_field': 'id', 'layer': 'OGRGeoJSON' } diff --git a/tests/provider/test_ogr_shapefile_provider.py b/tests/provider/test_ogr_shapefile_provider.py index 26d618f61..25512a6b4 100644 --- a/tests/provider/test_ogr_shapefile_provider.py +++ b/tests/provider/test_ogr_shapefile_provider.py @@ -35,12 +35,11 @@ import pytest import pyproj +from shapely.geometry import shape as geojson_to_geom +from pygeoapi.crs import CrsTransformSpec, get_transform_from_crs from pygeoapi.provider.base import ProviderItemNotFoundError from pygeoapi.provider.ogr import OGRProvider -from pygeoapi.util import ( - CrsTransformSpec, get_transform_from_crs, geojson_to_geom, -) LOGGER = logging.getLogger(__name__) diff --git a/tests/provider/test_ogr_wfs_provider.py b/tests/provider/test_ogr_wfs_provider.py index 6535913ab..86e66ece0 100644 --- a/tests/provider/test_ogr_wfs_provider.py +++ b/tests/provider/test_ogr_wfs_provider.py @@ -53,8 +53,6 @@ def config_MapServer_WFS_cities(): 'data': { 'source_type': 'WFS', 'source': 'WFS:https://www.example.com/wfs', - # 'source_srs': 'EPSG:4326', - # 'target_srs': 'EPSG:4326', 'source_capabilities': { 'paging': True }, diff --git a/tests/provider/test_ogr_wfs_provider_live.py b/tests/provider/test_ogr_wfs_provider_live.py index 492e4831b..a0c33b1a9 100644 --- a/tests/provider/test_ogr_wfs_provider_live.py +++ b/tests/provider/test_ogr_wfs_provider_live.py @@ -57,8 +57,6 @@ def config_MapServer_WFS_cities(): 'data': { 'source_type': 'WFS', 'source': 'WFS:https://demo.mapserver.org/cgi-bin/wfs', - # 'source_srs': 'EPSG:4326', - # 'target_srs': 'EPSG:4326', 'source_capabilities': { 'paging': True }, @@ -88,8 +86,6 @@ def config_MapServer_WFS_continents(): 'data': { 'source_type': 'WFS', 'source': 'WFS:https://demo.mapserver.org/cgi-bin/wfs', - # 'source_srs': 'EPSG:4326', - # 'target_srs': 'EPSG:4326', 'source_capabilities': { 'paging': True }, @@ -120,8 +116,6 @@ def config_geosol_gs_WFS(): 'source_type': 'WFS', 'source': 'WFS:https://gs-stable.geosolutionsgroup.com/geoserver/wfs?', - # 'source_srs': 'EPSG:32632', - # 'target_srs': 'EPSG:4326', 'source_capabilities': { 'paging': True }, @@ -140,7 +134,7 @@ def config_geosol_gs_WFS(): 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', 'http://www.opengis.net/def/crs/EPSG/0/32632' ], - 'storageCrs': 'http://www.opengis.net/def/crs/EPSG/0/32632', + 'storage_crs': 'http://www.opengis.net/def/crs/EPSG/0/32632', 'id_field': 'gml_id', 'layer': 'unesco:Unesco_point', } @@ -155,8 +149,6 @@ def config_geonode_gs_WFS(): 'source_type': 'WFS', 'source': 'WFS:https://geonode.wfp.org/geoserver/wfs', - # 'source_srs': 'EPSG:4326', - # 'target_srs': 'EPSG:4326', 'source_capabilities': { 'paging': True }, diff --git a/tests/provider/test_postgresql_provider.py b/tests/provider/test_postgresql_provider.py index 42ceb0b3a..c8cb8140e 100644 --- a/tests/provider/test_postgresql_provider.py +++ b/tests/provider/test_postgresql_provider.py @@ -40,11 +40,12 @@ # See pygeoapi/provider/postgresql.py for instructions on setting up # test database in Docker +from http import HTTPStatus import os import json import pytest import pyproj -from http import HTTPStatus +from shapely.geometry import shape as geojson_to_geom from pygeofilter.parsers.ecql import parse @@ -52,6 +53,7 @@ from pygeoapi.api.itemtypes import ( get_collection_items, get_collection_item, manage_collection_item ) +from pygeoapi.crs import DEFAULT_CRS, get_transform_from_crs, get_crs from pygeoapi.provider.base import ( ProviderConnectionError, ProviderItemNotFoundError, @@ -60,13 +62,11 @@ from pygeoapi.provider.sql import PostgreSQLProvider import pygeoapi.provider.sql as postgresql_provider_module -from pygeoapi.util import (yaml_load, geojson_to_geom, - get_transform_from_crs, get_crs_from_uri) +from pygeoapi.util import yaml_load 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' @pytest.fixture() @@ -767,9 +767,8 @@ def test_get_collection_items_postgresql_crs(pg_api_): break transform_func = get_transform_from_crs( - get_crs_from_uri(DEFAULT_CRS), - pyproj.CRS.from_epsg(32735), - always_xy=False, + get_crs(DEFAULT_CRS), + pyproj.CRS.from_epsg(32735) ) # Check that the coordinates of returned features were transformed for feat_orig in features_orig['features']: @@ -841,7 +840,7 @@ def test_get_collection_item_postgresql_crs(pg_api_): geom_32735 = geojson_to_geom(feat_32735['geometry']) transform_func = get_transform_from_crs( - get_crs_from_uri(DEFAULT_CRS), + get_crs(DEFAULT_CRS), pyproj.CRS.from_epsg(32735), always_xy=False, )