diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 8d0adcf9d..12e949426 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -80,6 +80,11 @@ jobs: with: packages: libsqlite3-mod-spatialite version: 4.3.0a-6build1 + - name: Install GDAL with Python bindings + uses: awalsh128/cache-apt-pkgs-action@latest + with: + packages: gdal-bin libgdal-dev + version: 3.0.4 - name: Install requirements 📦 run: | pip3 install -r requirements.txt @@ -89,6 +94,7 @@ jobs: python3 setup.py install pip3 install --upgrade numpy elasticsearch pip3 install --upgrade numpy "sqlalchemy<2" + pip3 install --global-option=build_ext --global-option="-I/usr/include/gdal" GDAL==`gdal-config --version` #pip3 install --upgrade rasterio==1.1.8 - name: setup test data ⚙️ run: | @@ -102,6 +108,7 @@ jobs: POSTGRESQL_PASSWORD: ${{ secrets.DatabasePassword || 'postgres' }} run: | pytest tests/test_api.py + pytest tests/test_api_ogr_provider.py pytest tests/test_config.py pytest tests/test_csv__formatter.py pytest tests/test_csv__provider.py @@ -110,12 +117,12 @@ jobs: pytest tests/test_filesystem_provider.py pytest tests/test_geojson_provider.py pytest tests/test_mongo_provider.py - #pytest tests/test_ogr_csv_provider.py - #pytest tests/test_ogr_esrijson_provider.py - #pytest tests/test_ogr_gpkg_provider.py - #pytest tests/test_ogr_shapefile_provider.py - #pytest tests/test_ogr_sqlite_provider.py - #pytest tests/test_ogr_wfs_provider.py + pytest tests/test_ogr_csv_provider.py + pytest tests/test_ogr_esrijson_provider.py + pytest tests/test_ogr_gpkg_provider.py + pytest tests/test_ogr_shapefile_provider.py + pytest tests/test_ogr_sqlite_provider.py + pytest tests/test_ogr_wfs_provider.py pytest tests/test_openapi.py pytest tests/test_postgresql_provider.py pytest tests/test_rasterio_provider.py diff --git a/.gitignore b/.gitignore index a0b9c1fb9..97c5b8970 100644 --- a/.gitignore +++ b/.gitignore @@ -115,3 +115,9 @@ ENV/ .DS_Store examples/django/sample_project/db.sqlite3 + +# Backup files +*~ + +# Pre-commit hooks config file +.pre-commit-config.yaml diff --git a/aws-lambda/function/pygeoapi-config.yml b/aws-lambda/function/pygeoapi-config.yml index 7f6dd5b50..388f6b1b1 100644 --- a/aws-lambda/function/pygeoapi-config.yml +++ b/aws-lambda/function/pygeoapi-config.yml @@ -296,8 +296,8 @@ resources: data: source_type: WFS source: WFS:https://service.pdok.nl/kadaster/rdinfo/wfs/v1_0? - source_srs: EPSG:28992 - target_srs: EPSG:4326 +# source_srs: EPSG:28992 +# target_srs: EPSG:4326 source_capabilities: paging: True @@ -312,6 +312,12 @@ resources: # GDAL_PROXY_AUTH: (optional auth for remote WFS) CPL_DEBUG: NO + crs: + - http://www.opengis.net/def/crs/OGC/1.3/CRS84 + - http://www.opengis.net/def/crs/EPSG/0/4326 + - http://www.opengis.net/def/crs/EPSG/0/4258 + - http://www.opengis.net/def/crs/EPSG/0/28992 + storage_crs: http://www.opengis.net/def/crs/EPSG/0/28992 id_field: gml_id layer: rdinfo:stations diff --git a/docker/default.config.yml b/docker/default.config.yml index a63203ae3..2ac3ebae1 100644 --- a/docker/default.config.yml +++ b/docker/default.config.yml @@ -211,8 +211,8 @@ resources: data: source_type: WFS source: WFS:https://service.pdok.nl/kadaster/rdinfo/wfs/v1_0? - source_srs: EPSG:28992 - target_srs: EPSG:4326 +# source_srs: EPSG:28992 +# target_srs: EPSG:4326 source_capabilities: paging: True @@ -227,6 +227,10 @@ resources: # GDAL_PROXY_AUTH: (optional auth for remote WFS) CPL_DEBUG: NO + crs: + - http://www.opengis.net/def/crs/EPSG/0/4258 + - http://www.opengis.net/def/crs/EPSG/0/28992 + storage_crs: http://www.opengis.net/def/crs/EPSG/0/28992 id_field: gml_id layer: rdinfo:stations @@ -258,8 +262,8 @@ resources: data: source_type: WFS source: WFS:http://demo.deegree.org/utah-workspace/services/wfs?TYPENAME=app:SGID93_LOCATION_UDOTMap_CityLocations - source_srs: EPSG:26912 - target_srs: EPSG:4326 +# source_srs: EPSG:26912 +# target_srs: EPSG:4326 source_capabilities: paging: True @@ -274,6 +278,10 @@ resources: # GDAL_PROXY_AUTH: (optional auth for remote WFS) CPL_DEBUG: NO + crs: + - http://www.opengis.net/def/crs/EPSG/0/4258 + - http://www.opengis.net/def/crs/EPSG/0/26912 + storage_crs: http://www.opengis.net/def/crs/EPSG/0/26912 id_field: NAME layer: app:SGID93_LOCATION_UDOTMap_CityLocations @@ -304,8 +312,8 @@ resources: data: source_type: WFS source: WFS:https://gs-stable.geosolutionsgroup.com/geoserver/wfs - source_srs: EPSG:32632 - target_srs: EPSG:4326 +# source_srs: EPSG:32632 +# target_srs: EPSG:4326 source_capabilities: paging: True @@ -320,6 +328,10 @@ resources: # GDAL_PROXY_AUTH: (optional auth for remote WFS) CPL_DEBUG: NO + crs: + - http://www.opengis.net/def/crs/EPSG/0/4258 + - 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 @@ -355,8 +367,8 @@ resources: data: source_type: GPKG source: tests/data/poi_portugal.gpkg - source_srs: EPSG:4326 - target_srs: EPSG:4326 +# source_srs: EPSG:4326 +# target_srs: EPSG:4326 source_capabilities: paging: True @@ -395,8 +407,8 @@ resources: data: source_type: GeoJSON source: tests/data/ne_110m_lakes.geojson - source_srs: EPSG:4326 - target_srs: EPSG:4326 +# source_srs: EPSG:4326 +# target_srs: EPSG:4326 source_capabilities: paging: True @@ -438,8 +450,8 @@ resources: source_type: SQLite # source: tests/data/ne_110m_admin_0_countries.sqlite source: tests/data/dutch_addresses_4326.sqlite - source_srs: EPSG:4326 - target_srs: EPSG:4326 +# source_srs: EPSG:4326 +# target_srs: EPSG:4326 source_capabilities: paging: True @@ -480,8 +492,8 @@ resources: data: source_type: GPKG source: tests/data/dutch_addresses_4326.gpkg - source_srs: EPSG:4326 - target_srs: EPSG:4326 +# source_srs: EPSG:4326 +# target_srs: EPSG:4326 source_capabilities: paging: True diff --git a/docs/source/configuration.rst b/docs/source/configuration.rst index 8f3dd8427..a577ee09f 100644 --- a/docs/source/configuration.rst +++ b/docs/source/configuration.rst @@ -161,8 +161,8 @@ default. - observations - monitoring linked-data: # linked data configuration (see Linked Data section) - item_template: tests/data/base.jsonld - context: + item_template: tests/data/base.jsonld + context: - datetime: https://schema.org/DateTime - vocab: https://example.com/vocab# stn_id: "vocab:stn_id" @@ -195,6 +195,13 @@ default. 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 + crs: # optional: supported CRSs for parameters 'crs' and 'bbox-crs' (OGC OAPIF Part 2) + # default: http://www.opengis.net/def/crs/OGC/1.3/CRS84 + - http://www.opengis.net/def/crs/EPSG/0/4326 + - http://www.opengis.net/def/crs/EPSG/0/3857 + - http://www.opengis.net/def/crs/EPSG/0/28992 + 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 @@ -203,6 +210,14 @@ default. properties: # optional: only return the following properties, in order - stn_id - value + # 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) + storage_crs: http://www.opengis.net/def/crs/EPSG/0/28992 # CRS of the dataset to publish + crs: # supported coordinate reference systems (CRS) for 'crs' query parameter + - 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 hello-world: # name of process type: collection # REQUIRED (collection, process, or stac-collection) @@ -570,12 +585,12 @@ deployment flexibility, the path can be specified with string interpolation of e .. code-block:: yaml linked-data: - item_template: tests/data/base.jsonld + item_template: tests/data/base.jsonld context: - datetime: https://schema.org/DateTime .. note:: - The template ``tests/data/base.jsonld`` renders the unmodified JSON-LD. For more information on the capacities + The template ``tests/data/base.jsonld`` renders the unmodified JSON-LD. For more information on the capacities of Jinja2 templates, see :ref:`html-templating`. Summary diff --git a/docs/source/crs.rst b/docs/source/crs.rst new file mode 100644 index 000000000..d2abc463a --- /dev/null +++ b/docs/source/crs.rst @@ -0,0 +1,248 @@ +.. _crs: + +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. + +Configuration +------------- + +For details visit the :ref:`configuration` section for Feature Providers. At this moment only the 'URI' CRS notation format is supported. + + +* `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 + + +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. + +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 configured CRSs, or their defaults, `crs` and `storageCRS` and optionally `storageCrsCoordinateEpoch` will be present in the "Describe Collection" response. + +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) + +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). + +An HTTP Header named `Content-Crs` specifies the CRS for returned Feature-coordinates as +according to the "OGC API - Features - Part 2" standard. For example: + +`Content-Crs: `. + +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. + +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 +according to the `storage_crs` configuration. Then the (transformed) `bbox` 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 +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. + +Feature Providers, like CSV for example, that do not (yet) support coordinate transformation provide a 'flag' +that triggers pygeoapi to perform the transformation on the Provider response data. +Details: this is effected through a Python Decorator `@crs_transform` on the Provider functions `query()` and `get()`. +By removing that flag, Providers may later move transformation to their internal implementation. + + +Examples +-------- + +Suppose an addresses collection with the following CRS support in its collection metadata: + +.. code-block:: bash + + + curl 'http://localhost:5000/collections/dutch_addresses_4326?f=json' + + . + . + + "crs": [ + "http://www.opengis.net/def/crs/EPSG/0/4326", + "http://www.opengis.net/def/crs/EPSG/0/3857", + "http://www.opengis.net/def/crs/EPSG/0/28992", + "http://www.opengis.net/def/crs/OGC/1.3/CRS84" + ], + "storageCRS": "http://www.opengis.net/def/crs/OGC/1.3/CRS84" + + +This allows a `bbox-crs` query using Dutch "RD" coordinates with CRS `http://www.opengis.net/def/crs/EPSG/0/28992` to retrieve +for example a single address. Note that the URIs are URL-encoded, +This is sometimes required in `curl` commands but when entering in a browser, plain text can be used. +Though `curl` may also understand non-encoded URLs when using single quotes around the complete URL. + +.. code-block:: bash + + curl 'http://localhost:5000/collections/dutch_addresses_4326/items?f=json&bbox-crs=http%3A%2F%2Fwww.opengis.net%2Fdef%2Fcrs%2FEPSG%2F0%2F28992&bbox=177430,459268,177440,459278' + # or plain URL + curl 'http://localhost:5000/collections/dutch_addresses_4326/items?f=json&bbox-crs=http://www.opengis.net/def/crs/EPSG/0/28992&bbox=177430,459268,177440,459278' + + # response fragment + { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 5.714846709450305, + 52.12122746454743 + ] + }, + "properties": { + "straatnaam": "Willinkhuizersteeg", + "huisnummer": "2", + "huisletter": "C", + "woonplaats": "Wekerom", + "postcode": "6733EB", + "toevoeging": null + }, + "id": "inspireadressen.1742212" + } + ], + "links": [ + . + . + +You can also use a WGS84 equivalent with lat/lon axis order as in CRS `http://www.opengis.net/def/crs/EPSG/0/4326`. + +.. code-block:: bash + + curl 'http://localhost:5000/collections/dutch_addresses_4326/items?f=json&bbox-crs=http%3A%2F%2Fwww.opengis.net%2Fdef%2Fcrs%2FEPSG%2F0%2F4326&bbox=52.12122,5.71484,52.12123,5.71486' + + # response fragment + { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 5.714846709450305, + 52.12122746454743 + ] + }, + "properties": { + "straatnaam": "Willinkhuizersteeg", + "huisnummer": "2", + "huisletter": "C", + "woonplaats": "Wekerom", + "postcode": "6733EB", + "toevoeging": null + }, + "id": "inspireadressen.1742212" + } + ], + "links": [ + . + . + +Using the `crs` parameter you can retrieve the data within the bbox in a different CRS like +`http://www.opengis.net/def/crs/EPSG/0/28992`. The `bbox` is assumed to specified in the Storage CRS `http://www.opengis.net/def/crs/OGC/1.3/CRS84`. + +.. code-block:: bash + + curl 'http://localhost:5000/collections/dutch_addresses_4326/items?f=json&crs=http%3A%2F%2Fwww.opengis.net%2Fdef%2Fcrs%2FEPSG%2F0%2F28992&bbox=5.71484,52.12122,5.71486,52.12123' + # or plain URL + curl 'http://localhost:5000/collections/dutch_addresses_4326/items?f=json&crs=http://www.opengis.net/def/crs/EPSG/0/28992&bbox=5.71484,52.12122,5.71486,52.12123' + + # response fragment + { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 177439.0002001376, + 459273.9995615507 + ] + }, + "properties": { + "straatnaam": "Willinkhuizersteeg", + "huisnummer": "2", + "huisletter": "C", + "woonplaats": "Wekerom", + "postcode": "6733EB", + "toevoeging": null + }, + "id": "inspireadressen.1742212" + } + ], + "links": [ + . + . + + +Or you may specify both `crs` and `bbox-crs` and thus `bbox` in that CRS `http://www.opengis.net/def/crs/EPSG/0/28992`. + +.. code-block:: bash + + curl 'http://localhost:5000/collections/dutch_addresses_4326/items?f=json&crs=http%3A%2F%2Fwww.opengis.net%2Fdef%2Fcrs%2FEPSG%2F0%2F28992&bbox-crs=http%3A%2F%2Fwww.opengis.net%2Fdef%2Fcrs%2FEPSG%2F0%2F28992&bbox=177430,459268,177440,459278' + # or plain URL + curl 'http://localhost:5000/collections/dutch_addresses_4326/items?f=json&crs=http://www.opengis.net/def/crs/EPSG/0/28992&bbox-crs=http://www.opengis.net/def/crs/EPSG/0/28992&bbox=177430,459268,177440,459278' + + # response fragment + { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + 177439.0002001376, + 459273.9995615507 + ] + }, + "properties": { + "straatnaam": "Willinkhuizersteeg", + "huisnummer": "2", + "huisletter": "C", + "woonplaats": "Wekerom", + "postcode": "6733EB", + "toevoeging": null + }, + "id": "inspireadressen.1742212" + } + ], + "links": [ + . + . + +.. _`OGC API - Features - Part 2: Coordinate Reference Systems by Reference corrigendum`: https://docs.opengeospatial.org/is/18-058r1/18-058r1.html diff --git a/docs/source/data-publishing/ogcapi-features.rst b/docs/source/data-publishing/ogcapi-features.rst index 5e36d0d36..9db0e2aa3 100644 --- a/docs/source/data-publishing/ogcapi-features.rst +++ b/docs/source/data-publishing/ogcapi-features.rst @@ -14,27 +14,37 @@ Providers pygeoapi core feature providers are listed below, along with a matrix of supported query parameters. + .. csv-table:: - :header: Provider, property filters/display, resulttype, bbox, datetime, sortby, skipGeometry, CQL, transactions + :header: Provider, property filters/display, resulttype, bbox, datetime, sortby, skipGeometry, CQL, transactions, crs :align: left - `CSV`_,✅/✅,results/hits,❌,❌,❌,✅,❌,❌ - `Elasticsearch`_,✅/✅,results/hits,✅,✅,✅,✅,✅,✅ - `ESRI Feature Service`_,✅/✅,results/hits,✅,✅,✅,✅,❌,❌ - `GeoJSON`_,✅/✅,results/hits,❌,❌,❌,✅,❌,❌ - `MongoDB`_,✅/❌,results,✅,✅,✅,✅,❌,❌ - `OGR`_,✅/❌,results/hits,✅,❌,❌,✅,❌,❌ - `PostgreSQL`_,✅/✅,results/hits,✅,✅,✅,✅,✅,❌ - `SQLiteGPKG`_,✅/❌,results/hits,✅,❌,❌,✅,❌,❌ - `SensorThings API`_,✅/✅,results/hits,✅,✅,✅,✅,❌,❌ - `Socrata`_,✅/✅,results/hits,✅,✅,✅,✅,❌,❌ + `CSV`_,✅/✅,results/hits,❌,❌,❌,✅,❌,❌,✅ + `Elasticsearch`_,✅/✅,results/hits,✅,✅,✅,✅,✅,✅,✅ + `ESRI Feature Service`_,✅/✅,results/hits,✅,✅,✅,✅,❌,❌,✅ + `GeoJSON`_,✅/✅,results/hits,❌,❌,❌,✅,❌,❌,✅ + `MongoDB`_,✅/❌,results,✅,✅,✅,✅,❌,❌,✅ + `OGR`_,✅/❌,results/hits,✅,❌,❌,✅,❌,❌,✅n + `PostgreSQL`_,✅/✅,results/hits,✅,✅,✅,✅,✅,❌,✅n + `SQLiteGPKG`_,✅/❌,results/hits,✅,❌,❌,✅,❌,❌,✅ + `SensorThings API`_,✅/✅,results/hits,✅,✅,✅,✅,❌,❌,✅ + `Socrata`_,✅/✅,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: '✅n'. -Below are specific connection examples based on supported providers. 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 ^^^ @@ -52,6 +62,11 @@ definition. geometry: x_field: long y_field: lat + crs: + - 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/EPSG/0/28992 GeoJSON @@ -118,7 +133,7 @@ specify the URL for the service layer in the ``data`` field. data: https://sampleserver5.arcgisonline.com/arcgis/rest/services/NYTimes_Covid19Cases_USCounties/MapServer/0 id_field: objectid time_field: date_in_your_device_time_zone # Optional time field - crs: 4326 # Optional crs (default is ESPG:4326) + crs: 4326 # Optional crs (default is EPSG:4326) username: username # Optional ArcGIS username password: password # Optional ArcGIS password @@ -129,9 +144,9 @@ OGR .. note:: Requires Python package gdal -`GDAL/OGR `_ supports a wide range of spatial file formats, such as shapefile, dxf, gpx, kml, +`GDAL/OGR `_ supports a wide range of spatial file formats, such as shapefile, dxf, gpx, kml, but also services such as WFS. Read the full list and configuration options at https://gdal.org/drivers/vector. -Additional formats and features are available via the `virtual format `_, +Additional formats and features are available via the `virtual format `_, use this driver for example for flat database files (CSV). The OGR provider requires a recent (3+) version of GDAL to be installed. @@ -169,9 +184,15 @@ The OGR provider requires a recent (3+) version of GDAL to be installed. GDAL_HTTP_PROXY: (optional proxy) GDAL_PROXY_AUTH: (optional auth for remote WFS) CPL_DEBUG: NO + crs: + - http://www.opengis.net/def/crs/OGC/1.3/CRS84 + - http://www.opengis.net/def/crs/EPSG/0/4326 + - http://www.opengis.net/def/crs/EPSG/0/4258 + - http://www.opengis.net/def/crs/EPSG/0/28992 + storage_crs: http://www.opengis.net/def/crs/EPSG/0/28992 id_field: gml_id layer: rdinfo:stations - + .. code-block:: yaml providers: @@ -180,8 +201,6 @@ The OGR provider requires a recent (3+) version of GDAL to be installed. data: source_type: ESRIJSON source: https://map.bgs.ac.uk/arcgis/rest/services/GeoIndex_Onshore/boreholes/MapServer/0/query?where=BGS_ID+%3D+BGS_ID&outfields=*&orderByFields=BGS_ID+ASC&f=json - source_srs: EPSG:27700 - target_srs: EPSG:4326 source_capabilities: paging: True open_options: @@ -203,12 +222,22 @@ The OGR provider requires a recent (3+) version of GDAL to be installed. data: source_type: PostgreSQL source: "PG: host=127.0.0.1 dbname=test user=postgres password=postgres" - source_srs: EPSG:4326 - target_srs: EPSG:4326 # Can be used to transform/reproject the data id_field: osm_id layer: osm.hotosm_bdi_waterways # Value follows a 'my_schema.my_table' structure geom_field: foo_geom +.. note:: + NB: Formerly the config parameters ``source_srs`` and ``target_srs`` could be used to + transform/reproject the data for every request. Starting with pygeoapi release 0.15.0 these fields are no longer supported. + Reason is that pygeoapi now supports CRS-handling as per the OGC API Features Standard "Part 2". + `storage_crs`: is basically the same as `source_crs` but complying with standards (and axis ordering!) + It should be set to the actual or default CRS of the source data/service. When omitted the default http://www.opengis.net/def/crs/OGC/1.3/CRS84 + if assumed. + `crs` is an array of supported CRSs, also the same default applies when omitted. + The `crs` or `bbox-crs` query parameter can now be used and must be present in the `crs` array (or + the default applies). + The `crs` query parameter is used as follows: + e.g. ``http://localhost:5000/collections/foo/items?crs=http%3A%2F%2Fwww.opengis.net%2Fdef%2Fcrs%2FEPSG%2F0%2F28992``. MongoDB @@ -239,9 +268,9 @@ PostgreSQL .. note:: Requires Python packages sqlalchemy, geoalchemy2 and psycopg2-binary -Must have PostGIS installed. +Must have PostGIS installed. -.. note:: +.. note:: Geometry must be using EPSG:4326 .. code-block:: yaml @@ -251,7 +280,7 @@ Must have PostGIS installed. name: PostgreSQL data: host: 127.0.0.1 - port: 3010 # Default 5432 if not provided + port: 3010 # Default 5432 if not provided dbname: test user: postgres password: postgres @@ -316,22 +345,22 @@ GeoPackage file: SensorThings API ^^^^^^^^^^^^^^^^ -The STA provider is capable of creating feature collections from OGC SensorThings -API endpoints. Three of the STA entities are configurable: Things, Datastreams, and -Observations. For a full description of the SensorThings entity model, see -`here `_. +The STA provider is capable of creating feature collections from OGC SensorThings +API endpoints. Three of the STA entities are configurable: Things, Datastreams, and +Observations. For a full description of the SensorThings entity model, see +`here `_. For each entity of ``Things``, pygeoapi will expand all entities directly related to -the ``Thing``, including its associated ``Location``, from which the -geometry for the feature collection is derived. Similarly, ``Datastreams`` are expanded to -include the associated ``Thing``, ``Sensor`` and ``ObservedProperty``. +the ``Thing``, including its associated ``Location``, from which the +geometry for the feature collection is derived. Similarly, ``Datastreams`` are expanded to +include the associated ``Thing``, ``Sensor`` and ``ObservedProperty``. -The default id_field is ``@iot.id``. The STA provider adds one required field, -``entity``, and an optional field, ``intralink``. The ``entity`` field refers to -which STA entity to use for the feature collection. The ``intralink`` field controls +The default id_field is ``@iot.id``. The STA provider adds one required field, +``entity``, and an optional field, ``intralink``. The ``entity`` field refers to +which STA entity to use for the feature collection. The ``intralink`` field controls how the provider is acted upon by other STA providers and is by default, False. -If ``intralink`` is true for an adjacent STA provider collection within a -pygeoapi instance, the expanded entity is instead represented by an intra-pygeoapi -link to the other entity or it's ``uri_field`` if declared. +If ``intralink`` is true for an adjacent STA provider collection within a +pygeoapi instance, the expanded entity is instead represented by an intra-pygeoapi +link to the other entity or it's ``uri_field`` if declared. .. code-block:: yaml @@ -340,14 +369,14 @@ link to the other entity or it's ``uri_field`` if declared. name: SensorThings data: https://sensorthings-wq.brgm-rec.fr/FROST-Server/v1.0/ uri_field: uri - entity: Datastreams + entity: Datastreams time_field: phenomenonTime intralink: true -If all three entities are configured, the STA provider will represent a complete STA -endpoint as OGC-API feature collections. The ``Things`` features will include links -to the associated features in the ``Datastreams`` feature collection, and the -``Observations`` features will include links to the associated features in the +If all three entities are configured, the STA provider will represent a complete STA +endpoint as OGC-API feature collections. The ``Things`` features will include links +to the associated features in the ``Datastreams`` feature collection, and the +``Observations`` features will include links to the associated features in the ``Datastreams`` feature collection. Examples with three entities configured are included in the docker examples for SensorThings. @@ -407,6 +436,8 @@ Data access examples * http://localhost:5000/collections/foo/items?f=csv * query features (spatial) * http://localhost:5000/collections/foo/items?bbox=-180,-90,180,90 +* query features (spatial with bbox-crs) + * http://localhost:5000/collections/foo/items?bbox=120000,450000,130000,460000&bbox-crs=http%3A%2F%2Fwww.opengis.net%2Fdef%2Fcrs%2FEPSG%2F0%2F28992 * query features (attribute) * http://localhost:5000/collections/foo/items?propertyname=foo * query features (temporal) @@ -415,8 +446,20 @@ Data access examples * http://localhost:5000/collections/foo/items?datetime=2020-04-10T14:11:00Z&sortby=+datetime * query features (temporal) and sort descending by a property * http://localhost:5000/collections/foo/items?datetime=2020-04-10T14:11:00Z&sortby=-datetime +* query features in a given (and supported) CRS + * http://localhost:5000/collections/foo/items?crs=http%3A%2F%2Fwww.opengis.net%2Fdef%2Fcrs%2FEPSG%2F0%2F32633 +* query features in a given bounding BBOX and return in given CRS + * http://localhost:5000/collections/foo/items?bbox=120000,450000,130000,460000&bbox-crs=http%3A%2F%2Fwww.opengis.net%2Fdef%2Fcrs%2FEPSG%2F0%2F28992&crs=http%3A%2F%2Fwww.opengis.net%2Fdef%2Fcrs%2FEPSG%2F0%2F32633 * fetch a specific feature * http://localhost:5000/collections/foo/items/123 +* fetch a specific feature in a given (and supported) CRS + * http://localhost:5000/collections/foo/items/123?crs=http%3A%2F%2Fwww.opengis.net%2Fdef%2Fcrs%2FEPSG%2F0%2F32633 + +.. note:: + when no ``crs`` and/or ``bbox-crs`` is provided, the default CRS http://www.opengis.net/def/crs/OGC/1.3/CRS84 (WGS84 in lon, lat ordering) is assumed. + pygeoapi may perform the necessary transformations if the ``storage_crs`` differs from this default. Features are then always returned in + that default CRS (as per the GeoJSON Standard). + In all cases, weather or not these query parameters are supplied, the HTTP Header ``Content-Crs`` denotes the CRS of the Feature(s) in the response. .. note:: ``.../items`` queries which return an alternative representation to GeoJSON (which prompt a download) diff --git a/docs/source/index.rst b/docs/source/index.rst index 6e3e63d99..d709efb57 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -38,6 +38,7 @@ reference documentation on all aspects of the project. transactions plugins html-templating + crs cql language development diff --git a/docs/source/plugins.rst b/docs/source/plugins.rst index 97dfe3680..83bfd698c 100644 --- a/docs/source/plugins.rst +++ b/docs/source/plugins.rst @@ -166,6 +166,8 @@ Each base class documents the functions, arguments and return types required for .. note:: You can add language support to your plugin using :ref:`these guides`. +.. note:: You can let the pygeoapi core do coordinate transformation for `crs` queries using the `@crs_transform` Decorator on `query()` and `get()` methods. See :ref:`crs`. + Example: custom pygeoapi raster data provider --------------------------------------------- diff --git a/pygeoapi/api.py b/pygeoapi/api.py index 143db16f0..4b183efee 100644 --- a/pygeoapi/api.py +++ b/pygeoapi/api.py @@ -46,13 +46,14 @@ import json import logging import re -from typing import Any, Tuple, Union +from typing import Any, Tuple, Union, Optional import urllib.parse import uuid from dateutil.parser import parse as dateparse from pygeofilter.parsers.ecql import parse as parse_ecql_text from pygeofilter.parsers.cql_json import parse as parse_cql_json +from pyproj.exceptions import CRSError import pytz from shapely.errors import WKTReadingError from shapely.wkt import loads as shapely_loads @@ -78,7 +79,9 @@ filter_dict_by_key_value, get_provider_by_type, get_provider_default, get_typed_value, JobStatus, json_serial, render_j2_template, str2bool, - TEMPLATES, to_json, get_api_rules, get_base_url) + TEMPLATES, to_json, get_api_rules, get_base_url, + get_crs_from_uri, get_supported_crs_list, + CrsTransformSpec, transform_bbox) from pygeoapi.models.provider.base import TilesMetadataFormat @@ -120,6 +123,7 @@ 'http://www.opengis.net/spec/ogcapi-features-1/1.0/req/oas30', 'http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/html', 'http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/geojson', + 'http://www.opengis.net/spec/ogcapi-features-2/1.0/conf/crs', 'http://www.opengis.net/spec/ogcapi-features-4/1.0/conf/create-replace-delete' # noqa ], 'coverage': [ @@ -158,6 +162,14 @@ 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 pre_process(func): """ @@ -1028,6 +1040,13 @@ def describe_collections(self, request: Union[APIRequest, Any], 'href': f'{self.get_collections_url()}/{k}/items?f={F_HTML}' # noqa }) + # OAPIF Part 2 - list supported CRSs and StorageCRS + if collection_data_type == 'feature': + collection['crs'] = get_supported_crs_list(collection_data, DEFAULT_CRS_LIST) # noqa + 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 + elif collection_data_type == 'coverage': # TODO: translate LOGGER.debug('Adding coverage based links') @@ -1347,8 +1366,8 @@ def get_collection_items( **self.api_headers) properties = [] - reserved_fieldnames = ['bbox', 'f', 'lang', 'limit', 'offset', - 'resulttype', 'datetime', 'sortby', + reserved_fieldnames = ['bbox', 'bbox-crs', 'crs', 'f', 'lang', 'limit', + 'offset', 'resulttype', 'datetime', 'sortby', 'properties', 'skipGeometry', 'q', 'filter', 'filter-lang'] @@ -1431,14 +1450,17 @@ def get_collection_items( LOGGER.debug('Loading provider') + provider_def = None try: + provider_type = 'feature' provider_def = get_provider_by_type( - collections[dataset]['providers'], 'feature') + collections[dataset]['providers'], provider_type) p = load_plugin('provider', provider_def) except ProviderTypeError: try: + provider_type = 'record' provider_def = get_provider_by_type( - collections[dataset]['providers'], 'record') + collections[dataset]['providers'], provider_type) p = load_plugin('provider', provider_def) except ProviderTypeError: msg = 'Invalid provider type' @@ -1456,6 +1478,63 @@ def get_collection_items( HTTPStatus.INTERNAL_SERVER_ERROR, headers, request.format, 'NoApplicableCode', msg) + crs_transform_spec = None + if provider_type == 'feature': + # crs query parameter is only available for OGC API - Features + # right now, not for OGC API - Records. + LOGGER.debug('Processing crs parameter') + query_crs_uri = request.params.get('crs') + try: + crs_transform_spec = self._create_crs_transform_spec( + provider_def, query_crs_uri, + ) + except (ValueError, CRSError) as err: + msg = str(err) + return self.get_exception( + HTTPStatus.BAD_REQUEST, headers, request.format, + 'InvalidParameterValue', msg) + self._set_content_crs_header(headers, provider_def, query_crs_uri) + + LOGGER.debug('Processing bbox-crs parameter') + bbox_crs = request.params.get('bbox-crs') + if bbox_crs is not None: + # Validate bbox-crs parameter + if len(bbox) == 0: + msg = 'bbox-crs specified without bbox parameter' + return self.get_exception( + HTTPStatus.BAD_REQUEST, headers, request.format, + 'NoApplicableCode', msg) + + if len(bbox_crs) == 0: + msg = 'bbox-crs specified but is empty' + return self.get_exception( + HTTPStatus.BAD_REQUEST, headers, request.format, + 'NoApplicableCode', msg) + + supported_crs_list = get_supported_crs_list(provider_def, DEFAULT_CRS_LIST) # noqa + if bbox_crs not in supported_crs_list: + msg = f'bbox-crs {bbox_crs} not supported for this collection' + return self.get_exception( + HTTPStatus.BAD_REQUEST, headers, request.format, + 'NoApplicableCode', msg) + elif len(bbox) > 0: + # bbox but no bbox-crs parm: assume bbox is in default CRS + bbox_crs = DEFAULT_CRS + + # Transform bbox to storageCRS + # when bbox-crs different from storageCRS. + 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 + + # Do the (optional) Transform to the Storage CRS + bbox = transform_bbox(bbox, bbox_crs, storage_crs) + except CRSError as e: + return self.get_exception( + HTTPStatus.BAD_REQUEST, headers, request.format, + 'NoApplicableCode', str(e)) + LOGGER.debug('processing property parameters') for k, v in request.params.items(): if k not in reserved_fieldnames and k in list(p.fields.keys()): @@ -1540,6 +1619,8 @@ def get_collection_items( LOGGER.debug(f'resulttype: {resulttype}') LOGGER.debug(f'sortby: {sortby}') LOGGER.debug(f'bbox: {bbox}') + if provider_type == 'feature': + LOGGER.debug(f'crs: {query_crs_uri}') LOGGER.debug(f'datetime: {datetime_}') LOGGER.debug(f'properties: {properties}') LOGGER.debug(f'select properties: {select_properties}') @@ -1553,9 +1634,9 @@ def get_collection_items( content = p.query(offset=offset, limit=limit, resulttype=resulttype, bbox=bbox, datetime_=datetime_, properties=properties, - sortby=sortby, + sortby=sortby, skip_geometry=skip_geometry, select_properties=select_properties, - skip_geometry=skip_geometry, + crs_transform_spec=crs_transform_spec, q=q, language=prv_locale, filterq=filter_) except ProviderConnectionError as err: LOGGER.error(err) @@ -2142,26 +2223,59 @@ def get_collection_item(self, request: Union[APIRequest, Any], LOGGER.debug('Loading provider') try: + provider_type = 'feature' provider_def = get_provider_by_type( - collections[dataset]['providers'], 'feature') + collections[dataset]['providers'], provider_type) p = load_plugin('provider', provider_def) except ProviderTypeError: try: + provider_type = 'record' provider_def = get_provider_by_type( - collections[dataset]['providers'], 'record') + collections[dataset]['providers'], provider_type) p = load_plugin('provider', provider_def) except ProviderTypeError: msg = 'Invalid provider type' return self.get_exception( HTTPStatus.BAD_REQUEST, headers, request.format, 'InvalidParameterValue', msg) + except ProviderConnectionError: + msg = 'connection error (check logs)' + return self.get_exception( + HTTPStatus.INTERNAL_SERVER_ERROR, headers, request.format, + 'NoApplicableCode', msg) + except ProviderQueryError: + msg = 'query error (check logs)' + return self.get_exception( + HTTPStatus.INTERNAL_SERVER_ERROR, headers, request.format, + 'NoApplicableCode', msg) + + crs_transform_spec = None + if provider_type == 'feature': + # crs query parameter is only available for OGC API - Features + # right now, not for OGC API - Records. + LOGGER.debug('Processing crs parameter') + query_crs_uri = request.params.get('crs') + try: + crs_transform_spec = self._create_crs_transform_spec( + provider_def, query_crs_uri, + ) + except (ValueError, CRSError) as err: + msg = str(err) + return self.get_exception( + HTTPStatus.BAD_REQUEST, headers, request.format, + 'InvalidParameterValue', msg) + self._set_content_crs_header(headers, provider_def, query_crs_uri) # Get provider language (if any) prv_locale = l10n.get_plugin_locale(provider_def, request.raw_locale) try: LOGGER.debug(f'Fetching id {identifier}') - content = p.get(identifier, language=prv_locale) + content = p.get( + identifier, + language=prv_locale, + crs_transform_spec=crs_transform_spec, + ) except ProviderConnectionError as err: LOGGER.error(err) msg = 'connection error (check logs)' @@ -3944,6 +4058,99 @@ def get_format_exception(self, request) -> Tuple[dict, int, str]: def get_collections_url(self): return f"{self.base_url}/collections" + @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}>' + def validate_bbox(value=None) -> list: """ diff --git a/pygeoapi/openapi.py b/pygeoapi/openapi.py index e74963fb2..af1563030 100644 --- a/pygeoapi/openapi.py +++ b/pygeoapi/openapi.py @@ -52,7 +52,8 @@ LOGGER = logging.getLogger(__name__) OPENAPI_YAML = { - 'oapif': 'https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml', # noqa + 'oapif-1': 'https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml', # noqa + 'oapif-2': 'https://schemas.opengis.net/ogcapi/features/part2/1.0/openapi/ogcapi-features-2.yaml', # noqa 'oapip': 'https://schemas.opengis.net/ogcapi/processes/part1/1.0/openapi', 'oacov': 'https://raw.githubusercontent.com/tomkralidis/ogcapi-coverages-1/fix-cis/yaml-unresolved', # noqa 'oapit': 'https://raw.githubusercontent.com/opengeospatial/ogcapi-tiles/master/openapi/swaggerhub/tiles.yaml', # noqa @@ -144,7 +145,8 @@ def get_oas_30(cfg): api_rules = get_api_rules(cfg) osl = get_ogc_schemas_location(cfg['server']) - OPENAPI_YAML['oapif'] = os.path.join(osl, 'ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml') # noqa + OPENAPI_YAML['oapif-1'] = os.path.join(osl, 'ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml') # noqa + OPENAPI_YAML['oapif-2'] = os.path.join(osl, 'ogcapi/features/part2/1.0/openapi/ogcapi-features-2.yaml') # noqa LOGGER.debug('setting up server info') oas = { @@ -186,9 +188,9 @@ def get_oas_30(cfg): {'$ref': '#/components/parameters/lang'} ], 'responses': { - '200': {'$ref': f"{OPENAPI_YAML['oapif']}#/components/responses/LandingPage"}, # noqa - '400': {'$ref': f"{OPENAPI_YAML['oapif']}#/components/responses/InvalidParameter"}, # noqa - '500': {'$ref': f"{OPENAPI_YAML['oapif']}#/components/responses/ServerError"} # noqa + '200': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/LandingPage"}, # noqa + '400': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/InvalidParameter"}, # noqa + '500': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/ServerError"} # noqa } } } @@ -218,7 +220,7 @@ def get_oas_30(cfg): ], 'responses': { '200': {'$ref': '#/components/responses/200'}, - '400': {'$ref': f"{OPENAPI_YAML['oapif']}#/components/responses/InvalidParameter"}, # noqa + '400': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/InvalidParameter"}, # noqa 'default': {'$ref': '#/components/responses/default'} } } @@ -235,9 +237,9 @@ def get_oas_30(cfg): {'$ref': '#/components/parameters/lang'} ], 'responses': { - '200': {'$ref': f"{OPENAPI_YAML['oapif']}#/components/responses/LandingPage"}, # noqa - '400': {'$ref': f"{OPENAPI_YAML['oapif']}#/components/responses/InvalidParameter"}, # noqa - '500': {'$ref': f"{OPENAPI_YAML['oapif']}#/components/responses/ServerError"} # noqa + '200': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/LandingPage"}, # noqa + '400': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/InvalidParameter"}, # noqa + '500': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/ServerError"} # noqa } } } @@ -253,9 +255,9 @@ def get_oas_30(cfg): {'$ref': '#/components/parameters/lang'} ], 'responses': { - '200': {'$ref': f"{OPENAPI_YAML['oapif']}#/components/responses/LandingPage"}, # noqa - '400': {'$ref': f"{OPENAPI_YAML['oapif']}#/components/responses/InvalidParameter"}, # noqa - '500': {'$ref': f"{OPENAPI_YAML['oapif']}#/components/responses/ServerError"} # noqa + '200': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/LandingPage"}, # noqa + '400': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/InvalidParameter"}, # noqa + '500': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/ServerError"} # noqa } } } @@ -343,7 +345,54 @@ def get_oas_30(cfg): 'default': False } }, + 'crs': { + 'name': 'crs', + 'in': 'query', + 'description': 'Indicates the coordinate reference system for the results.', # noqa + 'style': 'form', + 'required': False, + 'explode': False, + 'schema': { + 'format': 'uri', + 'type': 'string' + } + }, + 'bbox': { + 'name': 'bbox', + 'in': 'query', + 'description': 'Only features that have a geometry that intersects the bounding box are selected.' # noqa + 'The bounding box is provided as four or six numbers, depending on whether the ' # noqa + 'coordinate reference system includes a vertical axis (height or depth).', # noqa + 'required': False, + 'style': 'form', + 'explode': False, + 'schema': { + 'type': 'array', + 'minItems': 4, + 'maxItems': 6, + 'items': { + 'type': 'number' + } + } + }, 'bbox-crs': { + 'name': 'bbox-crs', + 'in': 'query', + 'description': 'Indicates the coordinate reference system for the given bbox coordinates.', # noqa + 'style': 'form', + 'required': False, + 'explode': False, + 'schema': { + 'format': 'uri', + 'type': 'string' + } + }, + # FIXME: This is not compatible with the bbox-crs definition in + # OGCAPI Features Part 2! + # We need to change the mapscript provider and + # get_collection_map() method in the API! + # So this is for de map-provider only. + 'bbox-crs-epsg': { 'name': 'bbox-crs', 'in': 'query', 'description': 'Indicates the EPSG for the given bbox coordinates.', # noqa @@ -475,10 +524,10 @@ def get_oas_30(cfg): {'$ref': '#/components/parameters/lang'} ], 'responses': { - '200': {'$ref': f"{OPENAPI_YAML['oapif']}#/components/responses/Collection"}, # noqa - '400': {'$ref': f"{OPENAPI_YAML['oapif']}#/components/responses/InvalidParameter"}, # noqa - '404': {'$ref': f"{OPENAPI_YAML['oapif']}#/components/responses/NotFound"}, # noqa - '500': {'$ref': f"{OPENAPI_YAML['oapif']}#/components/responses/ServerError"} # noqa + '200': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/Collection"}, # noqa + '400': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/InvalidParameter"}, # noqa + '404': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/NotFound"}, # noqa + '500': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/ServerError"} # noqa } } } @@ -513,8 +562,10 @@ def get_oas_30(cfg): 'parameters': [ items_f, items_l, - {'$ref': f"{OPENAPI_YAML['oapif']}#/components/parameters/bbox"}, # noqa - {'$ref': f"{OPENAPI_YAML['oapif']}#/components/parameters/limit"}, # noqa + {'$ref': '#/components/parameters/bbox'}, + {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/parameters/limit"}, # noqa + {'$ref': '#/components/parameters/crs'}, # noqa + {'$ref': '#/components/parameters/bbox-crs'}, # noqa coll_properties, {'$ref': '#/components/parameters/vendorSpecificParameters'}, # noqa {'$ref': '#/components/parameters/skipGeometry'}, @@ -522,10 +573,10 @@ def get_oas_30(cfg): {'$ref': '#/components/parameters/offset'}, ], 'responses': { - '200': {'$ref': f"{OPENAPI_YAML['oapif']}#/components/responses/Features"}, # noqa - '400': {'$ref': f"{OPENAPI_YAML['oapif']}#/components/responses/InvalidParameter"}, # noqa - '404': {'$ref': f"{OPENAPI_YAML['oapif']}#/components/responses/NotFound"}, # noqa - '500': {'$ref': f"{OPENAPI_YAML['oapif']}#/components/responses/ServerError"} # noqa + '200': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/Features"}, # noqa + '400': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/InvalidParameter"}, # noqa + '404': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/NotFound"}, # noqa + '500': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/ServerError"} # noqa } } } @@ -549,8 +600,8 @@ def get_oas_30(cfg): }, 'responses': { '201': {'description': 'Successful creation'}, - '400': {'$ref': f"{OPENAPI_YAML['oapif']}#/components/responses/InvalidParameter"}, # noqa - '500': {'$ref': f"{OPENAPI_YAML['oapif']}#/components/responses/ServerError"} # noqa + '400': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/InvalidParameter"}, # noqa + '500': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/ServerError"} # noqa } } @@ -580,16 +631,16 @@ def get_oas_30(cfg): ], 'responses': { '200': {'$ref': '#/components/responses/Queryables'}, # noqa - '400': {'$ref': f"{OPENAPI_YAML['oapif']}#/components/responses/InvalidParameter"}, # noqa - '404': {'$ref': f"{OPENAPI_YAML['oapif']}#/components/responses/NotFound"}, # noqa - '500': {'$ref': f"{OPENAPI_YAML['oapif']}#/components/responses/ServerError"}, # noqa + '400': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/InvalidParameter"}, # noqa + '404': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/NotFound"}, # noqa + '500': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/ServerError"}, # noqa } } } if p.time_field is not None: paths[items_path]['get']['parameters'].append( - {'$ref': f"{OPENAPI_YAML['oapif']}#/components/parameters/datetime"}) # noqa + {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/parameters/datetime"}) # noqa for field, type_ in p.fields.items(): @@ -636,15 +687,16 @@ def get_oas_30(cfg): 'tags': [name], 'operationId': f'get{name.capitalize()}Feature', 'parameters': [ - {'$ref': f"{OPENAPI_YAML['oapif']}#/components/parameters/featureId"}, # noqa + {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/parameters/featureId"}, # noqa + {'$ref': '#/components/parameters/crs'}, # noqa {'$ref': '#/components/parameters/f'}, {'$ref': '#/components/parameters/lang'} ], 'responses': { - '200': {'$ref': f"{OPENAPI_YAML['oapif']}#/components/responses/Feature"}, # noqa - '400': {'$ref': f"{OPENAPI_YAML['oapif']}#/components/responses/InvalidParameter"}, # noqa - '404': {'$ref': f"{OPENAPI_YAML['oapif']}#/components/responses/NotFound"}, # noqa - '500': {'$ref': f"{OPENAPI_YAML['oapif']}#/components/responses/ServerError"} # noqa + '200': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/Feature"}, # noqa + '400': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/InvalidParameter"}, # noqa + '404': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/NotFound"}, # noqa + '500': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/ServerError"} # noqa } } } @@ -670,7 +722,7 @@ def get_oas_30(cfg): 'tags': [name], 'operationId': f'update{name.capitalize()}Features', 'parameters': [ - {'$ref': f"{OPENAPI_YAML['oapif']}#/components/parameters/featureId"} # noqa + {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/parameters/featureId"} # noqa ], 'requestBody': { 'description': 'Updates item in collection', @@ -683,8 +735,8 @@ def get_oas_30(cfg): }, 'responses': { '204': {'description': 'Successful update'}, - '400': {'$ref': f"{OPENAPI_YAML['oapif']}#/components/responses/InvalidParameter"}, # noqa - '500': {'$ref': f"{OPENAPI_YAML['oapif']}#/components/responses/ServerError"} # noqa + '400': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/InvalidParameter"}, # noqa + '500': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/ServerError"} # noqa } } @@ -702,12 +754,12 @@ def get_oas_30(cfg): 'tags': [name], 'operationId': f'delete{name.capitalize()}Features', 'parameters': [ - {'$ref': f"{OPENAPI_YAML['oapif']}#/components/parameters/featureId"}, # noqa + {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/parameters/featureId"}, # noqa ], 'responses': { '200': {'description': 'Successful delete'}, - '400': {'$ref': f"{OPENAPI_YAML['oapif']}#/components/responses/InvalidParameter"}, # noqa - '500': {'$ref': f"{OPENAPI_YAML['oapif']}#/components/responses/ServerError"} # noqa + '400': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/InvalidParameter"}, # noqa + '500': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/ServerError"} # noqa } } @@ -730,14 +782,14 @@ def get_oas_30(cfg): 'parameters': [ items_f, items_l, - {'$ref': f"{OPENAPI_YAML['oapif']}#/components/parameters/bbox"}, # noqa - {'$ref': '#/components/parameters/bbox-crs'} + {'$ref': '#/components/parameters/bbox'}, + {'$ref': '#/components/parameters/bbox-crs'}, # noqa ], 'responses': { - '200': {'$ref': f"{OPENAPI_YAML['oapif']}#/components/responses/Features"}, # noqa - '400': {'$ref': f"{OPENAPI_YAML['oapif']}#/components/responses/InvalidParameter"}, # noqa - '404': {'$ref': f"{OPENAPI_YAML['oapif']}#/components/responses/NotFound"}, # noqa - '500': {'$ref': f"{OPENAPI_YAML['oapif']}#/components/responses/ServerError"} # noqa + '200': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/Features"}, # noqa + '400': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/InvalidParameter"}, # noqa + '404': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/NotFound"}, # noqa + '500': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/ServerError"} # noqa } } } @@ -756,9 +808,9 @@ def get_oas_30(cfg): ], 'responses': { '200': {'$ref': f"{OPENAPI_YAML['oacov']}/schemas/cis_1.1/domainSet.yaml"}, # noqa - '400': {'$ref': f"{OPENAPI_YAML['oapif']}#/components/responses/InvalidParameter"}, # noqa - '404': {'$ref': f"{OPENAPI_YAML['oapif']}#/components/responses/NotFound"}, # noqa - '500': {'$ref': f"{OPENAPI_YAML['oapif']}#/components/responses/ServerError"} # noqa + '400': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/InvalidParameter"}, # noqa + '404': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/NotFound"}, # noqa + '500': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/ServerError"} # noqa } } } @@ -777,9 +829,9 @@ def get_oas_30(cfg): ], 'responses': { '200': {'$ref': f"{OPENAPI_YAML['oacov']}/schemas/cis_1.1/rangeType.yaml"}, # noqa - '400': {'$ref': f"{OPENAPI_YAML['oapif']}#/components/responses/InvalidParameter"}, # noqa - '404': {'$ref': f"{OPENAPI_YAML['oapif']}#/components/responses/NotFound"}, # noqa - '500': {'$ref': f"{OPENAPI_YAML['oapif']}#/components/responses/ServerError"} # noqa + '400': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/InvalidParameter"}, # noqa + '404': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/NotFound"}, # noqa + '500': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/ServerError"} # noqa } } } @@ -855,9 +907,9 @@ def get_oas_30(cfg): ], 'responses': { '200': {'$ref': '#/components/responses/Tiles'}, - '400': {'$ref': f"{OPENAPI_YAML['oapif']}#/components/responses/InvalidParameter"}, # noqa - '404': {'$ref': f"{OPENAPI_YAML['oapif']}#/components/responses/NotFound"}, # noqa - '500': {'$ref': f"{OPENAPI_YAML['oapif']}#/components/responses/ServerError"} # noqa + '400': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/InvalidParameter"}, # noqa + '404': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/NotFound"}, # noqa + '500': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/ServerError"} # noqa } } } @@ -890,9 +942,9 @@ def get_oas_30(cfg): } ], 'responses': { - '400': {'$ref': f"{OPENAPI_YAML['oapif']}#/components/responses/InvalidParameter"}, # noqa - '404': {'$ref': f"{OPENAPI_YAML['oapif']}#/components/responses/NotFound"}, # noqa - '500': {'$ref': f"{OPENAPI_YAML['oapif']}#/components/responses/ServerError"} # noqa + '400': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/InvalidParameter"}, # noqa + '404': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/NotFound"}, # noqa + '500': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/ServerError"} # noqa } } } @@ -940,7 +992,7 @@ def get_oas_30(cfg): 'operationId': eqe['op_id'], 'parameters': [ {'$ref': f"{OPENAPI_YAML['oaedr']}/parameters/{eqe['qt']}Coords.yaml"}, # noqa - {'$ref': f"{OPENAPI_YAML['oapif']}#/components/parameters/datetime"}, # noqa + {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/parameters/datetime"}, # noqa {'$ref': f"{OPENAPI_YAML['oaedr']}/parameters/parameter-name.yaml"}, # noqa {'$ref': f"{OPENAPI_YAML['oaedr']}/parameters/z.yaml"}, # noqa {'$ref': '#/components/parameters/f'} @@ -979,8 +1031,8 @@ def get_oas_30(cfg): 'tags': [k], 'operationId': 'getMap', 'parameters': [ - {'$ref': f"{OPENAPI_YAML['oapif']}#/components/parameters/bbox"}, # noqa - {'$ref': f"{OPENAPI_YAML['oapif']}#/components/parameters/datetime"}, # noqa + {'$ref': '#/components/parameters/bbox'}, + {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/parameters/datetime"}, # noqa { 'name': 'width', 'in': 'query', @@ -1015,7 +1067,7 @@ def get_oas_30(cfg): 'style': 'form', 'explode': False }, - {'$ref': '#/components/parameters/bbox-crs'}, + {'$ref': '#/components/parameters/bbox-crs-epsg'}, map_f ], 'responses': { @@ -1025,14 +1077,14 @@ def get_oas_30(cfg): 'application/json': {} } }, - '400': {'$ref': f"{OPENAPI_YAML['oapif']}#/components/responses/InvalidParameter"}, # noqa - '500': {'$ref': f"{OPENAPI_YAML['oapif']}#/components/responses/ServerError"}, # noqa + '400': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/InvalidParameter"}, # noqa + '500': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/ServerError"}, # noqa } } } if mp.time_field is not None: paths[pth]['get']['parameters'].append( - {'$ref': f"{OPENAPI_YAML['oapif']}#/components/parameters/datetime"}) # noqa + {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/parameters/datetime"}) # noqa LOGGER.debug('setting up STAC') stac_collections = filter_dict_by_key_value(cfg['resources'], diff --git a/pygeoapi/provider/csv_.py b/pygeoapi/provider/csv_.py index 10e8dbe72..88d56c56a 100644 --- a/pygeoapi/provider/csv_.py +++ b/pygeoapi/provider/csv_.py @@ -34,7 +34,7 @@ from pygeoapi.provider.base import (BaseProvider, ProviderQueryError, ProviderItemNotFoundError) -from pygeoapi.util import get_typed_value +from pygeoapi.util import get_typed_value, crs_transform LOGGER = logging.getLogger(__name__) @@ -183,6 +183,7 @@ def _load(self, offset=0, limit=10, resulttype='results', return feature_collection + @crs_transform def query(self, offset=0, limit=10, resulttype='results', bbox=[], datetime_=None, properties=[], sortby=[], select_properties=[], skip_geometry=False, q=None, **kwargs): @@ -208,6 +209,7 @@ def query(self, offset=0, limit=10, resulttype='results', select_properties=select_properties, skip_geometry=skip_geometry) + @crs_transform def get(self, identifier, **kwargs): """ query CSV id diff --git a/pygeoapi/provider/elasticsearch_.py b/pygeoapi/provider/elasticsearch_.py index fc026af02..e007b97bb 100644 --- a/pygeoapi/provider/elasticsearch_.py +++ b/pygeoapi/provider/elasticsearch_.py @@ -41,7 +41,7 @@ ProviderQueryError, ProviderItemNotFoundError) from pygeoapi.models.cql import CQLModel, get_next_node -from pygeoapi.util import get_envelope +from pygeoapi.util import get_envelope, crs_transform LOGGER = logging.getLogger(__name__) @@ -130,6 +130,7 @@ def get_fields(self): return fields_ + @crs_transform def query(self, offset=0, limit=10, resulttype='results', bbox=[], datetime_=None, properties=[], sortby=[], select_properties=[], skip_geometry=False, q=None, @@ -345,6 +346,7 @@ def query(self, offset=0, limit=10, resulttype='results', return feature_collection + @crs_transform def get(self, identifier, **kwargs): """ Get ES document by id diff --git a/pygeoapi/provider/esri.py b/pygeoapi/provider/esri.py index 2fe48a923..0d22a8805 100644 --- a/pygeoapi/provider/esri.py +++ b/pygeoapi/provider/esri.py @@ -34,7 +34,7 @@ from pygeoapi.provider.base import (BaseProvider, ProviderConnectionError, ProviderTypeError, ProviderQueryError) -from pygeoapi.util import format_datetime +from pygeoapi.util import format_datetime, crs_transform LOGGER = logging.getLogger(__name__) @@ -106,6 +106,7 @@ def get_fields(self): return self.fields + @crs_transform def query(self, offset=0, limit=10, resulttype='results', bbox=[], datetime_=None, properties=[], sortby=[], select_properties=[], skip_geometry=False, q=None, **kwargs): @@ -163,6 +164,7 @@ def query(self, offset=0, limit=10, resulttype='results', return fc + @crs_transform def get(self, identifier, **kwargs): """ Query ESRI by id diff --git a/pygeoapi/provider/geojson.py b/pygeoapi/provider/geojson.py index d5fa4e4ff..180cf0746 100644 --- a/pygeoapi/provider/geojson.py +++ b/pygeoapi/provider/geojson.py @@ -34,6 +34,7 @@ import uuid from pygeoapi.provider.base import BaseProvider, ProviderItemNotFoundError +from pygeoapi.util import crs_transform LOGGER = logging.getLogger(__name__) @@ -130,6 +131,7 @@ def _load(self, skip_geometry=None, properties=[], select_properties=[]): if k in set(self.properties) | set(select_properties)} # noqa return data + @crs_transform def query(self, offset=0, limit=10, resulttype='results', bbox=[], datetime_=None, properties=[], sortby=[], select_properties=[], skip_geometry=False, q=None, **kwargs): @@ -164,6 +166,7 @@ def query(self, offset=0, limit=10, resulttype='results', return data + @crs_transform def get(self, identifier, **kwargs): """ query the provider by id diff --git a/pygeoapi/provider/mongo.py b/pygeoapi/provider/mongo.py index 3d5a21159..f97a724f1 100644 --- a/pygeoapi/provider/mongo.py +++ b/pygeoapi/provider/mongo.py @@ -37,6 +37,7 @@ from pymongo import ASCENDING, DESCENDING from pymongo.collection import ObjectId from pygeoapi.provider.base import BaseProvider, ProviderItemNotFoundError +from pygeoapi.util import crs_transform LOGGER = logging.getLogger(__name__) @@ -109,6 +110,7 @@ def _get_feature_list(self, filterObj, sortList=[], skip=0, maxitems=1, return featurelist, matchCount + @crs_transform def query(self, offset=0, limit=10, resulttype='results', bbox=[], datetime_=None, properties=[], sortby=[], select_properties=[], skip_geometry=False, q=None, **kwargs): @@ -155,6 +157,7 @@ def query(self, offset=0, limit=10, resulttype='results', return feature_collection + @crs_transform def get(self, identifier, **kwargs): """ query the provider by id diff --git a/pygeoapi/provider/ogr.py b/pygeoapi/provider/ogr.py index 25f692af3..1584e6dcc 100644 --- a/pygeoapi/provider/ogr.py +++ b/pygeoapi/provider/ogr.py @@ -32,7 +32,6 @@ import functools import importlib -import json import logging import os from typing import Any @@ -46,6 +45,8 @@ ProviderQueryError, ProviderConnectionError, ProviderItemNotFoundError) +from pygeoapi.util import get_crs_from_uri + LOGGER = logging.getLogger(__name__) @@ -73,9 +74,6 @@ class OGRProvider(BaseProvider): os.environ['OGR_GEOJSON_MAX_OBJ_SIZE'] = os.environ.get( 'OGR_GEOJSON_MAX_OBJ_SIZE', '20MB') - # Setting for traditional CRS axis order. - OAMS_TRADITIONAL_GIS_ORDER = osgeo_osr.OAMS_TRADITIONAL_GIS_ORDER - def __init__(self, provider_def): """ Initialize object @@ -144,31 +142,28 @@ 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]) + # 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: + 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 = osgeo_osr.SpatialReference() - source.SetAxisMappingStrategy( - OGRProvider.OAMS_TRADITIONAL_GIS_ORDER) - source.ImportFromEPSG(self.source_srs) - - target = osgeo_osr.SpatialReference() - target.SetAxisMappingStrategy( - OGRProvider.OAMS_TRADITIONAL_GIS_ORDER) - target.ImportFromEPSG(self.target_srs) - - self.transform_in = \ - osgeo_osr.CoordinateTransformation(target, source) - self.transform_out = \ - osgeo_osr.CoordinateTransformation(source, target) + # 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']) @@ -305,7 +300,8 @@ def get_fields(self): def query(self, offset=0, limit=10, resulttype='results', bbox=[], datetime_=None, properties=[], sortby=[], - select_properties=[], skip_geometry=False, q=None, **kwargs): + select_properties=[], skip_geometry=False, q=None, + crs_transform_spec=None, **kwargs): """ Query OGR source @@ -319,6 +315,7 @@ def query(self, offset=0, limit=10, resulttype='results', :param select_properties: list of property names :param skip_geometry: bool of whether to skip geometry (default False) :param q: full-text search term(s) + :param crs_transform_spec: `CrsTransformSpec` instance, optional :returns: dict of 0..n GeoJSON features """ @@ -337,8 +334,8 @@ 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) + # if self.transform_in: + # polygon.Transform(self.transform_in) layer.SetSpatialFilter(polygon) @@ -363,7 +360,11 @@ def query(self, offset=0, limit=10, resulttype='results', elif resulttype == 'results': LOGGER.debug('results specified') result = self._response_feature_collection( - layer, limit, skip_geometry=skip_geometry) + layer, + limit, + skip_geometry=skip_geometry, + crs_transform_spec=crs_transform_spec, + ) else: LOGGER.error('Invalid resulttype: %s' % resulttype) @@ -382,15 +383,56 @@ def query(self, offset=0, limit=10, resulttype='results', return result - def get(self, identifier, **kwargs): + def _get_spatial_ref_from_epsg(self, epsg_code, force_auth_comply=False): + axis_order = osgeo_osr.OAMS_AUTHORITY_COMPLIANT + # Assume http://www.opengis.net/def/crs/OGC/1.3/CRS84 + # for EPSG:4326, GeoJSON Compliant + if epsg_code == 4326 and not force_auth_comply: + axis_order = osgeo_osr.OAMS_TRADITIONAL_GIS_ORDER + spatial_ref = osgeo_osr.SpatialReference() + spatial_ref.SetAxisMappingStrategy(axis_order) + spatial_ref.ImportFromEPSG(epsg_code) + return spatial_ref + + def _get_spatial_ref_from_uri(self, crs_uri): + # Assume http://www.opengis.net/def/crs/OGC/1.3/CRS84 + # is EPSG:4326, with lon/lat order + if crs_uri == 'http://www.opengis.net/def/crs/OGC/1.3/CRS84': + epsg_code = 4326 + force_auth_comply = False + else: + pyproj_crs = get_crs_from_uri(crs_uri) + epsg_code = int(pyproj_crs.srs.split(':')[1]) + force_auth_comply = True + return self._get_spatial_ref_from_epsg( + epsg_code, force_auth_comply=force_auth_comply) + + def _get_crs_transform(self, crs_transform_spec=None): + if crs_transform_spec is not None: + source = self._get_spatial_ref_from_uri( + crs_transform_spec.source_crs_uri) + target = self._get_spatial_ref_from_uri( + crs_transform_spec.target_crs_uri) + crs_transform = osgeo_osr.CoordinateTransformation(source, target) + else: + crs_transform = None + return crs_transform + + def get(self, identifier, crs_transform_spec=None, **kwargs): """ Get Feature by id :param identifier: feature id + :param crs_transform_spec: `CrsTransformSpec` instance, optional :returns: feature collection """ 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() @@ -398,7 +440,9 @@ def get(self, identifier, **kwargs): layer.SetAttributeFilter(f"{self.id_field} = '{identifier}'") ogr_feature = self._get_next_feature(layer, identifier) - result = self._ogr_feature_to_json(ogr_feature) + result = self._ogr_feature_to_json( + ogr_feature, crs_transform_out=crs_transform_out, + ) except RuntimeError as err: LOGGER.error(err) @@ -463,20 +507,27 @@ def _get_next_feature(self, layer, feature_id): LOGGER.error(self.gdal.GetLastErrorMsg()) raise gdalerr - def _ogr_feature_to_json(self, ogr_feature, skip_geometry=False): + def _ogr_feature_to_json( + self, ogr_feature, skip_geometry=False, crs_transform_out=None, + ): if self.geom_field is not None: geom = ogr_feature.GetGeomFieldRef(self.geom_field) else: geom = ogr_feature.GetGeometryRef() - if self.transform_out: + + if crs_transform_out is not None: # Optionally reproject the geometry - geom.Transform(self.transform_out) + geom.Transform(crs_transform_out) + # NB With GDAL >= 3.3 seems that Axis is swapped for e.g. + # EPSG:4258 in ExportToJson where it shouldn't. See #1174. + # Suppress swapping by unassigning SpatialReference + geom.AssignSpatialReference(None) json_feature = ogr_feature.ExportToJson(as_object=True) + if skip_geometry: json_feature['geometry'] = None - else: - json_feature['geometry'] = json.loads(geom.ExportToJson()) + try: json_feature['id'] = json_feature['properties'].pop( self.id_field, json_feature['id'] @@ -486,7 +537,9 @@ def _ogr_feature_to_json(self, ogr_feature, skip_geometry=False): return json_feature - def _response_feature_collection(self, layer, limit, skip_geometry=False): + def _response_feature_collection( + self, layer, limit, skip_geometry=False, crs_transform_spec=None, + ): """ Assembles output from Layer query as GeoJSON FeatureCollection structure. @@ -502,14 +555,21 @@ def _response_feature_collection(self, layer, limit, skip_geometry=False): # See https://github.com/OSGeo/gdal/blob/master/autotest/ # ogr/ogr_wfs.py#L313 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') count = 0 while ogr_feature is not None: json_feature = self._ogr_feature_to_json( - ogr_feature, skip_geometry=skip_geometry) + ogr_feature, + skip_geometry=skip_geometry, + crs_transform_out=crs_transform_out, + ) feature_collection['features'].append(json_feature) diff --git a/pygeoapi/provider/postgresql.py b/pygeoapi/provider/postgresql.py index 41935cf37..e44729c76 100644 --- a/pygeoapi/provider/postgresql.py +++ b/pygeoapi/provider/postgresql.py @@ -55,6 +55,7 @@ from geoalchemy2.functions import ST_MakeEnvelope from geoalchemy2.shape import to_shape from pygeofilter.backends.sqlalchemy.evaluate import to_filter +import pyproj import shapely from sqlalchemy import create_engine, MetaData, PrimaryKeyConstraint, asc, desc from sqlalchemy.engine import URL @@ -65,6 +66,7 @@ from pygeoapi.provider.base import BaseProvider, \ ProviderConnectionError, ProviderQueryError, ProviderItemNotFoundError +from pygeoapi.util import get_transform_from_crs _ENGINE_STORE = {} @@ -109,7 +111,7 @@ def __init__(self, provider_def): def query(self, offset=0, limit=10, resulttype='results', bbox=[], datetime_=None, properties=[], sortby=[], select_properties=[], skip_geometry=False, q=None, - filterq=None, **kwargs): + filterq=None, crs_transform_spec=None, **kwargs): """ Query Postgis for all the content. e,g: http://localhost:5000/collections/hotosm_bdi_waterways/items? @@ -126,6 +128,7 @@ def query(self, offset=0, limit=10, resulttype='results', :param skip_geometry: bool of whether to skip geometry (default False) :param q: full-text search term(s) :param filterq: CQL query as text string + :param crs_transform_spec: `CrsTransformSpec` instance, optional :returns: GeoJSON FeatureCollection """ @@ -168,9 +171,11 @@ def query(self, offset=0, limit=10, resulttype='results', if resulttype == "hits" or not results: response['numberReturned'] = 0 return response - + crs_transform_out = self._get_crs_transform(crs_transform_spec) for item in results.limit(limit): - response['features'].append(self._sqlalchemy_to_feature(item)) + response['features'].append( + self._sqlalchemy_to_feature(item, crs_transform_out) + ) return response @@ -190,12 +195,13 @@ def get_fields(self): return fields - def get(self, identifier, **kwargs): + def get(self, identifier, crs_transform_spec=None, **kwargs): """ Query the provider for a specific feature id e.g: /collections/hotosm_bdi_waterways/items/13990765 :param identifier: feature id + :param crs_transform_spec: `CrsTransformSpec` instance, optional :returns: GeoJSON FeatureCollection """ @@ -209,7 +215,8 @@ def get(self, identifier, **kwargs): if item is None: msg = f"No such item: {self.id_field}={identifier}." raise ProviderItemNotFoundError(msg) - feature = self._sqlalchemy_to_feature(item) + crs_transform_out = self._get_crs_transform(crs_transform_spec) + feature = self._sqlalchemy_to_feature(item, crs_transform_out) # Drop non-defined properties if self.properties: @@ -344,7 +351,7 @@ def _name_for_scalar_relationship( return newname return name - def _sqlalchemy_to_feature(self, item): + def _sqlalchemy_to_feature(self, item, crs_transform_out=None): feature = { 'type': 'Feature' } @@ -359,6 +366,8 @@ def _sqlalchemy_to_feature(self, item): if feature['properties'].get(self.geom): wkb_geom = feature['properties'].pop(self.geom) shapely_geom = to_shape(wkb_geom) + if crs_transform_out is not None: + shapely_geom = crs_transform_out(shapely_geom) geojson_geom = shapely.geometry.mapping(shapely_geom) feature['geometry'] = geojson_geom else: @@ -443,3 +452,13 @@ def _select_properties_clause(self, select_properties, skip_geometry): selected_properties_clause = load_only(*selected_columns) 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 diff --git a/pygeoapi/provider/sensorthings.py b/pygeoapi/provider/sensorthings.py index 6bfa48e30..1aa75e34e 100644 --- a/pygeoapi/provider/sensorthings.py +++ b/pygeoapi/provider/sensorthings.py @@ -34,10 +34,10 @@ import logging from requests import Session -from pygeoapi.provider.base import (BaseProvider, ProviderQueryError, - ProviderConnectionError) -from pygeoapi.util import (yaml_load, url_join, get_provider_default, - get_base_url) +from pygeoapi.provider.base import ( + BaseProvider, ProviderQueryError, ProviderConnectionError) +from pygeoapi.util import ( + yaml_load, url_join, get_provider_default, crs_transform, get_base_url) LOGGER = logging.getLogger(__name__) @@ -167,6 +167,7 @@ def get_fields(self): return self.fields + @crs_transform def query(self, offset=0, limit=10, resulttype='results', bbox=[], datetime_=None, properties=[], sortby=[], select_properties=[], skip_geometry=False, q=None, **kwargs): @@ -192,6 +193,7 @@ def query(self, offset=0, limit=10, resulttype='results', sortby=sortby, select_properties=select_properties, skip_geometry=skip_geometry) + @crs_transform def get(self, identifier, **kwargs): """ Query STA by id diff --git a/pygeoapi/provider/socrata.py b/pygeoapi/provider/socrata.py index e6fb05c33..0f402a735 100644 --- a/pygeoapi/provider/socrata.py +++ b/pygeoapi/provider/socrata.py @@ -37,7 +37,7 @@ from pygeoapi.provider.base import (BaseProvider, ProviderQueryError, ProviderConnectionError) -from pygeoapi.util import format_datetime +from pygeoapi.util import format_datetime, crs_transform LOGGER = logging.getLogger(__name__) @@ -91,6 +91,7 @@ def get_fields(self): return self.fields + @crs_transform def query(self, offset=0, limit=10, resulttype='results', bbox=[], datetime_=None, properties=[], sortby=[], select_properties=[], skip_geometry=False, q=None, **kwargs): @@ -157,6 +158,7 @@ def make_feature(f): return fc + @crs_transform def get(self, identifier, **kwargs): """ Query SODA by id diff --git a/pygeoapi/provider/sqlite.py b/pygeoapi/provider/sqlite.py index 39241eacf..d7ae81d16 100644 --- a/pygeoapi/provider/sqlite.py +++ b/pygeoapi/provider/sqlite.py @@ -38,6 +38,7 @@ from pygeoapi.plugin import InvalidPluginError from pygeoapi.provider.base import (BaseProvider, ProviderConnectionError, ProviderItemNotFoundError) +from pygeoapi.util import crs_transform LOGGER = logging.getLogger(__name__) @@ -275,6 +276,7 @@ def __load(self): return cursor + @crs_transform def query(self, offset=0, limit=10, resulttype='results', bbox=[], datetime_=None, properties=[], sortby=[], select_properties=[], skip_geometry=False, q=None, **kwargs): @@ -334,6 +336,7 @@ def query(self, offset=0, limit=10, resulttype='results', return feature_collection + @crs_transform def get(self, identifier, **kwargs): """ Query the provider for a specific @@ -341,7 +344,7 @@ def get(self, identifier, **kwargs): :param identifier: feature id - :returns: GeoJSON FeaturesCollection + :returns: dict of single GeoJSON feature """ LOGGER.debug('Get item from SQLite/GPKG') diff --git a/pygeoapi/schemas/config/pygeoapi-config-0.x.yml b/pygeoapi/schemas/config/pygeoapi-config-0.x.yml index e3ee1c836..cbe664b4b 100644 --- a/pygeoapi/schemas/config/pygeoapi-config-0.x.yml +++ b/pygeoapi/schemas/config/pygeoapi-config-0.x.yml @@ -459,6 +459,35 @@ properties: type: string minItems: 1 uniqueItems: true + crs: + type: array + description: |- + supported coordinate reference systems (CRSs). + pygeoapi will always provide or add the default if not specified. + items: + type: string + format: uri + default: + - http://www.opengis.net/def/crs/OGC/1.3/CRS84 + uniqueItems: true + storage_crs: + type: string + format: uri + description: |- + the CRS identifier, from the list of supported CRS identifiers, that may be used to retrieve + features from a collection without the need to apply a CRS transformation. + default: + http://www.opengis.net/def/crs/OGC/1.3/CRS84 + storage_crs_coordinate_epoch: + type: number + format: uri + description: |- + point in time at which coordinates in the spatial feature collection are referenced to the dynamic + coordinate reference system in `storageCrs`, that may be used to retrieve features from a + collection without the need to apply a change of coordinate epoch. It is expressed as a decimal + year in the Gregorian calendar. + example: + 2017-03-25 in the Gregorian calendar is epoch 2017.23 required: - type - name diff --git a/pygeoapi/templates/collections/collection.html b/pygeoapi/templates/collections/collection.html index 36d507a67..61288cbe8 100644 --- a/pygeoapi/templates/collections/collection.html +++ b/pygeoapi/templates/collections/collection.html @@ -69,6 +69,26 @@

{% trans %}Links{% endtrans %}

{% endfor %} + {% if data['itemType'] == 'feature' %} +

{% trans %}Reference Systems{% endtrans %}

+
    + {% for crs in data['crs'] %} +
  • + {{ crs }} +
  • + {% endfor %} +
+

{% trans %}Storage CRS{% endtrans %}

+ + {% endif %} + {% endblock %} diff --git a/pygeoapi/util.py b/pygeoapi/util.py index edab76dd9..a877be9f4 100644 --- a/pygeoapi/util.py +++ b/pygeoapi/util.py @@ -35,19 +35,36 @@ import mimetypes import os import re +import functools +from functools import partial +from dataclasses import dataclass from datetime import date, datetime, time from decimal import Decimal from enum import Enum from pathlib import Path -from typing import Any, IO, Union, List +from typing import Any, IO, Union, List, Callable from urllib.parse import urlparse from urllib.request import urlopen import dateutil.parser +import shapely.ops +from shapely.geometry import ( + GeometryCollection, + LinearRing, + LineString, + MultiLineString, + MultiPoint, + MultiPolygon, + Polygon, + Point, + shape as geojson_to_geom, + mapping as geom_to_geojson, +) import yaml from babel.support import Translations from jinja2 import Environment, FileSystemLoader, select_autoescape -from shapely.geometry import Polygon +import pyproj +from pyproj.exceptions import CRSError from requests import Session from requests.structures import CaseInsensitiveDict @@ -56,12 +73,50 @@ from pygeoapi.models import config as config_models from pygeoapi.provider.base import ProviderTypeError + LOGGER = logging.getLogger(__name__) DATETIME_FORMAT = '%Y-%m-%dT%H:%M:%S.%fZ' TEMPLATES = Path(__file__).parent.resolve() / 'templates' +CRS_AUTHORITY = [ + "AUTO", + "EPSG", + "OGC", +] + +# Global to compile only once +CRS_URI_PATTERN = re.compile( + ( + rf"^http://www.opengis\.net/def/crs/" + rf"(?P{'|'.join(CRS_AUTHORITY)})/" + rf"[\d|\.]+?/(?P\w+?)$" + ) +) + + +# 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') @@ -556,6 +611,202 @@ 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. + :type uri: str + + :raises `CRSError`: Error raised if no CRS could be identified from the + URI. + + :returns: `pyproj.CRS` instance matching the input URI. + :rtype: `pyproj.CRS` + """ + + try: + crs = pyproj.CRS.from_authority(*CRS_URI_PATTERN.search(uri).groups()) + except CRSError: + msg = ( + f"CRS could not be identified from URI {uri!r} " + f"(Authority: {CRS_URI_PATTERN.search(uri).group('auth')!r}, " + f"Code: {CRS_URI_PATTERN.search(uri).group('code')!r})." + ) + LOGGER.error(msg) + raise CRSError(msg) + except AttributeError: + msg = ( + f"CRS could not be identified from URI {uri!r}. CRS URIs must " + "follow the format " + "'http://www.opengis.net/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) + 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(shapely.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. diff --git a/tests/data/norwegian_urban_areas.csv b/tests/data/norwegian_urban_areas.csv new file mode 100644 index 000000000..c045163ce --- /dev/null +++ b/tests/data/norwegian_urban_areas.csv @@ -0,0 +1,16 @@ +id,name,population_2022,easting,northing +153,Oslo,1064235,262210.89,6649335.41 +591,Bergen,267117,-31954.46,6734395.49 +521,Stavanger/Sandnes,231693,-32979.91,6567659.77 +892,Trondheim,194860,270337.87,7041814.2 +6,Fredrikstad/Sarpsborg,118992,274437.03,6576398.27 +287,Drammen,111036,230607.36,6632636.02 +409,Porsgrunn/Skien,94709,194156.57,6570663.73 +472,Kristiansand,65506,88127.85,6466407.04 +790,Ålesund,54983,44946.46,6958028.95 +356,Tønsberg,54580,238341.31,6578863.19 +8,Moss,48477,253785.51,6596421.39 +359,Sandefjord,45816,226708.55,6564464.78 +524,Haugesund,45686,-51111.79,6626568.28 +445,Arendal,44352,136644.16,6496930.55 +1015,Bodø,42662,473071.2,7463027.38 diff --git a/tests/pygeoapi-test-config-ogr.yml b/tests/pygeoapi-test-config-ogr.yml new file mode 100644 index 000000000..6eb960a76 --- /dev/null +++ b/tests/pygeoapi-test-config-ogr.yml @@ -0,0 +1,355 @@ +# ================================================================= +# +# Authors: Just van den Broecke +# +# Copyright (c) 2019 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. +# +# ================================================================= + +server: + bind: + host: 0.0.0.0 + port: 5000 + url: http://localhost:5000/ + mimetype: application/json; charset=UTF-8 + encoding: utf-8 + language: en-US + cors: true + gzip: false + pretty_print: true + limit: 10 + # templates: /path/to/templates + map: + url: https://maps.wikimedia.org/osm-intl/{z}/{x}/{y}.png + attribution: 'Wikimedia maps | Map data © OpenStreetMap contributors' + +logging: + level: DEBUG + #logfile: /tmp/pygeoapi.log + +metadata: + identification: + title: pygeoapi default instance + description: pygeoapi provides an API to geospatial data + keywords: + - geospatial + - data + - api + keywords_type: theme + terms_of_service: https://creativecommons.org/licenses/by/4.0/ + url: http://example.org + license: + name: CC-BY 4.0 license + url: https://creativecommons.org/licenses/by/4.0/ + provider: + name: Organization Name + url: https://pygeoapi.io + contact: + name: Lastname, Firstname + position: Position Title + address: Mailing Address + city: City + stateorprovince: Administrative Area + postalcode: Zip or Postal Code + country: Country + phone: +xx-xxx-xxx-xxxx + fax: +xx-xxx-xxx-xxxx + email: you@example.org + url: Contact URL + hours: Hours of Service + instructions: During hours of service. Off on weekends. + role: pointOfContact + +resources: + dutch_georef_stations: + type: collection + title: Dutch Georef Stations via OGR WFS + description: Locations of RD/GNSS-reference stations from Dutch Kadaster PDOK a.k.a RDInfo. Uses MapServer WFS v2 backend via OGRProvider. + keywords: + - Netherlands + - GNSS + - Surveying + - Holland + - RD + links: + - type: text/html + rel: canonical + title: information + href: http://www.nationaalgeoregister.nl/geonetwork/srv/dut/catalog.search#/metadata/3ebe56dc-5f09-4fb3-b224-55c2db4ca2fd?tab=general + hreflang: nl-NL + extents: + spatial: + bbox: [ 50.7539, 7.21097, 53.4658, 3.37087 ] + crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 + temporal: + begin: + end: null # or empty + providers: + - type: feature + name: OGR + 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: + # OGR_WFS_VERSION: 1.1.0 + OGR_WFS_LOAD_MULTIPLE_LAYER_DEFN: NO + + gdal_ogr_options: + EMPTY_AS_NULL: NO + GDAL_CACHEMAX: 64 + # GDAL_HTTP_PROXY: (optional proxy) + # GDAL_PROXY_AUTH: (optional auth for remote WFS) + CPL_DEBUG: NO + + storage_crs: http://www.opengis.net/def/crs/EPSG/0/28992 + id_field: gml_id + layer: rdinfo:stations + + dutch_addresses_4326: + type: collection + title: Dutch Addresses from GeoPackage in EPSG:4326 + description: Selection of Dutch addresses as derived from the key registry BAG. + keywords: + - Netherlands + - Addresses + - Europe + - Holland + - BAG + links: + - type: text/html + rel: canonical + title: information + href: http://www.nationaalgeoregister.nl/geonetwork/srv/dut/catalog.search#/metadata/3a97fbe4-2b0d-4e9c-9644-276883400dd7 + hreflang: nl-NL + extents: + spatial: + bbox: [ 5.670670, 52.042700, 5.829110, 52.123700 ] + crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 + temporal: + begin: + end: null # or empty + providers: + - type: feature + name: OGR + data: + source_type: GPKG + source: + # Feature Count: 2481 + ./tests/data/dutch_addresses_4326.gpkg +# source_srs: EPSG:4326 +# target_srs: EPSG:4326 + source_capabilities: + paging: True + crs: + - http://www.opengis.net/def/crs/EPSG/0/4326 + - http://www.opengis.net/def/crs/EPSG/0/3857 + - http://www.opengis.net/def/crs/EPSG/0/4258 + - http://www.opengis.net/def/crs/EPSG/0/28992 + storage_crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 + id_field: id + layer: ogrgeojson + + dutch_addresses_28992: + type: collection + title: Dutch Addresses from GeoPackage in EPSG:28992 + description: Selection of Dutch addresses as derived from the key registry BAG. + keywords: + - Netherlands + - Addresses + - Europe + - Holland + - BAG + links: + - type: text/html + rel: canonical + title: information + href: http://www.nationaalgeoregister.nl/geonetwork/srv/dut/catalog.search#/metadata/3a97fbe4-2b0d-4e9c-9644-276883400dd7 + hreflang: nl-NL + extents: + spatial: + bbox: [ 5.670670, 52.042700, 5.829110, 52.123700 ] + crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 + temporal: + begin: + end: null # or empty + providers: + - type: feature + name: OGR + data: + source_type: GPKG + source: + # Feature Count: 2481 + ./tests/data/dutch_addresses_28992.gpkg +# source_srs: EPSG:4326 +# target_srs: EPSG:4326 + source_capabilities: + paging: True + crs: + - http://www.opengis.net/def/crs/EPSG/0/4326 + - http://www.opengis.net/def/crs/EPSG/0/4258 + - 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 + + ogr_gpkg_poi: + type: collection + title: Portuguese Points of Interest via OGR GPKG + description: Portuguese Points of Interest obtained from OpenStreetMap. Dataset includes Madeira and Azores islands. Uses GeoPackage backend via OGR provider. + keywords: + - Portugal + - POI + - Point of Interrest + - Madeira + - Azores + - OSM + - Open Street Map + - NaturaGIS + links: + - type: text/html + rel: canonical + title: information + href: https://wiki.openstreetmap.org/wiki/Points_of_interest/ + hreflang: en-US + extents: + spatial: + bbox: [ -31.2687, 32.5898, -6.18992, 42.152 ] + crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 + temporal: + begin: + end: null # or empty + providers: + - type: feature + name: OGR + data: + source_type: GPKG + source: tests/data/poi_portugal.gpkg +# source_srs: EPSG:4326 +# target_srs: EPSG:4326 + source_capabilities: + paging: True + + gdal_ogr_options: + EMPTY_AS_NULL: NO + GDAL_CACHEMAX: 64 + # GDAL_HTTP_PROXY: (optional proxy) + # GDAL_PROXY_AUTH: (optional auth for remote WFS) + CPL_DEBUG: NO + + id_field: osm_id + layer: poi_portugal + + sf_311incidents: + type: collection + title: SF 311Incidents via OGR ESRI Feature Server + description: OGR Provider - ESRI Feature Server - SF 311Incidents + keywords: + - USA + - ESRI + links: + - type: text/html + rel: canonical + title: information + href: http://sampleserver3.arcgisonline.com/ArcGIS/rest/services/SanFrancisco/311Incidents/FeatureServer/0 + hreflang: en-US + extents: + spatial: + bbox: [ -180, -90, 180, 90 ] + crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 + temporal: + begin: + end: null # or empty + providers: + - type: feature + name: OGR + data: + source_type: ESRIJSON + source: ESRIJSON:http://sampleserver3.arcgisonline.com/ArcGIS/rest/services/SanFrancisco/311Incidents/FeatureServer/0/query?where=objectid+%3D+objectid&outfields=*&f=json +# source_srs: EPSG:4326 +# target_srs: EPSG:4326 + source_capabilities: + paging: True + + gdal_ogr_options: + EMPTY_AS_NULL: NO + GDAL_CACHEMAX: 64 + # GDAL_HTTP_PROXY: (optional proxy) + # GDAL_PROXY_AUTH: (optional auth for remote WFS) + CPL_DEBUG: NO + + id_field: objectid + + cases_italy_per_region_from_github: + type: collection + title: "Cases in Italy - DPC GitHub" + description: "Current situation within Italy, number of cases with variation per Italy, provided by ESRI, source data from DPC." + keywords: [ Daily, Cases Variation, Region ] + crs: + - CRS84 + links: + - type: text/html + rel: canonical + title: "GitHub DPC repository - COVID-19 raw data for Italy" + href: https://github.com/pcm-dpc/COVID-19 + hreflang: it-IT + extents: + spatial: + bbox: [ -180,-90,180,90 ] + crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 + temporal: + begin: 2020-01-01T00:00:00Z + end: # or empty + + providers: + - type: feature + name: OGR + data: + source_type: CSV + source: /vsicurl/https://raw.githubusercontent.com/pcm-dpc/COVID-19/master/dati-regioni/dpc-covid19-ita-regioni.csv +# source_srs: EPSG:4326 +# target_srs: EPSG:4326 + source_capabilities: + paging: True + open_options: + X_POSSIBLE_NAMES: long + Y_POSSIBLE_NAMES: lat + gdal_ogr_options: + EMPTY_AS_NULL: NO + GDAL_CACHEMAX: 64 + # GDAL_HTTP_PROXY: (optional proxy) + # GDAL_PROXY_AUTH: (optional auth for remote WFS) + CPL_DEBUG: NO + id_field: fid + time_field: data + layer: dpc-covid19-ita-regioni + + hello-world: + type: process + processor: + name: HelloWorld diff --git a/tests/pygeoapi-test-config-postgresql.yml b/tests/pygeoapi-test-config-postgresql.yml index df72c8d06..5ee6c9d18 100644 --- a/tests/pygeoapi-test-config-postgresql.yml +++ b/tests/pygeoapi-test-config-postgresql.yml @@ -135,6 +135,12 @@ resources: id_field: osm_id table: hotosm_bdi_waterways geom_field: foo_geom + crs: + - http://www.opengis.net/def/crs/EPSG/0/4326 + - http://www.opengis.net/def/crs/EPSG/0/32735 + - http://www.opengis.net/def/crs/EPSG/0/32736 + storage_crs: http://www.opengis.net/def/crs/EPSG/0/4326 + dummy_naming_conflicts: type: collection title: Dummy data diff --git a/tests/pygeoapi-test-config.yml b/tests/pygeoapi-test-config.yml index b6d6e1795..772917da1 100644 --- a/tests/pygeoapi-test-config.yml +++ b/tests/pygeoapi-test-config.yml @@ -144,11 +144,47 @@ resources: - type: feature name: CSV data: tests/data/obs.csv + crs: + - http://www.opengis.net/def/crs/OGC/1.3/CRS84 + - http://www.opengis.net/def/crs/EPSG/0/4326 + - http://www.opengis.net/def/crs/EPSG/0/3857 + - http://www.opengis.net/def/crs/EPSG/0/28992 + storage_crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 id_field: id geometry: x_field: long y_field: lat + norway_pop: + type: collection + title: Norwegian urban areas + description: Most populated Norwegian urban areas + keywords: + - population + - Norway + links: + - type: text/html + rel: canonical + title: information + href: https://www.ssb.no/statbank/table/04859/ + hreflang: nb-NO + extents: + spatial: + bbox: [-180,-90,180,90] + crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 + providers: + - type: feature + name: CSV + data: tests/data/norwegian_urban_areas.csv + id_field: id + geometry: + x_field: easting + y_field: northing + crs: + - http://www.opengis.net/def/crs/EPSG/0/4258 + - http://www.opengis.net/def/crs/EPSG/0/25833 + storage_crs: http://www.opengis.net/def/crs/EPSG/0/25833 + cmip5: type: collection title: CMIP5 sample @@ -205,6 +241,10 @@ resources: name: GeoJSON data: tests/data/ne_110m_lakes.geojson id_field: id + crs: + - http://www.opengis.net/def/crs/OGC/1.3/CRS84 + storage_crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 + storage_crs_coordinate_epoch: 2017.23 - type: tile name: MVT # data: http://localhost:9000/ne_110m_lakes/{z}/{x}/{y} diff --git a/tests/pygeoapi-test-ogr-config.yml b/tests/pygeoapi-test-ogr-config.yml deleted file mode 100644 index 46af0009a..000000000 --- a/tests/pygeoapi-test-ogr-config.yml +++ /dev/null @@ -1,410 +0,0 @@ -# ================================================================= -# -# Authors: Just van den Broecke -# -# Copyright (c) 2019 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. -# -# ================================================================= - -server: - bind: - host: 0.0.0.0 - port: 5000 - url: http://localhost:5000/ - mimetype: application/json; charset=UTF-8 - encoding: utf-8 - language: en-US - cors: true - gzip: false - pretty_print: true - limit: 10 - # templates: /path/to/templates - map: - url: https://maps.wikimedia.org/osm-intl/{z}/{x}/{y}.png - attribution: 'Wikimedia maps | Map data © OpenStreetMap contributors' - -logging: - level: DEBUG - #logfile: /tmp/pygeoapi.log - -metadata: - identification: - title: pygeoapi default instance - description: pygeoapi provides an API to geospatial data - keywords: - - geospatial - - data - - api - keywords_type: theme - terms_of_service: https://creativecommons.org/licenses/by/4.0/ - url: http://example.org - license: - name: CC-BY 4.0 license - url: https://creativecommons.org/licenses/by/4.0/ - provider: - name: Organization Name - url: https://pygeoapi.io - contact: - name: Lastname, Firstname - position: Position Title - address: Mailing Address - city: City - stateorprovince: Administrative Area - postalcode: Zip or Postal Code - country: Country - phone: +xx-xxx-xxx-xxxx - fax: +xx-xxx-xxx-xxxx - email: you@example.org - url: Contact URL - hours: Hours of Service - instructions: During hours of service. Off on weekends. - role: pointOfContact - -resources: - dutch_georef_stations: - type: collection - title: Dutch Georef Stations via OGR WFS - description: Locations of RD/GNSS-reference stations from Dutch Kadaster PDOK a.k.a RDInfo. Uses MapServer WFS v2 backend via OGRProvider. - keywords: - - Netherlands - - GNSS - - Surveying - - Holland - - RD - links: - - type: text/html - rel: canonical - title: information - href: http://www.nationaalgeoregister.nl/geonetwork/srv/dut/catalog.search#/metadata/3ebe56dc-5f09-4fb3-b224-55c2db4ca2fd?tab=general - hreflang: nl-NL - extents: - spatial: - bbox: [50.7539, 7.21097, 53.4658, 3.37087] - crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 - temporal: - begin: - end: null # or empty - providers: - - type: feature - name: OGR - 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: - # OGR_WFS_VERSION: 1.1.0 - OGR_WFS_LOAD_MULTIPLE_LAYER_DEFN: NO - - gdal_ogr_options: - EMPTY_AS_NULL: NO - GDAL_CACHEMAX: 64 - # GDAL_HTTP_PROXY: (optional proxy) - # GDAL_PROXY_AUTH: (optional auth for remote WFS) - CPL_DEBUG: NO - - id_field: gml_id - layer: rdinfo:stations - - # Warning: this layer contains about 10 million addresses, the backend WFS seems not optimized - dutch_addresses: - type: collection - title: Dutch Addresses via OGR WFS - description: All Dutch addresses as derived from the key registry BAG. Uses GeoServer WFS v2 backend via OGRProvider. SLOW BACKEND! - keywords: - - Netherlands - - Addresses - - Europe - - Holland - - BAG - links: - - type: text/html - rel: canonical - title: information - href: http://www.nationaalgeoregister.nl/geonetwork/srv/dut/catalog.search#/metadata/3a97fbe4-2b0d-4e9c-9644-276883400dd7 - hreflang: nl-NL - extents: - spatial: - bbox: [50.7539, 7.21097, 53.4658, 3.37087] - crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 - temporal: - begin: - end: null # or empty - providers: - - type: feature - name: OGR - data: - source_type: WFS - source: WFS:http://geodata.nationaalgeoregister.nl/inspireadressen/wfs? - source_srs: EPSG:28992 - target_srs: EPSG:4326 - source_capabilities: - paging: True - - source_options: - # OGR_WFS_VERSION: 2.0.0 - OGR_WFS_LOAD_MULTIPLE_LAYER_DEFN: NO - - gdal_ogr_options: - EMPTY_AS_NULL: NO - GDAL_CACHEMAX: 64 - # GDAL_HTTP_PROXY: (optional proxy) - # GDAL_PROXY_AUTH: (optional auth for remote WFS) - CPL_DEBUG: NO - - id_field: gml_id - layer: inspireadressen:inspireadressen - - utah_city_locations: - type: collection - title: Cities in Utah via OGR WFS - description: Data from the state of Utah. Standard demo dataset from the deegree WFS server that is used as backend WFS. - keywords: - - USA - - deegree - - Utah - - Demo data - links: - - type: text/html - rel: canonical - title: information - href: http://download.deegree.org/documentation/3.3.20/html/lightly.html#example-workspace-2-utah-webmapping-services - hreflang: en-US - extents: - spatial: - bbox: [-112.108489, 39.854053, -111.028628, 40.460098] - crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 - temporal: - begin: - end: null # or empty - providers: - - type: feature - name: OGR - data: - source_type: WFS - source: WFS:http://demo.deegree.org/utah-workspace/services/wfs?TYPENAME=app:SGID93_LOCATION_UDOTMap_CityLocations - source_srs: EPSG:26912 - target_srs: EPSG:4326 - source_capabilities: - paging: True - - source_options: - # OGR_WFS_VERSION: 2.0.0 - OGR_WFS_LOAD_MULTIPLE_LAYER_DEFN: NO - - gdal_ogr_options: - EMPTY_AS_NULL: NO - GDAL_CACHEMAX: 64 - # GDAL_HTTP_PROXY: (optional proxy) - # GDAL_PROXY_AUTH: (optional auth for remote WFS) - CPL_DEBUG: NO - - id_field: NAME - layer: app:SGID93_LOCATION_UDOTMap_CityLocations - - unesco_pois_italy: - type: collection - title: Unesco POIs in Italy via OGR WFS - description: Unesco Points of Interest in Italy. Using GeoSolutions GeoServer WFS demo-server as backend WFS. - keywords: - - Italy - - Unesco - - Demo - links: - - type: text/html - rel: canonical - title: information - href: https://mapstore2.geo-solutions.it/mapstore/#/dashboard/5593 - hreflang: en-US - extents: - spatial: - bbox: [36.0, 17.0, 46.0, 18.0] - crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 - temporal: - begin: - end: null # or empty - providers: - - type: feature - name: OGR - data: - source_type: WFS - source: WFS:https://gs-stable.geosolutionsgroup.com/geoserver/wfs - source_srs: EPSG:32632 - target_srs: EPSG:4326 - source_capabilities: - paging: True - - source_options: - # OGR_WFS_VERSION: 1.1.0 - OGR_WFS_LOAD_MULTIPLE_LAYER_DEFN: NO - - gdal_ogr_options: - EMPTY_AS_NULL: NO - GDAL_CACHEMAX: 64 - # GDAL_HTTP_PROXY: (optional proxy) - # GDAL_PROXY_AUTH: (optional auth for remote WFS) - CPL_DEBUG: NO - - id_field: gml_id - layer: unesco:Unesco_point - - ogr_gpkg_poi: - type: collection - title: Portuguese Points of Interest via OGR GPKG - description: Portuguese Points of Interest obtained from OpenStreetMap. Dataset includes Madeira and Azores islands. Uses GeoPackage backend via OGR provider. - keywords: - - Portugal - - POI - - Point of Interrest - - Madeira - - Azores - - OSM - - Open Street Map - - NaturaGIS - links: - - type: text/html - rel: canonical - title: information - href: https://wiki.openstreetmap.org/wiki/Points_of_interest/ - hreflang: en-US - extents: - spatial: - bbox: [-31.2687, 32.5898, -6.18992, 42.152] - crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 - temporal: - begin: - end: null # or empty - providers: - - type: feature - name: OGR - data: - source_type: GPKG - source: tests/data/poi_portugal.gpkg - source_srs: EPSG:4326 - target_srs: EPSG:4326 - source_capabilities: - paging: True - - gdal_ogr_options: - EMPTY_AS_NULL: NO - GDAL_CACHEMAX: 64 - # GDAL_HTTP_PROXY: (optional proxy) - # GDAL_PROXY_AUTH: (optional auth for remote WFS) - CPL_DEBUG: NO - - id_field: osm_id - layer: poi_portugal - - - sf_311incidents: - type: collection - title: SF 311Incidents via OGR ESRI Feature Server - description: OGR Provider - ESRI Feature Server - SF 311Incidents - keywords: - - USA - - ESRI - links: - - type: text/html - rel: canonical - title: information - href: http://sampleserver3.arcgisonline.com/ArcGIS/rest/services/SanFrancisco/311Incidents/FeatureServer/0 - hreflang: en-US - extents: - spatial: - bbox: [-180, -90, 180, 90] - crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 - temporal: - begin: - end: null # or empty - providers: - - type: feature - name: OGR - data: - source_type: ESRIJSON - source: ESRIJSON:http://sampleserver3.arcgisonline.com/ArcGIS/rest/services/SanFrancisco/311Incidents/FeatureServer/0/query?where=objectid+%3D+objectid&outfields=*&f=json - source_srs: EPSG:4326 - target_srs: EPSG:4326 - source_capabilities: - paging: True - - gdal_ogr_options: - EMPTY_AS_NULL: NO - GDAL_CACHEMAX: 64 - # GDAL_HTTP_PROXY: (optional proxy) - # GDAL_PROXY_AUTH: (optional auth for remote WFS) - CPL_DEBUG: NO - - id_field: objectid - - cases_italy_per_region_from_github: - type: collection - title: "Cases in Italy - DPC GitHub" - description: "Current situation within Italy, number of cases with variation per Italy, provided by ESRI, source data from DPC." - keywords: [Daily, Cases Variation, Region] - crs: - - CRS84 - links: - - type: text/html - rel: canonical - title: "GitHub DPC repository - COVID-19 raw data for Italy" - href: https://github.com/pcm-dpc/COVID-19 - hreflang: it-IT - extents: - spatial: - bbox: [-180,-90,180,90] - crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 - temporal: - begin: 2020-01-01T00:00:00Z - end: # or empty - - providers: - - type: feature - name: OGR - data: - source_type: CSV - source: /vsicurl/https://raw.githubusercontent.com/pcm-dpc/COVID-19/master/dati-regioni/dpc-covid19-ita-regioni.csv - source_srs: EPSG:4326 - target_srs: EPSG:4326 - source_capabilities: - paging: True - open_options: - X_POSSIBLE_NAMES: long - Y_POSSIBLE_NAMES: lat - gdal_ogr_options: - EMPTY_AS_NULL: NO - GDAL_CACHEMAX: 64 - # GDAL_HTTP_PROXY: (optional proxy) - # GDAL_PROXY_AUTH: (optional auth for remote WFS) - CPL_DEBUG: NO - id_field: fid - time_field: data - layer: dpc-covid19-ita-regioni - - hello-world: - type: process - processor: - name: HelloWorld diff --git a/tests/test_api.py b/tests/test_api.py index 3d05e8207..ab8879a86 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -39,13 +39,18 @@ from pyld import jsonld import pytest +import pyproj +from shapely.geometry import Point from pygeoapi.api import ( API, APIRequest, FORMAT_TYPES, validate_bbox, validate_datetime, validate_subset, F_HTML, F_JSON, F_JSONLD, F_GZIP, __version__ ) -from pygeoapi.util import yaml_load, get_api_rules, get_base_url -from .util import get_test_file_path, mock_request, mock_flask, mock_starlette +from pygeoapi.util import (yaml_load, get_crs_from_uri, + get_api_rules, get_base_url) + +from .util import (get_test_file_path, mock_request, + mock_flask, mock_starlette) LOGGER = logging.getLogger(__name__) @@ -602,7 +607,9 @@ def test_conformance(config, api_): assert isinstance(root, dict) assert 'conformsTo' in root - assert len(root['conformsTo']) == 22 + assert len(root['conformsTo']) == 23 + assert 'http://www.opengis.net/spec/ogcapi-features-2/1.0/conf/crs' \ + in root['conformsTo'] req = mock_request({'f': 'foo'}) rsp_headers, code, response = api_.conformance(req) @@ -629,7 +636,7 @@ def test_describe_collections(config, api_): collections = json.loads(response) assert len(collections) == 2 - assert len(collections['collections']) == 7 + assert len(collections['collections']) == 8 assert len(collections['links']) == 3 rsp_headers, code, response = api_.describe_collections(req, 'foo') @@ -657,6 +664,19 @@ def test_describe_collections(config, api_): } } + # OAPIF Part 2 CRS 6.2.1 A, B, configured CRS + defaults + assert collection['crs'] is not None + crs_set = [ + 'http://www.opengis.net/def/crs/EPSG/0/28992', + 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', + 'http://www.opengis.net/def/crs/EPSG/0/4326', + ] + for crs in crs_set: + assert crs in collection['crs'] + assert collection['storageCRS'] is not None + assert collection['storageCRS'] == 'http://www.opengis.net/def/crs/OGC/1.3/CRS84' # noqa + assert 'storageCrsCoordinateEpoch' not in collection + # French language request req = mock_request({'lang': 'fr'}) rsp_headers, code, response = api_.describe_collections(req, 'obs') @@ -686,6 +706,21 @@ def test_describe_collections(config, api_): collection = json.loads(response) assert collection['id'] == 'naturalearth/lakes' + # OAPIF Part 2 CRS 6.2.1 B, defaults when not configured + assert collection['crs'] is not None + default_crs_list = [ + 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', + 'http://www.opengis.net/def/crs/OGC/1.3/CRS84h', + ] + contains_default = False + for crs in default_crs_list: + if crs in default_crs_list: + contains_default = True + assert contains_default + assert collection['storageCRS'] is not None + assert collection['storageCRS'] == 'http://www.opengis.net/def/crs/OGC/1.3/CRS84' # noqa + assert collection['storageCrsCoordinateEpoch'] == 2017.23 + def test_describe_collections_hidden_resources( config_hidden_resources, api_hidden_resources): @@ -790,6 +825,40 @@ def test_get_collection_items(config, api_): assert code == HTTPStatus.BAD_REQUEST + req = mock_request({'bbox': '1,2,3,4', 'bbox-crs': 'bad_value'}) + rsp_headers, code, response = api_.get_collection_items(req, 'obs') + + assert code == HTTPStatus.BAD_REQUEST + + req = mock_request({'bbox-crs': 'bad_value'}) + rsp_headers, code, response = api_.get_collection_items(req, 'obs') + + assert code == HTTPStatus.BAD_REQUEST + + # bbox-crs must be in configured values for Collection + req = mock_request({'bbox': '1,2,3,4', 'bbox-crs': 'http://www.opengis.net/def/crs/EPSG/0/4258'}) # noqa + rsp_headers, code, response = api_.get_collection_items(req, 'obs') + + assert code == HTTPStatus.BAD_REQUEST + + # bbox-crs must be in configured values for Collection (CSV will ignore) + req = mock_request({'bbox': '52,4,53,5', 'bbox-crs': 'http://www.opengis.net/def/crs/EPSG/0/4326'}) # noqa + rsp_headers, code, response = api_.get_collection_items(req, 'obs') + + assert code == HTTPStatus.OK + + # bbox-crs can be a default even if not configured + req = mock_request({'bbox': '4,52,5,53', 'bbox-crs': 'http://www.opengis.net/def/crs/OGC/1.3/CRS84'}) # noqa + rsp_headers, code, response = api_.get_collection_items(req, 'obs') + + assert code == HTTPStatus.OK + + # bbox-crs can be a default even if not configured + req = mock_request({'bbox': '4,52,5,53'}) # noqa + rsp_headers, code, response = api_.get_collection_items(req, 'obs') + + assert code == HTTPStatus.OK + req = mock_request({'f': 'html', 'lang': 'fr'}) rsp_headers, code, response = api_.get_collection_items(req, 'obs') assert rsp_headers['Content-Type'] == FORMAT_TYPES[F_HTML] @@ -1008,6 +1077,95 @@ def test_get_collection_items(config, api_): assert code == HTTPStatus.BAD_REQUEST +def test_get_collection_items_crs(config, api_): + + # Invalid CRS query parameter + req = mock_request({'crs': '4326'}) + rsp_headers, code, response = api_.get_collection_items(req, 'norway_pop') + + assert code == HTTPStatus.BAD_REQUEST + + # Unsupported CRS + req = mock_request({'crs': 'http://www.opengis.net/def/crs/EPSG/0/32633'}) + rsp_headers, code, response = api_.get_collection_items(req, 'norway_pop') + + assert code == HTTPStatus.BAD_REQUEST + + # Supported CRSs + default_crs = 'http://www.opengis.net/def/crs/OGC/1.3/CRS84' + storage_crs = 'http://www.opengis.net/def/crs/EPSG/0/25833' + crs_4258 = 'http://www.opengis.net/def/crs/EPSG/0/4258' + supported_crs_list = [default_crs, storage_crs, crs_4258] + + for crs in supported_crs_list: + req = mock_request({'crs': crs}) + rsp_headers, code, response = api_.get_collection_items( + req, 'norway_pop', + ) + + assert code == HTTPStatus.OK + assert rsp_headers['Content-Crs'] == f'<{crs}>' + + # With CRS query parameter, using storageCRS + req = mock_request({'crs': storage_crs}) + rsp_headers, code, response = api_.get_collection_items(req, 'norway_pop') + + assert code == HTTPStatus.OK + assert rsp_headers['Content-Crs'] == f'<{storage_crs}>' + + features_25833 = json.loads(response) + + # With CRS query parameter resulting in coordinates transformation + req = mock_request({'crs': crs_4258}) + rsp_headers, code, response = api_.get_collection_items(req, 'norway_pop') + + assert code == HTTPStatus.OK + assert rsp_headers['Content-Crs'] == f'<{crs_4258}>' + + features_4258 = json.loads(response) + transform_func = pyproj.Transformer.from_crs( + pyproj.CRS.from_epsg(25833), + pyproj.CRS.from_epsg(4258), + always_xy=False, + ).transform + for feat_orig in features_25833['features']: + id_ = feat_orig['id'] + x, y, *_ = feat_orig['geometry']['coordinates'] + loc_transf = Point(transform_func(x, y)) + for feat_out in features_4258['features']: + if id_ == feat_out['id']: + loc_out = Point(feat_out['geometry']['coordinates'][:2]) + + assert loc_out.equals_exact(loc_transf, 1e-5) + break + + # Without CRS query parameter: assume Transform to default WGS84 lon,lat + req = mock_request({}) + rsp_headers, code, response = api_.get_collection_items(req, 'norway_pop') + + assert code == HTTPStatus.OK + assert rsp_headers['Content-Crs'] == f'<{default_crs}>' + + features_wgs84 = json.loads(response) + + # With CRS query parameter resulting in coordinates transformation + transform_func = pyproj.Transformer.from_crs( + pyproj.CRS.from_epsg(4258), + get_crs_from_uri(default_crs), + always_xy=False, + ).transform + for feat_orig in features_4258['features']: + id_ = feat_orig['id'] + x, y, *_ = feat_orig['geometry']['coordinates'] + loc_transf = Point(transform_func(x, y)) + for feat_out in features_wgs84['features']: + if id_ == feat_out['id']: + loc_out = Point(feat_out['geometry']['coordinates'][:2]) + + assert loc_out.equals_exact(loc_transf, 1e-5) + break + + def test_manage_collection_item_read_only_options_req(config, api_): """Test OPTIONS request on a read-only items endpoint""" req = mock_request() diff --git a/tests/test_api_ogr_provider.py b/tests/test_api_ogr_provider.py new file mode 100644 index 000000000..63a881403 --- /dev/null +++ b/tests/test_api_ogr_provider.py @@ -0,0 +1,216 @@ +# ================================================================= +# +# Authors: Just van den Broecke +# Authors: Tom Kralidis +# +# Copyright (c) 2019 Just van den Broecke +# Copyright (c) 2022 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. +# +# ================================================================= + +import json +import logging +import pytest +from pygeoapi.api import (API) +from pygeoapi.util import yaml_load, geojson_to_geom + +from .util import get_test_file_path, mock_request + +LOGGER = logging.getLogger(__name__) + +DEFAULT_CRS = 'http://www.opengis.net/def/crs/OGC/1.3/CRS84' + + +@pytest.fixture() +def config(): + with open(get_test_file_path('pygeoapi-test-config-ogr.yml')) as fh: + return yaml_load(fh) + + +@pytest.fixture() +def api_(config): + return API(config) + + +def test_get_collection_items_bbox_crs(config, api_): + CRS_BBOX_DICT = { + 'http://www.opengis.net/def/crs/OGC/1.3/CRS84': '5.71484, 52.12122, 5.71486, 52.12123', # noqa + 'http://www.opengis.net/def/crs/EPSG/0/4326': '52.12122, 5.71484, 52.12123, 5.71486', # noqa + 'http://www.opengis.net/def/crs/EPSG/0/28992': '177430, 459268, 177440, 459278' # noqa + } + + COLLECTIONS = ['dutch_addresses_4326', 'dutch_addresses_28992'] + for coll in COLLECTIONS: + # bbox-crs full extent + req = mock_request({'bbox': '5.670670, 52.042700, 5.829110, 52.123700', 'bbox-crs': 'http://www.opengis.net/def/crs/OGC/1.3/CRS84'}) # noqa + rsp_headers, code, response = api_.get_collection_items(req, coll) # noqa + features = json.loads(response)['features'] + + assert len(features) == 10 + + # bbox-crs partial extent, 1 feature, request with multiple CRSs + for crs in CRS_BBOX_DICT: + req = mock_request({'bbox': CRS_BBOX_DICT[crs], 'bbox-crs': crs}) # noqa + rsp_headers, code, response = api_.get_collection_items(req, coll) # noqa + features = json.loads(response)['features'] + + assert len(features) == 1 + properties = features[0]['properties'] + assert properties['straatnaam'] == 'Willinkhuizersteeg' + assert properties['huisnummer'] == '2' + + # bbox-crs outside extent + req = mock_request({'bbox': '5, 51.9, 5.1, 52.0', 'bbox-crs': 'http://www.opengis.net/def/crs/OGC/1.3/CRS84'}) # noqa + rsp_headers, code, response = api_.get_collection_items(req, coll) # noqa + features = json.loads(response)['features'] + + assert len(features) == 0 + + # bbox-crs outside extent + req = mock_request({'bbox': '130000, 440000, 140000, 450000', 'bbox-crs': 'http://www.opengis.net/def/crs/EPSG/0/28992'}) # noqa + rsp_headers, code, response = api_.get_collection_items(req, coll) # noqa + features = json.loads(response)['features'] + + assert len(features) == 0 + + # bbox-crs outside extent - axis reversed CRS + req = mock_request({'bbox': '51.9, 5, 52.0, 5.1', 'bbox-crs': 'http://www.opengis.net/def/crs/EPSG/0/4326'}) # noqa + rsp_headers, code, response = api_.get_collection_items(req, coll) # noqa + features = json.loads(response)['features'] + + assert len(features) == 0 + + # bbox-crs full extent - axis reversed CRS + req = mock_request({'bbox': '52.042700, 5.670670, 52.123700, 5.829110', 'bbox-crs': 'http://www.opengis.net/def/crs/EPSG/0/4326'}) # noqa + rsp_headers, code, response = api_.get_collection_items(req, coll) # noqa + features = json.loads(response)['features'] + + assert len(features) == 10 + + +def test_get_collection_items_crs(config, api_): + # 'http://www.opengis.net/def/crs/EPSG/0/4258': [52.12122746, 5.714847], # noqa + CRS_DICT = { + 'none': [5.714847, 52.12122746], # noqa + 'http://www.opengis.net/def/crs/OGC/1.3/CRS84': [5.714847, 52.12122746], # noqa + 'http://www.opengis.net/def/crs/EPSG/0/28992': [177439, 459274], # noqa + 'http://www.opengis.net/def/crs/EPSG/0/4326': [52.12122746, 5.714847], # noqa + } + # 'http://www.opengis.net/def/crs/EPSG/0/4258': '52.12122, 5.71484, 52.12123, 5.71486', # noqa + CRS_BBOX_DICT = { + 'none': '5.71484, 52.12122, 5.71486, 52.12123', # noqa + 'http://www.opengis.net/def/crs/OGC/1.3/CRS84': '5.71484, 52.12122, 5.71486, 52.12123', # noqa + 'http://www.opengis.net/def/crs/EPSG/0/4326': '52.12122, 5.71484, 52.12123, 5.71486', # noqa + 'http://www.opengis.net/def/crs/EPSG/0/28992': '177430, 459268, 177440, 459278' # noqa + } + + COLLECTIONS = ['dutch_addresses_4326', 'dutch_addresses_28992'] + for coll in COLLECTIONS: + # crs full extent to get target feature + req = mock_request({}) # noqa + rsp_headers, code, response = api_.get_collection_items(req, coll) # noqa + features = json.loads(response)['features'] + + assert len(features) == 10 + feature_id = features[0]['id'] + + # request with multiple CRSs + for crs in CRS_DICT: + # Do for query (/items) + req = mock_request({'crs': crs}) # noqa + if crs == 'none': + # Test for default bbox CRS + req = mock_request({}) # noqa + crs = DEFAULT_CRS + + rsp_headers, code, response = api_.get_collection_items(req, coll) # noqa + features = json.loads(response)['features'] + + assert len(features) == 10 + + test_feature = features[0] + assert test_feature['id'] == feature_id + + properties = test_feature['properties'] + assert properties['straatnaam'] == 'Willinkhuizersteeg' + assert properties['huisnummer'] == '2' + + # Test if CRS in header and the feature coordinates + # correspond to CRS parameter. + assert rsp_headers['Content-Crs'] == f'<{crs}>' + + test_geom_json = test_feature.get('geometry') + test_geom = geojson_to_geom(test_geom_json) + crs_geom = geojson_to_geom({'type': 'Point', 'coordinates': CRS_DICT[crs]}) # noqa + assert test_geom.equals_exact(crs_geom, 1), f'coords not equal for CRS: {crs} {crs_geom} in COLL: {coll} {test_geom}' # noqa + + # Same for single Feature 'get' + req = mock_request({'crs': crs}) # noqa + rsp_headers, code, response = api_.get_collection_item(req, coll, feature_id) # noqa + test_feature = json.loads(response) + + assert test_feature['id'] == feature_id + + properties = test_feature['properties'] + assert properties['straatnaam'] == 'Willinkhuizersteeg' + assert properties['huisnummer'] == '2' + + # Test if CRS in header and the feature coordinates + # correspond to CRS parameter. + assert rsp_headers['Content-Crs'] == f'<{crs}>' + + test_geom_json = test_feature.get('geometry') + test_geom = geojson_to_geom(test_geom_json) + crs_geom = geojson_to_geom({'type': 'Point', 'coordinates': CRS_DICT[crs]}) # noqa + assert test_geom.equals_exact(crs_geom, 1), f'coords not equal for CRS: {crs} {crs_geom} in COLL: {coll} {test_geom}' # noqa + + # Test combining BBOX and BBOX-CRS + for bbox_crs in CRS_BBOX_DICT: + # Do for query (/items) + req = mock_request({'crs': crs, 'bbox': CRS_BBOX_DICT[bbox_crs], 'bbox-crs': bbox_crs}) # noqa + if bbox_crs == 'none': + # Test for default bbox CRS + req = mock_request({'crs': crs, 'bbox': CRS_BBOX_DICT[bbox_crs]}) # noqa + bbox_crs = DEFAULT_CRS + + rsp_headers, code, response = api_.get_collection_items(req, coll) # noqa + features = json.loads(response)['features'] + + assert len(features) == 1 + + test_feature = features[0] + assert test_feature['id'] == feature_id + + properties = test_feature['properties'] + assert properties['straatnaam'] == 'Willinkhuizersteeg' + assert properties['huisnummer'] == '2' + + # Test if CRS in header and the feature coordinates + # correspond to CRS parameter. + assert rsp_headers['Content-Crs'] == f'<{crs}>' + + test_geom_json = test_feature.get('geometry') + test_geom = geojson_to_geom(test_geom_json) + crs_geom = geojson_to_geom({'type': 'Point', 'coordinates': CRS_DICT[crs]}) # noqa + assert test_geom.equals_exact(crs_geom, 1), f'coords not equal for CRS: {crs} {crs_geom} in COLL: {coll} {test_geom} bbox-crs={bbox_crs}' # noqa diff --git a/tests/test_ogr_capabilities.py b/tests/test_ogr_capabilities.py new file mode 100644 index 000000000..eb547d571 --- /dev/null +++ b/tests/test_ogr_capabilities.py @@ -0,0 +1,114 @@ +# ================================================================= +# +# Authors: Just van den Broecke +# +# Copyright (c) 2023 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. +# +# ================================================================= + +from osgeo import gdal +from osgeo import ogr +from osgeo import osr +import pyproj + + +def get_spatial_ref(epsg_int, axis_order): + spatial_ref = osr.SpatialReference() + spatial_ref.SetAxisMappingStrategy(axis_order) + spatial_ref.ImportFromEPSG(epsg_int) + return spatial_ref + + +def get_axis_order(coords): + axis_order = 'lat,lon' + if round(coords[0]) == 5 and round(coords[1]) == 52: + axis_order = 'lon,lat' + return axis_order + + +def test_transforms(): + version_num = int(gdal.VersionInfo('VERSION_NUM')) + assert version_num > 3000000, f'GDAL version={version_num} must be > 3.0.0' + print(f'GDAL Version num = {version_num}') + + pyproj.show_versions() + FORCE_LON_LAT = osr.OAMS_TRADITIONAL_GIS_ORDER + AUTH_COMPLIANT = osr.OAMS_AUTHORITY_COMPLIANT + ORDER_LATLON = 'lat,lon' + ORDER_LONLAT = 'lon,lat' + + CRS_DICT = { + 'http://www.opengis.net/def/crs/OGC/1.3/CRS84': + {'epsg': 4326, 'order': ORDER_LONLAT, 'mapping': FORCE_LON_LAT}, # noqa + 'http://www.opengis.net/def/crs/EPSG/0/4326': + {'epsg': 4326, 'order': ORDER_LATLON, 'mapping': AUTH_COMPLIANT}, # noqa + 'http://www.opengis.net/def/crs/EPSG/0/4258': + {'epsg': 4258, 'order': ORDER_LATLON, 'mapping': AUTH_COMPLIANT}, # noqa + } + + for crs in CRS_DICT: + print(f'Testing CRS={crs}') + crs_entry = CRS_DICT[crs] + source = get_spatial_ref(28992, AUTH_COMPLIANT) + target = get_spatial_ref(crs_entry['epsg'], crs_entry['mapping']) + + # Somewhere central in The Netherlands + x = 130000 + y = 455000 + + # Result should be lon = 5.022480 lat = 52.082704 + transformer = osr.CoordinateTransformation(source, target) + result = transformer.TransformPoint(x, y) + + # Determine Axis order + axis_order = get_axis_order(result) + + # Axis order should match that of CRS + print(f'Transform result={result} Axis order={axis_order}') + crs_axis_order = crs_entry['order'] + assert axis_order == crs_axis_order, f'Axis order for {crs} after Transform should be {crs_axis_order} result={result}' # noqa + + # Create an dummy in-memory OGR dataset + drv = ogr.GetDriverByName('Memory') + dst_ds = drv.CreateDataSource('out') + dst_layer = dst_ds.CreateLayer('dummy', srs=target, geom_type=ogr.wkbPoint) # noqa + feature_defn = dst_layer.GetLayerDefn() + feature = ogr.Feature(feature_defn) + wkt = "POINT({} {})".format(result[0], result[1]) + geom = ogr.CreateGeometryFromWkt(wkt) + + # Suppress swapping by nulling SpatialReference + geom.AssignSpatialReference(None) + feature.SetGeometry(geom) + json_feature = feature.ExportToJson(as_object=True) + + # Determine Axis order after ExportToJson + coords = json_feature['geometry']['coordinates'] + axis_order = get_axis_order(coords) + print(f'ExportToJson result={coords} Axis order={axis_order}') + assert axis_order == crs_axis_order, f'Axis order for {crs} after ExportToJson should be {crs_axis_order} coords={coords}' # noqa + + +if __name__ == '__main__': + test_transforms() diff --git a/tests/test_ogr_csv_provider.py b/tests/test_ogr_csv_provider.py index a96d5db05..3bd371d3b 100644 --- a/tests/test_ogr_csv_provider.py +++ b/tests/test_ogr_csv_provider.py @@ -52,8 +52,8 @@ def config_vsicurl_csv(): 'data': { 'source_type': 'CSV', 'source': '/vsicurl/https://raw.githubusercontent.com/pcm-dpc/COVID-19/master/dati-regioni/dpc-covid19-ita-regioni.csv', # noqa - 'source_srs': 'EPSG:4326', - 'target_srs': 'EPSG:4326', + # 'source_srs': 'EPSG:4326', + # 'target_srs': 'EPSG:4326', 'source_capabilities': { 'paging': True }, diff --git a/tests/test_ogr_esrijson_provider.py b/tests/test_ogr_esrijson_provider.py index b6af4cec4..3df386cf3 100644 --- a/tests/test_ogr_esrijson_provider.py +++ b/tests/test_ogr_esrijson_provider.py @@ -53,8 +53,8 @@ def config_ArcGIS_ESRIJSON(): 'data': { 'source_type': 'ESRIJSON', 'source': 'https://sampleserver6.arcgisonline.com/arcgis/rest/services/CommunityAddressing/FeatureServer/0/query?where=objectid+%3D+objectid&outfields=*&orderByFields=objectid+ASC&f=json', # noqa - 'source_srs': 'EPSG:4326', - 'target_srs': 'EPSG:4326', + # 'source_srs': 'EPSG:4326', + # 'target_srs': 'EPSG:4326', 'source_capabilities': { 'paging': True }, diff --git a/tests/test_ogr_gpkg_provider.py b/tests/test_ogr_gpkg_provider.py index ad1d941bb..723e2dca2 100644 --- a/tests/test_ogr_gpkg_provider.py +++ b/tests/test_ogr_gpkg_provider.py @@ -50,8 +50,8 @@ def config_poi_portugal(): 'data': { 'source_type': 'GPKG', 'source': './tests/data/poi_portugal.gpkg', - 'source_srs': 'EPSG:4326', - 'target_srs': 'EPSG:4326', + # 'source_srs': 'EPSG:4326', + # 'target_srs': 'EPSG:4326', 'source_capabilities': { 'paging': True }, @@ -105,8 +105,8 @@ def config_gpkg_4326(): 'source_type': 'GPKG', 'source': './tests/data/dutch_addresses_4326.gpkg', - 'source_srs': 'EPSG:4326', - 'target_srs': 'EPSG:4326', + # 'source_srs': 'EPSG:4326', + # 'target_srs': 'EPSG:4326', 'source_capabilities': { 'paging': True }, @@ -126,12 +126,17 @@ def config_gpkg_28992(): 'source_type': 'GPKG', 'source': './tests/data/dutch_addresses_28992.gpkg', - 'source_srs': 'EPSG:28992', - 'target_srs': 'EPSG:4326', + # 'source_srs': 'EPSG:28992', + # 'target_srs': 'EPSG:4326', 'source_capabilities': { 'paging': True }, }, + 'crs': [ + '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', 'id_field': 'id', 'layer': 'OGRGeoJSON' } @@ -210,7 +215,7 @@ def test_query_bbox_hits_28992(config_gpkg_28992): # feature_collection = p.query( # bbox=(180800, 452500, 181200, 452700), resulttype='hits') feature_collection = p.query( - bbox=[5.763409, 52.060197, 5.769256, 52.061976], resulttype='hits') + bbox=[180800, 452500, 181200, 452700], resulttype='hits') assert feature_collection.get('type') == 'FeatureCollection' features = feature_collection.get('features') @@ -227,7 +232,7 @@ def test_query_bbox_28992(config_gpkg_28992): # feature_collection = p.query( # bbox=[180800, 452500, 181200, 452700], resulttype='results') feature_collection = p.query( - bbox=(5.763409, 52.060197, 5.769256, 52.061976), resulttype='results') + bbox=(180800, 452500, 181200, 452700), resulttype='results') assert feature_collection.get('type') == 'FeatureCollection' features = feature_collection.get('features') assert len(features) == 1 @@ -340,7 +345,7 @@ def test_query_bbox_with_offset_28992(config_gpkg_28992): p = OGRProvider(config_gpkg_28992) feature_collection = p.query( offset=10, limit=5, - bbox=(5.742, 52.053, 5.773, 52.098), + bbox=(181000, 456000, 182000, 457000), resulttype='results') assert feature_collection.get('type') == 'FeatureCollection' features = feature_collection.get('features') @@ -352,8 +357,8 @@ def test_query_bbox_with_offset_28992(config_gpkg_28992): assert properties is not None geometry = feature.get('geometry') assert geometry is not None - assert properties['straatnaam'] == 'Buurtweg' - assert properties['huisnummer'] == '4' + assert properties['straatnaam'] == 'Arnhemseweg' + assert properties['huisnummer'] == '99' def test_query_bbox_with_offset_4326(config_gpkg_4326): diff --git a/tests/test_ogr_shapefile_provider.py b/tests/test_ogr_shapefile_provider.py index 28372b198..26d618f61 100644 --- a/tests/test_ogr_shapefile_provider.py +++ b/tests/test_ogr_shapefile_provider.py @@ -34,9 +34,13 @@ import logging import pytest +import pyproj 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__) @@ -73,17 +77,30 @@ def config_shapefile_28992(): 'source_type': 'ESRI Shapefile', 'source': '/vsizip/./tests/data/dutch_addresses_shape_28992.zip', - 'source_srs': 'EPSG:28992', - 'target_srs': 'EPSG:4326', 'source_capabilities': { 'paging': True }, }, + 'crs': [ + 'http://www.opengis.net/def/crs/OGC/1.3/CRS84', + 'http://www.opengis.net/def/crs/EPSG/0/28992' + ], + 'storage_crs': 'http://www.opengis.net/def/crs/EPSG/0/28992', 'id_field': 'id', 'layer': 'inspireadressen' } +@pytest.fixture() +def crs_transform_spec(): + return CrsTransformSpec( + source_crs_uri='http://www.opengis.net/def/crs/OGC/1.3/CRS84', + source_crs_wkt=pyproj.CRS.from_epsg(4326), + target_crs_uri='http://www.opengis.net/def/crs/EPSG/0/28992', + target_crs_wkt=pyproj.CRS.from_epsg(28992), + ) + + def test_get_fields_4326(config_shapefile_4326): """Testing field types""" p = OGRProvider(config_shapefile_4326) @@ -100,12 +117,30 @@ def test_get_28992(config_shapefile_28992): assert 'Mosselsepad' in result['properties']['straatnaam'] -def test_get_4326(config_shapefile_4326): - """Testing query for a specific object""" +def test_get_crs_4326(config_shapefile_4326, crs_transform_spec): + """Testing query with and without crs parameter for a specific object""" + # Query without CRS parameter p = OGRProvider(config_shapefile_4326) - result = p.get('inspireadressen.1747652') - assert result['id'] == 'inspireadressen.1747652' - assert 'Mosselsepad' in result['properties']['straatnaam'] + result_orig = p.get('inspireadressen.1747652') + geom_orig = geojson_to_geom(result_orig['geometry']) + assert result_orig['id'] == 'inspireadressen.1747652' + assert 'Mosselsepad' in result_orig['properties']['straatnaam'] + + # Query with CRS parameter + result_28992 = p.get( + 'inspireadressen.1747652', crs_transform_spec=crs_transform_spec, + ) + geom_28992 = geojson_to_geom(result_28992['geometry']) + assert result_28992['id'] == 'inspireadressen.1747652' + assert 'Mosselsepad' in result_28992['properties']['straatnaam'] + + transform_func = get_transform_from_crs( + pyproj.CRS.from_epsg(4326), + pyproj.CRS.from_epsg(28992), + always_xy=True + ) + geom_28992_from_orig = transform_func(geom_orig) + assert geom_28992.equals_exact(geom_28992_from_orig, 1) def test_get_not_existing_feature_raise_exception( @@ -166,7 +201,7 @@ def test_query_bbox_hits_28992(config_shapefile_28992): # feature_collection = p.query( # bbox=(180800, 452500, 181200, 452700), resulttype='hits') feature_collection = p.query( - bbox=[5.763409, 52.060197, 5.769256, 52.061976], resulttype='hits') + bbox=[180800, 452500, 181200, 452700], resulttype='hits') assert feature_collection.get('type') == 'FeatureCollection' features = feature_collection.get('features') @@ -183,7 +218,7 @@ def test_query_bbox_28992(config_shapefile_28992): # feature_collection = p.query( # bbox=[180800, 452500, 181200, 452700], resulttype='results') feature_collection = p.query( - bbox=(5.763409, 52.060197, 5.769256, 52.061976), resulttype='results') + bbox=(180800, 452500, 181200, 452700), resulttype='results') assert feature_collection.get('type') == 'FeatureCollection' features = feature_collection.get('features') assert len(features) == 1 @@ -197,26 +232,55 @@ def test_query_bbox_28992(config_shapefile_28992): assert properties['straatnaam'] == 'Planken Wambuisweg' -def test_query_bbox_4326(config_shapefile_4326): - """Testing query for a valid JSON object with geometry""" +def test_query_crs_and_bbox_4326(config_shapefile_4326, crs_transform_spec): + """Testing query for a valid JSON object with bbox and with/without crs""" p = OGRProvider(config_shapefile_4326) # feature_collection = p.query( # bbox=[180800, 452500, 181200, 452700], resulttype='results') - feature_collection = p.query( + # Query without CRS parameter + fc_orig = p.query( bbox=(5.763409, 52.060197, 5.769256, 52.061976), resulttype='results') - assert feature_collection.get('type') == 'FeatureCollection' - features = feature_collection.get('features') - assert len(features) == 1 - hits = feature_collection.get('numberMatched') + assert fc_orig.get('type') == 'FeatureCollection' + features_orig = fc_orig.get('features') + assert len(features_orig) == 1 + hits = fc_orig.get('numberMatched') assert hits is None - feature = features[0] + feature = features_orig[0] properties = feature.get('properties') assert properties is not None - geometry = feature.get('geometry') - assert geometry is not None + geojson_geom_orig = feature.get('geometry') + assert geojson_geom_orig is not None assert properties['straatnaam'] == 'Planken Wambuisweg' + # Query with CRS parameter + fc_28992 = p.query( + bbox=(5.763409, 52.060197, 5.769256, 52.061976), + resulttype='results', + crs_transform_spec=crs_transform_spec, + ) + assert fc_28992.get('type') == 'FeatureCollection' + features_28992 = fc_28992.get('features') + assert len(features_28992) == 1 + hits = fc_28992.get('numberMatched') + assert hits is None + feature = features_28992[0] + properties = feature.get('properties') + assert properties is not None + geojson_geom_28992 = feature.get('geometry') + assert geojson_geom_28992 is not None + assert properties['straatnaam'] == 'Planken Wambuisweg' + + transform_func = get_transform_from_crs( + pyproj.CRS.from_epsg(4326), + pyproj.CRS.from_epsg(28992), + always_xy=True, + ) + geom_orig = geojson_to_geom(geojson_geom_orig) + geom_28992 = geojson_to_geom(geojson_geom_28992) + geom_orig_to_28992 = transform_func(geom_orig) + assert geom_28992.equals_exact(geom_orig_to_28992, 1) + def test_query_with_limit_28992(config_shapefile_28992): """Testing query for a valid JSON object with geometry""" @@ -296,7 +360,7 @@ def test_query_bbox_with_offset_28992(config_shapefile_28992): p = OGRProvider(config_shapefile_28992) feature_collection = p.query( offset=10, limit=5, - bbox=(5.742, 52.053, 5.773, 52.098), + bbox=(181000, 456000, 182000, 457000), resulttype='results') assert feature_collection.get('type') == 'FeatureCollection' features = feature_collection.get('features') @@ -308,8 +372,8 @@ def test_query_bbox_with_offset_28992(config_shapefile_28992): assert properties is not None geometry = feature.get('geometry') assert geometry is not None - assert properties['straatnaam'] == 'Buurtweg' - assert properties['huisnummer'] == '4' + assert properties['straatnaam'] == 'Arnhemseweg' + assert properties['huisnummer'] == '99' def test_query_bbox_with_offset_4326(config_shapefile_4326): diff --git a/tests/test_ogr_sqlite_provider.py b/tests/test_ogr_sqlite_provider.py index 554eb94c6..8b0e216c1 100644 --- a/tests/test_ogr_sqlite_provider.py +++ b/tests/test_ogr_sqlite_provider.py @@ -53,8 +53,8 @@ def config_sqlite_4326(): 'source_type': 'SQLite', 'source': './tests/data/dutch_addresses_4326.sqlite', - 'source_srs': 'EPSG:4326', - 'target_srs': 'EPSG:4326', + # 'source_srs': 'EPSG:4326', + # 'target_srs': 'EPSG:4326', 'source_capabilities': { 'paging': True }, diff --git a/tests/test_ogr_wfs_provider.py b/tests/test_ogr_wfs_provider.py index ea629ae05..01ce4b7af 100644 --- a/tests/test_ogr_wfs_provider.py +++ b/tests/test_ogr_wfs_provider.py @@ -37,8 +37,7 @@ import pytest -from pygeoapi.provider.base import ( - ProviderQueryError, ProviderItemNotFoundError) +from pygeoapi.provider.base import (ProviderItemNotFoundError) from pygeoapi.provider.ogr import OGRProvider @@ -53,16 +52,19 @@ 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_srs': 'EPSG:4326', + # 'target_srs': 'EPSG:4326', 'source_capabilities': { 'paging': True }, 'source_options': { + 'OGR_WFS_VERSION': '2.0.0', 'OGR_WFS_LOAD_MULTIPLE_LAYER_DEFN': 'NO' }, 'gdal_ogr_options': { 'GDAL_CACHEMAX': '64', + 'GDAL_HTTP_VERSION': '1.1', + 'GDAL_HTTP_UNSAFESSL': 'YES', # 'GDAL_HTTP_PROXY': (optional proxy) # 'GDAL_PROXY_AUTH': (optional auth for remote WFS) 'CPL_DEBUG': 'NO' @@ -81,16 +83,19 @@ 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_srs': 'EPSG:4326', + # 'target_srs': 'EPSG:4326', 'source_capabilities': { 'paging': True }, 'source_options': { + 'OGR_WFS_VERSION': '2.0.0', 'OGR_WFS_LOAD_MULTIPLE_LAYER_DEFN': 'NO' }, 'gdal_ogr_options': { 'GDAL_CACHEMAX': '64', + 'GDAL_HTTP_VERSION': '1.1', + 'GDAL_HTTP_UNSAFESSL': 'YES', # 'GDAL_HTTP_PROXY': (optional proxy) # 'GDAL_PROXY_AUTH': (optional auth for remote WFS) 'CPL_DEBUG': 'NO' @@ -109,9 +114,9 @@ def config_geosol_gs_WFS(): 'data': { 'source_type': 'WFS', 'source': - 'WFS:https://demo.geo-solutions.it/geoserver/wfs?', - 'source_srs': 'EPSG:32632', - 'target_srs': 'EPSG:4326', + 'WFS:https://gs-stable.geosolutionsgroup.com/geoserver/wfs?', + # 'source_srs': 'EPSG:32632', + # 'target_srs': 'EPSG:4326', 'source_capabilities': { 'paging': True }, @@ -126,8 +131,13 @@ def config_geosol_gs_WFS(): 'CPL_DEBUG': 'NO' }, }, + 'crs': [ + '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', 'id_field': 'gml_id', - 'layer': 'unesco:Unesco_point' + 'layer': 'unesco:Unesco_point', } @@ -140,8 +150,8 @@ 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_srs': 'EPSG:4326', + # 'target_srs': 'EPSG:4326', 'source_capabilities': { 'paging': True }, @@ -205,35 +215,35 @@ def test_get_gs(config_MapServer_WFS_continents): assert result['properties']['NA3DESC'] == 'North America' -def test_gs_not_getting_gml_id(config_geonode_gs_WFS): - """Testing query not returning gml_id for a specific object""" - - p = OGRProvider(config_geonode_gs_WFS) - assert p.open_options is not None - result = p.get_fields() - assert result.get('gml_id') is None - - -def test_gs_force_getting_gml_id(config_geonode_gs_WFS): - """Testing query forcing to return gml_id for a specific object""" - - p = OGRProvider(config_geonode_gs_WFS) - assert p.open_options is not None - p.open_options['EXPOSE_GML_ID'] = 'YES' - result = p.get_fields() - assert result.get('gml_id') - - -def test_get_gs_with_geojson_output_too_complex_raise_exception( - config_geonode_gs_WFS -): - """Testing query for a specific object with too complex geojson""" - p = OGRProvider(config_geonode_gs_WFS) - assert p.open_options.get('URL') is None - p.open_options[ - 'URL'] = 'https://geonode.wfp.org/geoserver/wfs?outputformat=json' - with pytest.raises(ProviderQueryError): - p.get(272) +# def test_gs_not_getting_gml_id(config_geonode_gs_WFS): +# """Testing query not returning gml_id for a specific object""" +# +# p = OGRProvider(config_geonode_gs_WFS) +# assert p.open_options is not None +# result = p.get_fields() +# assert result.get('gml_id') is None +# +# +# def test_gs_force_getting_gml_id(config_geonode_gs_WFS): +# """Testing query forcing to return gml_id for a specific object""" +# +# p = OGRProvider(config_geonode_gs_WFS) +# assert p.open_options is not None +# p.open_options['EXPOSE_GML_ID'] = 'YES' +# result = p.get_fields() +# assert result.get('gml_id') +# +# +# def test_get_gs_with_geojson_output_too_complex_raise_exception( +# config_geonode_gs_WFS +# ): +# """Testing query for a specific object with too complex geojson""" +# p = OGRProvider(config_geonode_gs_WFS) +# assert p.open_options.get('URL') is None +# p.open_options[ +# 'URL'] = 'https://geonode.wfp.org/geoserver/wfs?outputformat=json' +# with pytest.raises(ProviderQueryError): +# p.get(272) def test_get_gs_not_existing_feature_raise_exception( @@ -328,7 +338,7 @@ def test_query_bbox_hits_geosol_gs(config_geosol_gs_WFS): p = OGRProvider(config_geosol_gs_WFS) feature_collection = p.query( - bbox=(681417.0, 4849032.0, 681417.3, 4849032.3), resulttype='hits') + bbox=(957858, 4561555, 957862, 4561557), resulttype='hits') # feature_collection = p.query(bbox=( # 5.763409, 52.060197, 5.769256, 52.061976), resulttype='hits') @@ -344,8 +354,6 @@ def test_query_bbox_ms(config_MapServer_WFS_cities): """Testing query for a valid JSON object with geometry""" p = OGRProvider(config_MapServer_WFS_cities) - # feature_collection = p.query( - # bbox=[120000, 480000, 124000, 487000], resulttype='results') feature_collection = p.query( bbox=[4.874016, 52.306852, 4.932020, 52.370004], resulttype='results') assert feature_collection.get('type') == 'FeatureCollection' @@ -380,13 +388,34 @@ def test_query_bbox_gs(config_MapServer_WFS_continents): def test_query_bbox_geosol_gs(config_geosol_gs_WFS): - """Testing query for a valid JSON object with geometry""" + """Testing query for a valid JSON object with geometry + + + + + 957860.4622 4561556.7274 + 957860.4622 4561556.7274 + + + + + 957860.4622 4561556.7274 + + + IT_174 + Centro storico di Firenze + 0 + sito + + + + """ p = OGRProvider(config_geosol_gs_WFS) - # feature_collection = p.query( - # bbox=[120000, 480000, 124000, 487000], resulttype='results') feature_collection = p.query( - bbox=(681417.0, 4849032.0, 681417.3, 4849032.3), + bbox=(957858, 4561555, 957862, 4561557), resulttype='results') assert feature_collection.get('type') == 'FeatureCollection' features = feature_collection.get('features') diff --git a/tests/test_postgresql_provider.py b/tests/test_postgresql_provider.py index 18a7da24c..e8d847109 100644 --- a/tests/test_postgresql_provider.py +++ b/tests/test_postgresql_provider.py @@ -41,6 +41,7 @@ import os import json import pytest +import pyproj from http import HTTPStatus from pygeofilter.parsers.ecql import parse @@ -55,11 +56,13 @@ from pygeoapi.provider.postgresql import PostgreSQLProvider import pygeoapi.provider.postgresql as postgresql_provider_module -from pygeoapi.util import yaml_load +from pygeoapi.util import (yaml_load, geojson_to_geom, + get_transform_from_crs, get_crs_from_uri) from .util import get_test_file_path, mock_request PASSWORD = os.environ.get('POSTGRESQL_PASSWORD', 'postgres') +DEFAULT_CRS = 'http://www.opengis.net/def/crs/OGC/1.3/CRS84' @pytest.fixture() @@ -572,6 +575,149 @@ def test_post_collection_items_postgresql_cql_bad_cql(pg_api_, bad_cql): assert error_response['description'].startswith('Bad CQL string') +def test_get_collection_items_postgresql_crs(pg_api_): + """Test the coordinates transformation implementation of + PostgreSQLProvider when using the crs parameter. + """ + storage_crs = DEFAULT_CRS + crs_32735 = 'http://www.opengis.net/def/crs/EPSG/0/32735' + + # Without CRS query parameter -> no coordinates transformation + req = mock_request({'bbox': '29.0,-2.85,29.05,-2.8'}) + rsp_headers, code, response = pg_api_.get_collection_items( + req, 'hot_osm_waterways', + ) + + assert code == HTTPStatus.OK + + features_orig = json.loads(response) + assert rsp_headers['Content-Crs'] == f'<{DEFAULT_CRS}>' + + # With CRS query parameter not resulting in coordinates transformation + # (i.e. 'crs' query parameter is the same as 'storage_crs') + req = mock_request({'crs': storage_crs, 'bbox': '29.0,-2.85,29.05,-2.8'}) + rsp_headers, code, response = pg_api_.get_collection_items( + req, 'hot_osm_waterways', + ) + + assert code == HTTPStatus.OK + assert rsp_headers['Content-Crs'] == f'<{storage_crs}>' + + features_storage_crs = json.loads(response) + + # With CRS query parameter resulting in coordinates transformation + req = mock_request({'crs': crs_32735, 'bbox': '29.0,-2.85,29.05,-2.8'}) + rsp_headers, code, response = pg_api_.get_collection_items( + req, 'hot_osm_waterways', + ) + + assert code == HTTPStatus.OK + assert rsp_headers['Content-Crs'] == f'<{crs_32735}>' + + features_32735 = json.loads(response) + + # Make sure that we compare the same features + assert ( + sorted(f['id'] for f in features_orig['features']) + == sorted(f['id'] for f in features_storage_crs['features']) + == sorted(f['id'] for f in features_32735['features']) + ) + + # Without 'crs' query parameter or with 'crs' set to 'storage_crs', the + # geometries of the returned features should be the same + for feat_orig in features_orig['features']: + id_ = feat_orig['id'] + for feat_storage_crs in features_storage_crs['features']: + if id_ == feat_storage_crs['id']: + assert feat_orig['geometry'] == feat_storage_crs['geometry'] + break + + transform_func = get_transform_from_crs( + get_crs_from_uri(DEFAULT_CRS), + pyproj.CRS.from_epsg(32735), + always_xy=False, + ) + # Check that the coordinates of returned features were transformed + for feat_orig in features_orig['features']: + id_ = feat_orig['id'] + geom_orig = geojson_to_geom(feat_orig['geometry']) + for feat_32735 in features_32735['features']: + if id_ == feat_32735['id']: + geom_32735 = geojson_to_geom(feat_32735['geometry']) + + assert geom_32735.equals_exact(transform_func(geom_orig), 1) + break + + +def test_get_collection_item_postgresql_crs(pg_api_): + """Test the coordinates transformation implementation of + PostgreSQLProvider when using the crs parameter. + """ + storage_crs = DEFAULT_CRS + crs_32735 = 'http://www.opengis.net/def/crs/EPSG/0/32735' + # List of feature IDs located in UTM zone 35S + fid_list = [ + '439338397', + '198190856', + '93063941', + '586449587', + '80827793', + '587350255', + '586994284', + '587960337', + '586449586', + '422440125', + ] + for fid in fid_list: + # Without CRS query parameter -> no coordinates transformation + req = mock_request({'f': 'json'}) + rsp_headers, code, response = pg_api_.get_collection_item( + req, 'hot_osm_waterways', fid, + ) + + assert code == HTTPStatus.OK + assert rsp_headers['Content-Crs'] == f'<{DEFAULT_CRS}>' + + feat_orig = json.loads(response) + geom_orig = geojson_to_geom(feat_orig['geometry']) + + # With CRS query parameter not resulting in coordinates transformation + # (i.e. 'crs' query parameter is the same as 'storage_crs') + req = mock_request({'f': 'json', 'crs': storage_crs}) + rsp_headers, code, response = pg_api_.get_collection_item( + req, 'hot_osm_waterways', fid, + ) + + assert code == HTTPStatus.OK + assert rsp_headers['Content-Crs'] == f'<{storage_crs}>' + + feat_storage_crs = json.loads(response) + + # Without 'crs' query parameter or with 'crs' set to 'storage_crs', the + # geometries should be identical, when storage CRS is WGS84 lon,lat. + assert feat_orig['geometry'] == feat_storage_crs['geometry'] + + # With CRS query parameter resulting in coordinates transformation + req = mock_request({'f': 'json', 'crs': crs_32735}) + rsp_headers, code, response = pg_api_.get_collection_item( + req, 'hot_osm_waterways', fid, + ) + + assert code == HTTPStatus.OK + assert rsp_headers['Content-Crs'] == f'<{crs_32735}>' + + feat_32735 = json.loads(response) + geom_32735 = geojson_to_geom(feat_32735['geometry']) + + transform_func = get_transform_from_crs( + get_crs_from_uri(DEFAULT_CRS), + pyproj.CRS.from_epsg(32735), + always_xy=False, + ) + # Check that the coordinates of returned feature were transformed + assert geom_32735.equals_exact(transform_func(geom_orig), 1) + + def test_get_collection_items_postgresql_automap_naming_conflicts(pg_api_): """ Test that PostgreSQLProvider can handle naming conflicts when automapping diff --git a/tests/test_util.py b/tests/test_util.py index edda63c8e..d1c44cbea 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -32,6 +32,8 @@ from copy import deepcopy import pytest +from pyproj.exceptions import CRSError +from shapely.geometry import Point from pygeoapi import util from pygeoapi.api import __version__ @@ -126,7 +128,7 @@ def test_path_basename(): def test_filter_dict_by_key_value(config): collections = util.filter_dict_by_key_value(config['resources'], 'type', 'collection') - assert len(collections) == 7 + assert len(collections) == 8 notfound = util.filter_dict_by_key_value(config['resources'], 'type', 'foo') @@ -218,6 +220,86 @@ 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 + + +def test_get_crs_from_uri(): + with pytest.raises(CRSError): + util.get_crs_from_uri('http://www.opengis.net/not/a/valid/crs/uri') + with pytest.raises(CRSError): + util.get_crs_from_uri('http://www.opengis.net/def/crs/EPSG/0/0') + CRS_DICT = { + 'http://www.opengis.net/def/crs/OGC/1.3/CRS84': 'OGC:CRS84', + 'http://www.opengis.net/def/crs/EPSG/0/4326': 'EPSG:4326', + 'http://www.opengis.net/def/crs/EPSG/0/28992': 'EPSG:28992' + } + for key in CRS_DICT: + crs_obj = util.get_crs_from_uri(key) + assert crs_obj.srs == CRS_DICT[key] + + +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') == {}