Compare commits
66 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0235bba4e5 | |||
| e1fec87d6f | |||
| 065ef3a495 | |||
| 3188db91da | |||
| acc3b9ae93 | |||
| 183caacff4 | |||
| a3b42ba0ed | |||
| 2e4ff714f6 | |||
| 9e87184fbf | |||
| 09423fb4be | |||
| e4beaf758e | |||
| b6c38b66ee | |||
| d2f38dea07 | |||
| 3bdeefe4e7 | |||
| 179c90ff31 | |||
| e736fa3b2f | |||
| d240a8210e | |||
| 474cb60d82 | |||
| b3a70719a2 | |||
| 83ef1ac174 | |||
| 6b91024aa5 | |||
| 52bec0fa89 | |||
| 76fd130493 | |||
| 6682b44928 | |||
| deb043f928 | |||
| 0677c2e646 | |||
| 28618034b8 | |||
| 6ad14a6d54 | |||
| 1429a81887 | |||
| 15be1dcd4f | |||
| 33b4ff73a4 | |||
| 7a3d8a824e | |||
| 067b1587b9 | |||
| 08876b5843 | |||
| 7e734348da | |||
| 44c589c1a4 | |||
| 54b9be4463 | |||
| bc1e8a6566 | |||
| 7d1028cf11 | |||
| 501bc6e839 | |||
| 4e77d75ea3 | |||
| 60bd40385e | |||
| 2a131c5131 | |||
| 71ce03e548 | |||
| c1b90dc3ac | |||
| 9ad8706223 | |||
| d4063f360e | |||
| 4b28de6d42 | |||
| 491ceaff48 | |||
| d1dfa179b3 | |||
| a806f89a31 | |||
| 0a7bb7f5f4 | |||
| b712cb2695 | |||
| b8dcf6a885 | |||
| 86390a6f12 | |||
| b2a8e0678d | |||
| 3adfdb2341 | |||
| 6c538ca330 | |||
| af8483a25b | |||
| 0281732c5c | |||
| 7bb7b38016 | |||
| d600f55214 | |||
| bbb5035508 | |||
| 31480af845 | |||
| e2676bdc56 | |||
| f55aa875c2 |
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
# These are supported funding model platforms
|
# These are supported funding model platforms
|
||||||
|
|
||||||
#github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
#github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||||
custom: ['https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=WT27AS28UFSNW&source=url'] # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
custom: ['https://github.com/geopython/pygeoapi/wiki/Sponsorship'] # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||||
|
|||||||
@@ -62,6 +62,12 @@ jobs:
|
|||||||
host node port: 9300
|
host node port: 9300
|
||||||
node port: 9300
|
node port: 9300
|
||||||
discovery type: 'single-node'
|
discovery type: 'single-node'
|
||||||
|
- name: Install and run OpenSearch 📦
|
||||||
|
uses: esmarkowski/opensearch-github-action@v1.0.0
|
||||||
|
with:
|
||||||
|
version: 2.12.0
|
||||||
|
security-disabled: true
|
||||||
|
port: 9209
|
||||||
- name: Install and run MongoDB
|
- name: Install and run MongoDB
|
||||||
uses: supercharge/mongodb-github-action@1.5.0
|
uses: supercharge/mongodb-github-action@1.5.0
|
||||||
with:
|
with:
|
||||||
@@ -94,13 +100,12 @@ jobs:
|
|||||||
pip3 install -r requirements-manager.txt
|
pip3 install -r requirements-manager.txt
|
||||||
pip3 install -r requirements-django.txt
|
pip3 install -r requirements-django.txt
|
||||||
python3 setup.py install
|
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 --global-option=build_ext --global-option="-I/usr/include/gdal" GDAL==`gdal-config --version`
|
||||||
#pip3 install --upgrade rasterio==1.1.8
|
#pip3 install --upgrade rasterio==1.1.8
|
||||||
- name: setup test data ⚙️
|
- name: setup test data ⚙️
|
||||||
run: |
|
run: |
|
||||||
python3 tests/load_es_data.py tests/data/ne_110m_populated_places_simple.geojson geonameid
|
python3 tests/load_es_data.py tests/data/ne_110m_populated_places_simple.geojson geonameid
|
||||||
|
python3 tests/load_opensearch_data.py tests/data/ne_110m_populated_places_simple.geojson geonameid
|
||||||
python3 tests/load_mongo_data.py tests/data/ne_110m_populated_places_simple.geojson
|
python3 tests/load_mongo_data.py tests/data/ne_110m_populated_places_simple.geojson
|
||||||
gunzip < tests/data/hotosm_bdi_waterways.sql.gz | psql postgresql://postgres:${{ secrets.DatabasePassword || 'postgres' }}@localhost:5432/test
|
gunzip < tests/data/hotosm_bdi_waterways.sql.gz | psql postgresql://postgres:${{ secrets.DatabasePassword || 'postgres' }}@localhost:5432/test
|
||||||
psql postgresql://postgres:${{ secrets.DatabasePassword || 'postgres' }}@localhost:5432/test -f tests/data/dummy_data.sql
|
psql postgresql://postgres:${{ secrets.DatabasePassword || 'postgres' }}@localhost:5432/test -f tests/data/dummy_data.sql
|
||||||
@@ -119,6 +124,7 @@ jobs:
|
|||||||
pytest tests/test_csv__provider.py
|
pytest tests/test_csv__provider.py
|
||||||
pytest tests/test_django.py
|
pytest tests/test_django.py
|
||||||
pytest tests/test_elasticsearch__provider.py
|
pytest tests/test_elasticsearch__provider.py
|
||||||
|
pytest tests/test_opensearch__provider.py
|
||||||
pytest tests/test_esri_provider.py
|
pytest tests/test_esri_provider.py
|
||||||
pytest tests/test_filesystem_provider.py
|
pytest tests/test_filesystem_provider.py
|
||||||
pytest tests/test_geojson_provider.py
|
pytest tests/test_geojson_provider.py
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ jobs:
|
|||||||
working-directory: .
|
working-directory: .
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout pygeoapi
|
- name: Checkout pygeoapi
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@master
|
||||||
- name: Scan vulnerabilities with trivy
|
- name: Scan vulnerabilities with trivy
|
||||||
uses: aquasecurity/trivy-action@master
|
uses: aquasecurity/trivy-action@master
|
||||||
with:
|
with:
|
||||||
@@ -37,6 +37,9 @@ jobs:
|
|||||||
docker buildx build -t ${{ github.repository }}:${{ github.sha }} --platform linux/amd64 --no-cache -f Dockerfile .
|
docker buildx build -t ${{ github.repository }}:${{ github.sha }} --platform linux/amd64 --no-cache -f Dockerfile .
|
||||||
- name: Scan locally built Docker image for vulnerabilities with trivy
|
- name: Scan locally built Docker image for vulnerabilities with trivy
|
||||||
uses: aquasecurity/trivy-action@master
|
uses: aquasecurity/trivy-action@master
|
||||||
|
env:
|
||||||
|
TRIVY_DB_REPOSITORY: public.ecr.aws/aquasecurity/trivy-db:2
|
||||||
|
TRIVY_JAVA_DB_REPOSITORY: public.ecr.aws/aquasecurity/trivy-java-db:1
|
||||||
with:
|
with:
|
||||||
scan-type: image
|
scan-type: image
|
||||||
exit-code: 1
|
exit-code: 1
|
||||||
|
|||||||
+1
-2
@@ -34,7 +34,7 @@
|
|||||||
#
|
#
|
||||||
# =================================================================
|
# =================================================================
|
||||||
|
|
||||||
FROM ubuntu:jammy-20240627.1
|
FROM ubuntu:jammy-20240911.1
|
||||||
|
|
||||||
LABEL maintainer="Just van den Broecke <justb4@gmail.com>"
|
LABEL maintainer="Just van den Broecke <justb4@gmail.com>"
|
||||||
|
|
||||||
@@ -98,7 +98,6 @@ ENV TZ=${TZ} \
|
|||||||
python3-greenlet \
|
python3-greenlet \
|
||||||
python3-pip \
|
python3-pip \
|
||||||
python3-tz \
|
python3-tz \
|
||||||
python3-unicodecsv \
|
|
||||||
python3-yaml \
|
python3-yaml \
|
||||||
${ADD_DEB_PACKAGES}"
|
${ADD_DEB_PACKAGES}"
|
||||||
|
|
||||||
|
|||||||
@@ -48,8 +48,8 @@ server:
|
|||||||
limit: 10
|
limit: 10
|
||||||
# templates: /path/to/templates
|
# templates: /path/to/templates
|
||||||
map:
|
map:
|
||||||
url: https://maps.wikimedia.org/osm-intl/{z}/{x}/{y}.png
|
url: https://tile.openstreetmap.org/{z}/{x}/{y}.png
|
||||||
attribution: '<a href="https://wikimediafoundation.org/wiki/Maps_Terms_of_Use">Wikimedia maps</a> | Map data © <a href="https://openstreetmap.org/copyright">OpenStreetMap contributors</a>'
|
attribution: '© <a href="https://openstreetmap.org/copyright">OpenStreetMap contributors</a>'
|
||||||
ogc_schemas_location: /schemas.opengis.net
|
ogc_schemas_location: /schemas.opengis.net
|
||||||
|
|
||||||
logging:
|
logging:
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 208 KiB |
@@ -16,11 +16,14 @@
|
|||||||
<a title="OGC Reference Implementation" href="https://www.ogc.org/resource/products/details/?pid=1663">
|
<a title="OGC Reference Implementation" href="https://www.ogc.org/resource/products/details/?pid=1663">
|
||||||
<img alt="OGC Reference Implementation" src="https://portal.ogc.org/public_ogc/compliance/badge.php?s=ogcapi-tiles-1%201.0.1&r=1&n=1)" width="164" height="44"/>
|
<img alt="OGC Reference Implementation" src="https://portal.ogc.org/public_ogc/compliance/badge.php?s=ogcapi-tiles-1%201.0.1&r=1&n=1)" width="164" height="44"/>
|
||||||
</a>
|
</a>
|
||||||
|
<a title="OGC Reference Implementation" href="https://www.ogc.org/resource/products/details/?pid=1826">
|
||||||
|
<img alt="OGC Reference Implementation" src="https://portal.ogc.org/public_ogc/compliance/badge.php?s=ogcapi-processes-1%201.0.1&n=1)" width="164" height="44"/>
|
||||||
|
</a>
|
||||||
<a title="OSGeo Project" href="https://www.osgeo.org/projects/pygeoapi">
|
<a title="OSGeo Project" href="https://www.osgeo.org/projects/pygeoapi">
|
||||||
<img style="background: white;" alt="OSGeo Project" src="https://raw.githubusercontent.com/OSGeo/osgeo/master/incubation/project/OSGeo_project.png" width="164" height="64"/>
|
<img style="background: white;" alt="OSGeo Project" src="https://raw.githubusercontent.com/OSGeo/osgeo/master/incubation/project/OSGeo_project.png" width="164" height="64"/>
|
||||||
</a>
|
</a>
|
||||||
<a title="FOSS4G Conference" href="https://2023.foss4g.org">
|
<a title="FOSS4G Conference" href="https://2024.foss4g.org">
|
||||||
<img style="background: white;" alt="FOSS4G Conference" width="145" height="45" src="https://2023.foss4g.org/img/logo.png"/>
|
<img style="background: white;" alt="FOSS4G Conference" width="145" height="45" src="https://2024.foss4g.org/_next/static/media/foss4g-belem-logo-vertical.30d8343b.svg"/>
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,15 @@ The API is enabled with the following server configuration:
|
|||||||
server:
|
server:
|
||||||
admin: true # boolean on whether to enable Admin API.
|
admin: true # boolean on whether to enable Admin API.
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
|
||||||
|
If you generate the OpenAPI definition after enabling the admin API, the admin routes will be exposed on ``/openapi``
|
||||||
|
|
||||||
|
.. image:: /_static/openapi_admin.png
|
||||||
|
:alt: admin routes
|
||||||
|
:align: center
|
||||||
|
|
||||||
|
|
||||||
Access control
|
Access control
|
||||||
--------------
|
--------------
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -112,7 +112,7 @@ today_fmt = '%Y-%m-%d'
|
|||||||
# built documents.
|
# built documents.
|
||||||
#
|
#
|
||||||
# The short X.Y version.
|
# The short X.Y version.
|
||||||
version = '0.18.dev0'
|
version = '0.19.dev0'
|
||||||
# The full version, including alpha/beta/rc tags.
|
# The full version, including alpha/beta/rc tags.
|
||||||
release = version
|
release = version
|
||||||
|
|
||||||
|
|||||||
@@ -57,8 +57,8 @@ For more information related to API design rules (the ``api_rules`` property in
|
|||||||
static: /path/to/static/folder # path to static folder containing css, js, images and other static files referenced by the template
|
static: /path/to/static/folder # path to static folder containing css, js, images and other static files referenced by the template
|
||||||
|
|
||||||
map: # leaflet map setup for HTML pages
|
map: # leaflet map setup for HTML pages
|
||||||
url: https://maps.wikimedia.org/osm-intl/{z}/{x}/{y}.png
|
url: https://tile.openstreetmap.org/{z}/{x}/{y}.png
|
||||||
attribution: '<a href="https://wikimediafoundation.org/wiki/Maps_Terms_of_Use">Wikimedia maps</a> | Map data © <a href="https://openstreetmap.org/copyright">OpenStreetMap contributors</a>'
|
attribution: '© <a href="https://openstreetmap.org/copyright">OpenStreetMap contributors</a>'
|
||||||
ogc_schemas_location: /opt/schemas.opengis.net # local copy of https://schemas.opengis.net
|
ogc_schemas_location: /opt/schemas.opengis.net # local copy of https://schemas.opengis.net
|
||||||
|
|
||||||
manager: # optional OGC API - Processes asynchronous job management
|
manager: # optional OGC API - Processes asynchronous job management
|
||||||
@@ -241,7 +241,7 @@ default.
|
|||||||
option_name: option_value
|
option_name: option_value
|
||||||
|
|
||||||
hello-world: # name of process
|
hello-world: # name of process
|
||||||
type: collection # REQUIRED (collection, process, or stac-collection)
|
type: process # REQUIRED (collection, process, or stac-collection)
|
||||||
processor:
|
processor:
|
||||||
name: HelloWorld # Python path of process definition
|
name: HelloWorld # Python path of process definition
|
||||||
|
|
||||||
|
|||||||
@@ -72,9 +72,12 @@ The `Xarray`_ provider plugin reads and extracts `NetCDF`_ and `Zarr`_ data.
|
|||||||
data: tests/data/coads_sst.nc
|
data: tests/data/coads_sst.nc
|
||||||
# optionally specify x/y/time fields, else provider will attempt
|
# optionally specify x/y/time fields, else provider will attempt
|
||||||
# to derive automagically
|
# to derive automagically
|
||||||
x_field: lat
|
|
||||||
x_field: lon
|
x_field: lon
|
||||||
|
y_field: lat
|
||||||
time_field: time
|
time_field: time
|
||||||
|
# optionally specify the coordinate reference system of your dataset
|
||||||
|
# else pygeoapi assumes it is WGS84 (EPSG:4326).
|
||||||
|
storage_crs: 4326
|
||||||
format:
|
format:
|
||||||
name: netcdf
|
name: netcdf
|
||||||
mimetype: application/x-netcdf
|
mimetype: application/x-netcdf
|
||||||
@@ -96,6 +99,11 @@ The `Xarray`_ provider plugin reads and extracts `NetCDF`_ and `Zarr`_ data.
|
|||||||
be sure to provide the full S3 URL. Any parameters required to open the dataset
|
be sure to provide the full S3 URL. Any parameters required to open the dataset
|
||||||
using fsspec can be added to the config file under `options` and `s3`.
|
using fsspec can be added to the config file under `options` and `s3`.
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
When providing a `storage_crs` value in the xarray configuration, specify the
|
||||||
|
coordinate reference system using any valid input for
|
||||||
|
`pyproj.CRS.from_user_input`_.
|
||||||
|
|
||||||
Data access examples
|
Data access examples
|
||||||
--------------------
|
--------------------
|
||||||
|
|
||||||
@@ -146,3 +154,4 @@ Data access examples
|
|||||||
.. _`NetCDF`: https://en.wikipedia.org/wiki/NetCDF
|
.. _`NetCDF`: https://en.wikipedia.org/wiki/NetCDF
|
||||||
.. _`Zarr`: https://zarr.readthedocs.io/en/stable
|
.. _`Zarr`: https://zarr.readthedocs.io/en/stable
|
||||||
.. _`GDAL raster driver short name`: https://gdal.org/drivers/raster/index.html
|
.. _`GDAL raster driver short name`: https://gdal.org/drivers/raster/index.html
|
||||||
|
.. _`pyproj.CRS.from_user_input`: https://pyproj4.github.io/pyproj/stable/api/crs/coordinate_system.html#pyproj.crs.CoordinateSystem.from_user_input
|
||||||
|
|||||||
@@ -44,9 +44,12 @@ The `xarray-edr`_ provider plugin reads and extracts `NetCDF`_ and `Zarr`_ data
|
|||||||
data: tests/data/coads_sst.nc
|
data: tests/data/coads_sst.nc
|
||||||
# optionally specify x/y/time fields, else provider will attempt
|
# optionally specify x/y/time fields, else provider will attempt
|
||||||
# to derive automagically
|
# to derive automagically
|
||||||
x_field: lat
|
|
||||||
x_field: lon
|
x_field: lon
|
||||||
|
y_field: lat
|
||||||
time_field: time
|
time_field: time
|
||||||
|
# optionally specify the coordinate reference system of your dataset
|
||||||
|
# else pygeoapi assumes it is WGS84 (EPSG:4326).
|
||||||
|
storage_crs: 4326
|
||||||
format:
|
format:
|
||||||
name: netcdf
|
name: netcdf
|
||||||
mimetype: application/x-netcdf
|
mimetype: application/x-netcdf
|
||||||
@@ -81,6 +84,11 @@ The `xarray-edr`_ provider plugin reads and extracts `NetCDF`_ and `Zarr`_ data
|
|||||||
S3 URL. Any parameters required to open the dataset using fsspec can be added
|
S3 URL. Any parameters required to open the dataset using fsspec can be added
|
||||||
to the config file under `options` and `s3`, as shown above.
|
to the config file under `options` and `s3`, as shown above.
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
When providing a `storage_crs` value in the EDR configuration, specify the
|
||||||
|
coordinate reference system using any valid input for
|
||||||
|
`pyproj.CRS.from_user_input`_.
|
||||||
|
|
||||||
|
|
||||||
Data access examples
|
Data access examples
|
||||||
--------------------
|
--------------------
|
||||||
@@ -105,6 +113,7 @@ Data access examples
|
|||||||
.. _`xarray`: https://docs.xarray.dev/en/stable/
|
.. _`xarray`: https://docs.xarray.dev/en/stable/
|
||||||
.. _`NetCDF`: https://en.wikipedia.org/wiki/NetCDF
|
.. _`NetCDF`: https://en.wikipedia.org/wiki/NetCDF
|
||||||
.. _`Zarr`: https://zarr.readthedocs.io/en/stable
|
.. _`Zarr`: https://zarr.readthedocs.io/en/stable
|
||||||
|
.. _`pyproj.CRS.from_user_input`: https://pyproj4.github.io/pyproj/stable/api/crs/coordinate_system.html#pyproj.crs.CoordinateSystem.from_user_input
|
||||||
|
|
||||||
|
|
||||||
.. _`OGC Environmental Data Retrieval (EDR) (API)`: https://github.com/opengeospatial/ogcapi-coverages
|
.. _`OGC Environmental Data Retrieval (EDR) (API)`: https://github.com/opengeospatial/ogcapi-coverages
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ parameters.
|
|||||||
`GeoJSON`_,✅/✅,results/hits,❌,❌,❌,✅,❌,❌,✅
|
`GeoJSON`_,✅/✅,results/hits,❌,❌,❌,✅,❌,❌,✅
|
||||||
`MongoDB`_,✅/❌,results,✅,✅,✅,✅,❌,❌,✅
|
`MongoDB`_,✅/❌,results,✅,✅,✅,✅,❌,❌,✅
|
||||||
`OGR`_,✅/❌,results/hits,✅,❌,❌,✅,❌,❌,✅
|
`OGR`_,✅/❌,results/hits,✅,❌,❌,✅,❌,❌,✅
|
||||||
|
`OpenSearch`_,✅/✅,results/hits,✅,✅,✅,✅,✅,✅,✅
|
||||||
`Oracle`_,✅/✅,results/hits,✅,❌,✅,✅,❌,❌,✅
|
`Oracle`_,✅/✅,results/hits,✅,❌,✅,✅,❌,❌,✅
|
||||||
|
`Parquet`_,✅/✅,results/hits,✅,✅,❌,✅,❌,❌,✅
|
||||||
`PostgreSQL`_,✅/✅,results/hits,✅,✅,✅,✅,✅,❌,✅
|
`PostgreSQL`_,✅/✅,results/hits,✅,✅,✅,✅,✅,❌,✅
|
||||||
`SQLiteGPKG`_,✅/❌,results/hits,✅,❌,❌,✅,❌,❌,✅
|
`SQLiteGPKG`_,✅/❌,results/hits,✅,❌,❌,✅,❌,❌,✅
|
||||||
`SensorThings API`_,✅/✅,results/hits,✅,✅,✅,✅,❌,❌,✅
|
`SensorThings API`_,✅/✅,results/hits,✅,✅,✅,✅,❌,❌,✅
|
||||||
@@ -144,7 +146,11 @@ To publish an ESRI `Feature Service`_ or `Map Service`_ specify the URL for the
|
|||||||
|
|
||||||
* ``id_field`` will often be ``OBJECTID``, ``objectid``, or ``FID``.
|
* ``id_field`` will often be ``OBJECTID``, ``objectid``, or ``FID``.
|
||||||
* If the map or feature service is not shared publicly, the ``username`` and ``password`` fields can be set in the
|
* If the map or feature service is not shared publicly, the ``username`` and ``password`` fields can be set in the
|
||||||
configuration to authenticate into the service.
|
configuration to authenticate to the service.
|
||||||
|
* If the map or feature service is self-hosted and not shared publicly, the ``token_service`` and optional ``referer`` fields
|
||||||
|
can be set in the configuration to authenticate to the service.
|
||||||
|
|
||||||
|
To publish from an ArcGIS online hosted service:
|
||||||
|
|
||||||
.. code-block:: yaml
|
.. code-block:: yaml
|
||||||
|
|
||||||
@@ -157,6 +163,24 @@ To publish an ESRI `Feature Service`_ or `Map Service`_ specify the URL for the
|
|||||||
crs: 4326 # Optional crs (default is EPSG:4326)
|
crs: 4326 # Optional crs (default is EPSG:4326)
|
||||||
username: username # Optional ArcGIS username
|
username: username # Optional ArcGIS username
|
||||||
password: password # Optional ArcGIS password
|
password: password # Optional ArcGIS password
|
||||||
|
token_service: https://your.server.com/arcgis/sharing/rest/generateToken # optional URL to your generateToken service
|
||||||
|
referer: https://your.server.com # optional referer, defaults to https://www.arcgis.com if not set
|
||||||
|
|
||||||
|
To publish from a self-hosted service that is not publicly accessible, the ``token_service`` field is required:
|
||||||
|
|
||||||
|
.. code-block:: yaml
|
||||||
|
|
||||||
|
providers:
|
||||||
|
- type: feature
|
||||||
|
name: ESRI
|
||||||
|
data: https://your.server.com/arcgis/rest/services/your-layer/MapServer/0
|
||||||
|
id_field: objectid
|
||||||
|
time_field: date_in_your_device_time_zone # Optional time field
|
||||||
|
crs: 4326 # Optional crs (default is EPSG:4326)
|
||||||
|
username: username # Optional ArcGIS username
|
||||||
|
password: password # Optional ArcGIS password
|
||||||
|
token_service: https://your.server.com/arcgis/sharing/rest/generateToken # Optional url to your generateToken service
|
||||||
|
referer: https://your.server.com # Optional referer, defaults to https://www.arcgis.com if not set
|
||||||
|
|
||||||
GeoJSON
|
GeoJSON
|
||||||
^^^^^^^
|
^^^^^^^
|
||||||
@@ -299,6 +323,44 @@ The OGR provider requires a recent (3+) version of GDAL to be installed.
|
|||||||
The `crs` query parameter is used as follows:
|
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``.
|
e.g. ``http://localhost:5000/collections/foo/items?crs=http%3A%2F%2Fwww.opengis.net%2Fdef%2Fcrs%2FEPSG%2F0%2F28992``.
|
||||||
|
|
||||||
|
.. _OpenSearch:
|
||||||
|
|
||||||
|
OpenSearch
|
||||||
|
^^^^^^^^^^
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
Requires Python package opensearch-py
|
||||||
|
|
||||||
|
To publish an OpenSearch index, the following are required in your index:
|
||||||
|
|
||||||
|
* indexes must be documents of valid GeoJSON Features
|
||||||
|
* index mappings must define the GeoJSON ``geometry`` as a ``geo_shape``
|
||||||
|
|
||||||
|
.. code-block:: yaml
|
||||||
|
|
||||||
|
providers:
|
||||||
|
- type: feature
|
||||||
|
name: OpenSearch
|
||||||
|
editable: true|false # optional, default is false
|
||||||
|
data: http://localhost:9200/ne_110m_populated_places_simple
|
||||||
|
id_field: geonameid
|
||||||
|
time_field: datetimefield
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
|
||||||
|
For OpenSearch indexes that are password protect, a RFC1738 URL can be used as follows:
|
||||||
|
|
||||||
|
``data: http://username:password@localhost:9200/ne_110m_populated_places_simple``
|
||||||
|
|
||||||
|
To further conceal authentication credentials, environment variables can be used:
|
||||||
|
|
||||||
|
``data: http://${MY_USERNAME}:${MY_PASSWORD}@localhost:9200/ne_110m_populated_places_simple``
|
||||||
|
|
||||||
|
The OpenSearch provider also has the support for the CQL queries as indicated in the table above.
|
||||||
|
|
||||||
|
.. seealso::
|
||||||
|
:ref:`cql` for more details on how to use Common Query Language (CQL) to filter the collection with specific queries.
|
||||||
|
|
||||||
.. _Oracle:
|
.. _Oracle:
|
||||||
|
|
||||||
Oracle
|
Oracle
|
||||||
@@ -420,7 +482,7 @@ Configured using environment variables.
|
|||||||
export ORACLE_POOL_MAX=10
|
export ORACLE_POOL_MAX=10
|
||||||
|
|
||||||
|
|
||||||
The ``ORACLE_POOL_MIN`` and ``ORACLE_POOL_MAX`` environment variables are used to trigger session pool creation in the Oracle Provider and the ``DatabaseConnection`` class. See https://python-oracledb.readthedocs.io/en/latest/api_manual/module.html#oracledb.create_pool for documentation of the ``create_pool`` function.
|
The ``ORACLE_POOL_MIN`` and ``ORACLE_POOL_MAX`` environment variables are used to trigger session pool creation in the Oracle Provider and the ``DatabaseConnection`` class. Supports auth via user + password or wallet. For an example of the configuration see above at Oracle - Connection. See https://python-oracledb.readthedocs.io/en/latest/api_manual/module.html#oracledb.create_pool for documentation of the ``create_pool`` function.
|
||||||
|
|
||||||
If none or only one of the environment variables is set, session pooling will not be activated and standalone connections are established at every request.
|
If none or only one of the environment variables is set, session pooling will not be activated and standalone connections are established at every request.
|
||||||
|
|
||||||
@@ -432,6 +494,40 @@ useful e.g. for authorization at row level or manipulation of the explain plan w
|
|||||||
|
|
||||||
An example an more information about that feature you can find in the test class in tests/test_oracle_provider.py.
|
An example an more information about that feature you can find in the test class in tests/test_oracle_provider.py.
|
||||||
|
|
||||||
|
.. _Parquet:
|
||||||
|
|
||||||
|
Parquet
|
||||||
|
^^^^^^^
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
Requires Python package pyarrow
|
||||||
|
|
||||||
|
To publish a GeoParquet file (with a geometry column) the geopandas package is also required.
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
Reading data directly from a public s3 bucket is also supported.
|
||||||
|
|
||||||
|
.. code-block:: yaml
|
||||||
|
|
||||||
|
providers:
|
||||||
|
- type: feature
|
||||||
|
name: Parquet
|
||||||
|
data:
|
||||||
|
source: ./tests/data/parquet/random.parquet
|
||||||
|
id_field: id
|
||||||
|
time_field: time
|
||||||
|
x_field:
|
||||||
|
- minlon
|
||||||
|
- maxlon
|
||||||
|
y_field:
|
||||||
|
- minlat
|
||||||
|
- maxlat
|
||||||
|
|
||||||
|
For GeoParquet data, the `x_field` and `y_field` must be specified in the provider definition,
|
||||||
|
and they must be arrays of two column names that contain the x and y coordinates of the
|
||||||
|
bounding box of each geometry. If the geometries in the data are all points, the `x_field` and `y_field`
|
||||||
|
can be strings instead of arrays and refer to a single column each.
|
||||||
|
|
||||||
.. _PostgreSQL:
|
.. _PostgreSQL:
|
||||||
|
|
||||||
PostgreSQL
|
PostgreSQL
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ Currently supported style files (`options.style`):
|
|||||||
.. code-block:: yaml
|
.. code-block:: yaml
|
||||||
|
|
||||||
providers:
|
providers:
|
||||||
- type: map
|
- type: map
|
||||||
name: MapScript
|
name: MapScript
|
||||||
data: /path/to/data.shp
|
data: /path/to/data.shp
|
||||||
options:
|
options:
|
||||||
@@ -59,7 +59,7 @@ Currently supported style files (`options.style`):
|
|||||||
layer: foo_name
|
layer: foo_name
|
||||||
style: ./foo.sld
|
style: ./foo.sld
|
||||||
format:
|
format:
|
||||||
name: png
|
name: png
|
||||||
mimetype: image/png
|
mimetype: image/png
|
||||||
|
|
||||||
WMSFacade
|
WMSFacade
|
||||||
@@ -71,14 +71,15 @@ required. An optional style name can be defined via `options.style`.
|
|||||||
.. code-block:: yaml
|
.. code-block:: yaml
|
||||||
|
|
||||||
providers:
|
providers:
|
||||||
- type: map
|
- type: map
|
||||||
name: WMSFacade
|
name: WMSFacade
|
||||||
data: https://demo.mapserver.org/cgi-bin/msautotest
|
data: https://demo.mapserver.org/cgi-bin/msautotest
|
||||||
options:
|
options:
|
||||||
layer: world_latlong
|
layer: world_latlong
|
||||||
style: default
|
style: default
|
||||||
|
version: 1.3.0
|
||||||
format:
|
format:
|
||||||
name: png
|
name: png
|
||||||
mimetype: image/png
|
mimetype: image/png
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,15 +14,47 @@ The pygeoapi offers two processes: a default ``hello-world`` process which allow
|
|||||||
Configuration
|
Configuration
|
||||||
-------------
|
-------------
|
||||||
|
|
||||||
|
The below configuration is an example of a process defined within the pygeoapi internal plugin registry:
|
||||||
|
|
||||||
.. code-block:: yaml
|
.. code-block:: yaml
|
||||||
|
|
||||||
processes:
|
processes:
|
||||||
|
# enabled by default
|
||||||
# enabled by default
|
|
||||||
hello-world:
|
hello-world:
|
||||||
processor:
|
processor:
|
||||||
name: HelloWorld
|
name: HelloWorld
|
||||||
|
|
||||||
|
The below configuration is an example of a process defined as part of a custom Python process:
|
||||||
|
|
||||||
|
.. code-block:: yaml
|
||||||
|
|
||||||
|
processes:
|
||||||
|
# enabled by default
|
||||||
|
hello-world:
|
||||||
|
processor:
|
||||||
|
# refer to a process in the standard PYTHONPATH
|
||||||
|
# e.g. my_package/my_module/my_file.py (class MyProcess)
|
||||||
|
# the MyProcess class must subclass from pygeoapi.process.base.BaseProcessor
|
||||||
|
name: my_package.my_module.my_file.MyProcess
|
||||||
|
|
||||||
|
See :ref:`example-custom-pygeoapi-processing-plugin` for processing plugin examples.
|
||||||
|
|
||||||
|
Processing and response handling
|
||||||
|
--------------------------------
|
||||||
|
|
||||||
|
pygeoapi processing plugins must return a tuple of media type and native outputs. Multipart
|
||||||
|
responses are not supported at this time, and it is up to the process plugin implementor to return a single
|
||||||
|
payload defining multiple artifacts (or references to them).
|
||||||
|
|
||||||
|
By default (or via the OGC API - Processes ``response: raw`` execution parameter), pygeoapi provides
|
||||||
|
processing responses in their native encoding and media type, as defined by a given
|
||||||
|
plugin (which needs to set the response content type and payload accordingly).
|
||||||
|
|
||||||
|
pygeoapi also supports a JSON-based response type (via the OGC API - Processes ``response: document``
|
||||||
|
execution parameter). When this mode is requested, the response will always be a JSON encoding, embedding
|
||||||
|
the resulting payload (part of which may be Base64 encoded for binary data, for example).
|
||||||
|
|
||||||
|
|
||||||
Asynchronous support
|
Asynchronous support
|
||||||
--------------------
|
--------------------
|
||||||
|
|
||||||
@@ -33,15 +65,27 @@ an asynchronous design pattern. This means that when a job is submitted in asyn
|
|||||||
mode, the server responds immediately with a reference to the job, which allows the client
|
mode, the server responds immediately with a reference to the job, which allows the client
|
||||||
to periodically poll the server for the processing status of a given job.
|
to periodically poll the server for the processing status of a given job.
|
||||||
|
|
||||||
pygeoapi provides asynchronous support by providing a 'manager' concept which, well,
|
In keeping with the OGC API - Processes specification, asynchronous process execution
|
||||||
|
can be requested by including the ``Prefer: respond-async`` HTTP header in the request.
|
||||||
|
|
||||||
|
Job management is required for asynchronous functionality.
|
||||||
|
|
||||||
|
Job management
|
||||||
|
--------------
|
||||||
|
|
||||||
|
pygeoapi provides job management by providing a 'manager' concept which, well,
|
||||||
manages job execution. The manager concept is implemented as part of the pygeoapi
|
manages job execution. The manager concept is implemented as part of the pygeoapi
|
||||||
:ref:`plugins` architecture. pygeoapi provides a default manager implementation
|
:ref:`plugins` architecture. pygeoapi provides a default manager implementation
|
||||||
based on `TinyDB`_ for simplicity. Custom manager plugins can be developed for more
|
based on `TinyDB`_ for simplicity. Custom manager plugins can be developed for more
|
||||||
advanced job management capabilities (e.g. Kubernetes, databases, etc.).
|
advanced job management capabilities (e.g. Kubernetes, databases, etc.).
|
||||||
|
|
||||||
In keeping with the OGC API - Processes specification, asynchronous process execution
|
Job managers
|
||||||
can be requested by including the ``Prefer: respond-async`` HTTP header in the request
|
------------
|
||||||
|
|
||||||
|
TinyDB
|
||||||
|
^^^^^^
|
||||||
|
|
||||||
|
TinyDB is the default job manager for pygeoapi when enabled.
|
||||||
|
|
||||||
.. code-block:: yaml
|
.. code-block:: yaml
|
||||||
|
|
||||||
@@ -52,11 +96,12 @@ can be requested by including the ``Prefer: respond-async`` HTTP header in the r
|
|||||||
output_dir: /tmp/
|
output_dir: /tmp/
|
||||||
|
|
||||||
MongoDB
|
MongoDB
|
||||||
--------------------
|
^^^^^^^
|
||||||
As an alternative to the default a manager employing `MongoDB`_ can be used.
|
|
||||||
The connection to an installed `MongoDB`_ instance must be provided in the configuration.
|
As an alternative to the default, a manager employing `MongoDB`_ can be used.
|
||||||
`MongoDB`_ uses the localhost and port 27017 by default. Jobs are stored in a collection named
|
The connection to a `MongoDB`_ instance must be provided in the configuration.
|
||||||
job_manager_pygeoapi.
|
`MongoDB`_ uses ``localhost`` and port ``27017`` by default. Jobs are stored in a collection named
|
||||||
|
``job_manager_pygeoapi``.
|
||||||
|
|
||||||
.. code-block:: yaml
|
.. code-block:: yaml
|
||||||
|
|
||||||
@@ -66,11 +111,34 @@ job_manager_pygeoapi.
|
|||||||
connection: mongodb://host:port
|
connection: mongodb://host:port
|
||||||
output_dir: /tmp/
|
output_dir: /tmp/
|
||||||
|
|
||||||
|
PostgreSQL
|
||||||
|
^^^^^^^^^^
|
||||||
|
|
||||||
|
As another alternative to the default, a manager employing `PostgreSQL`_ can be used.
|
||||||
|
The connection to a `PostgreSQL`_ database must be provided in the configuration.
|
||||||
|
`PostgreSQL`_ uses ``localhost`` and port ``5432`` by default. Jobs are stored in a table named ``jobs``.
|
||||||
|
|
||||||
|
.. code-block:: yaml
|
||||||
|
|
||||||
|
server:
|
||||||
|
manager:
|
||||||
|
name: PostgreSQL
|
||||||
|
connection:
|
||||||
|
host: localhost
|
||||||
|
port: 5432
|
||||||
|
database: test
|
||||||
|
user: postgres
|
||||||
|
password: ${POSTGRESQL_PASSWORD:-postgres}
|
||||||
|
# Alternative accepted connection definition:
|
||||||
|
# connection: postgresql://postgres:postgres@localhost:5432/test
|
||||||
|
# connection: postgresql://postgres:${POSTGRESQL_PASSWORD:-postgres}@localhost:5432/test
|
||||||
|
output_dir: /tmp
|
||||||
|
|
||||||
|
|
||||||
Putting it all together
|
Putting it all together
|
||||||
-----------------------
|
-----------------------
|
||||||
|
|
||||||
To summarize how pygeoapi processes and managers work together::
|
To summarize how pygeoapi processes and managers work together:
|
||||||
|
|
||||||
* process plugins implement the core processing / workflow functionality
|
* process plugins implement the core processing / workflow functionality
|
||||||
* manager plugins control and manage how processes are executed
|
* manager plugins control and manage how processes are executed
|
||||||
|
|||||||
@@ -106,8 +106,8 @@ Following block shows how to configure pygeoapi to read Mapbox vector tiles from
|
|||||||
zoom:
|
zoom:
|
||||||
min: 0
|
min: 0
|
||||||
max: 15
|
max: 15
|
||||||
schemes:
|
schemes:
|
||||||
- WebMercatorQuad # this option is needed in the MVT-proxy provider
|
- WebMercatorQuad # this option is needed in the MVT-proxy provider
|
||||||
format:
|
format:
|
||||||
name: pbf
|
name: pbf
|
||||||
mimetype: application/vnd.mapbox-vector-tile
|
mimetype: application/vnd.mapbox-vector-tile
|
||||||
@@ -124,8 +124,8 @@ Following code block shows how to configure pygeoapi to read Mapbox vector tiles
|
|||||||
zoom:
|
zoom:
|
||||||
min: 0
|
min: 0
|
||||||
max: 15
|
max: 15
|
||||||
schemes:
|
schemes:
|
||||||
- WebMercatorQuad
|
- WebMercatorQuad
|
||||||
format:
|
format:
|
||||||
name: pbf
|
name: pbf
|
||||||
mimetype: application/vnd.mapbox-vector-tile
|
mimetype: application/vnd.mapbox-vector-tile
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ Requirements and dependencies
|
|||||||
|
|
||||||
pygeoapi runs on Python 3.
|
pygeoapi runs on Python 3.
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
|
||||||
|
The exact Python version requirements are aligned with the version of Python on the pygeoapi supported Ubuntu
|
||||||
|
operating system version. For example, as of 2024-07, the supported version of Python is bound to Ubuntu 22.04
|
||||||
|
(Jammy) which supports Python 3.10. Ensure you have a Python version that is compatible with the current Ubuntu
|
||||||
|
version that is specified in pygeoapi's `Dockerfile`_.
|
||||||
|
|
||||||
Core dependencies are included as part of a given pygeoapi installation procedure. More specific requirements
|
Core dependencies are included as part of a given pygeoapi installation procedure. More specific requirements
|
||||||
details are described below depending on the platform.
|
details are described below depending on the platform.
|
||||||
|
|
||||||
@@ -32,7 +39,7 @@ For developers and the truly impatient
|
|||||||
vi example-config.yml # edit as required
|
vi example-config.yml # edit as required
|
||||||
export PYGEOAPI_CONFIG=example-config.yml
|
export PYGEOAPI_CONFIG=example-config.yml
|
||||||
export PYGEOAPI_OPENAPI=example-openapi.yml
|
export PYGEOAPI_OPENAPI=example-openapi.yml
|
||||||
pygeoapi openapi generate $PYGEOAPI_CONFIG > $PYGEOAPI_OPENAPI
|
pygeoapi openapi generate $PYGEOAPI_CONFIG --output-file $PYGEOAPI_OPENAPI
|
||||||
pygeoapi serve
|
pygeoapi serve
|
||||||
curl http://localhost:5000
|
curl http://localhost:5000
|
||||||
|
|
||||||
@@ -142,3 +149,4 @@ onto your system.
|
|||||||
|
|
||||||
|
|
||||||
.. _`Docker image`: https://github.com/geopython/pygeoapi/pkgs/container/pygeoapi
|
.. _`Docker image`: https://github.com/geopython/pygeoapi/pkgs/container/pygeoapi
|
||||||
|
.. _`Dockerfile`: https://github.com/geopython/pygeoapi/blob/master/Dockerfile
|
||||||
|
|||||||
@@ -14,12 +14,12 @@ Features
|
|||||||
* OGC API - Features
|
* OGC API - Features
|
||||||
* OGC API - Environmental Data Retrieval
|
* OGC API - Environmental Data Retrieval
|
||||||
* OGC API - Tiles
|
* OGC API - Tiles
|
||||||
|
* OGC API - Processes
|
||||||
|
|
||||||
* additionally implements
|
* additionally implements
|
||||||
|
|
||||||
* OGC API - Coverages
|
* OGC API - Coverages
|
||||||
* OGC API - Maps
|
* OGC API - Maps
|
||||||
* OGC API - Processes
|
|
||||||
* OGC API - Records
|
* OGC API - Records
|
||||||
* SpatioTemporal Asset Library
|
* SpatioTemporal Asset Library
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ Standards are at the core of pygeoapi. Below is the project's standards support
|
|||||||
`OGC API - Coverages`_,Implementing
|
`OGC API - Coverages`_,Implementing
|
||||||
`OGC API - Maps`_,Implementing
|
`OGC API - Maps`_,Implementing
|
||||||
`OGC API - Tiles`_,Reference Implementation
|
`OGC API - Tiles`_,Reference Implementation
|
||||||
`OGC API - Processes`_,Implementing
|
`OGC API - Processes`_,Compliant
|
||||||
`OGC API - Records`_,Implementing
|
`OGC API - Records`_,Implementing
|
||||||
`OGC API - Environmental Data Retrieval`_,Reference Implementation
|
`OGC API - Environmental Data Retrieval`_,Reference Implementation
|
||||||
`SpatioTemporal Asset Catalog`_,Implementing
|
`SpatioTemporal Asset Catalog`_,Implementing
|
||||||
|
|||||||
@@ -240,15 +240,16 @@ The below template provides a minimal example (let's call the file ``mycoolraste
|
|||||||
super().__init__(provider_def)
|
super().__init__(provider_def)
|
||||||
self.num_bands = 4
|
self.num_bands = 4
|
||||||
self.axes = ['Lat', 'Long']
|
self.axes = ['Lat', 'Long']
|
||||||
self.fields = self.get_fields()
|
self.get_fields()
|
||||||
|
|
||||||
def get_fields(self):
|
def get_fields(self):
|
||||||
# generate a JSON Schema of coverage band metadata
|
# generate a JSON Schema of coverage band metadata
|
||||||
return {
|
self._fields = {
|
||||||
'b1': {
|
'b1': {
|
||||||
'type': 'number'
|
'type': 'number'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return self._fields
|
||||||
|
|
||||||
def query(self, bands=[], subsets={}, format_='json', **kwargs):
|
def query(self, bands=[], subsets={}, format_='json', **kwargs):
|
||||||
# process bands and subsets parameters
|
# process bands and subsets parameters
|
||||||
@@ -272,6 +273,8 @@ implementation.
|
|||||||
|
|
||||||
Each base class documents the functions, arguments and return types required for implementation.
|
Each base class documents the functions, arguments and return types required for implementation.
|
||||||
|
|
||||||
|
.. _example-custom-pygeoapi-processing-plugin:
|
||||||
|
|
||||||
Example: custom pygeoapi processing plugin
|
Example: custom pygeoapi processing plugin
|
||||||
------------------------------------------
|
------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -14,4 +14,5 @@ as required.
|
|||||||
The following projects provide security frameworks atop pygeoapi:
|
The following projects provide security frameworks atop pygeoapi:
|
||||||
|
|
||||||
* `fastgeoapi <https://github.com/geobeyond/fastgeoapi>`_
|
* `fastgeoapi <https://github.com/geobeyond/fastgeoapi>`_
|
||||||
* `pygeoapi-auth <https://github.com/cartologic/pygeoapi-auth>`_
|
* `pygeoapi-auth-deployment <https://github.com/cartologic/pygeoapi-auth-deployment>`_
|
||||||
|
* `pygeoapi-auth <https://github.com/geopython/pygeoapi-auth>`_ (Python package for use along with pygeoapi-auth-deployment)
|
||||||
|
|||||||
@@ -0,0 +1,748 @@
|
|||||||
|
# Arabic translations for PROJECT.
|
||||||
|
# Copyright (C) 2024 OSGeo
|
||||||
|
# This file is distributed under the same license as the pygeoapi project.
|
||||||
|
# FIRST AUTHOR Youssef Harby, 2024.
|
||||||
|
#
|
||||||
|
msgid ""
|
||||||
|
msgstr ""
|
||||||
|
"Project-Id-Version: 0.0.19\n"
|
||||||
|
"Report-Msgid-Bugs-To: pygeoapi@lists.osgeo.org\n"
|
||||||
|
"POT-Creation-Date: 2024-11-19 23:22+0200\n"
|
||||||
|
"PO-Revision-Date: 2024-11-19 23:22+0200\n"
|
||||||
|
"Last-Translator: Youssef Harby <me@youssefharby.com>\n"
|
||||||
|
"Language: ar\n"
|
||||||
|
"Language-Team: ar <LL@li.org>\n"
|
||||||
|
"Plural-Forms: nplurals=6; plural=(n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : "
|
||||||
|
"n%100>=3 && n%100<=10 ? 3 : n%100>=0 && n%100<=2 ? 4 : 5);\n"
|
||||||
|
"MIME-Version: 1.0\n"
|
||||||
|
"Content-Type: text/plain; charset=utf-8\n"
|
||||||
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
|
"Generated-By: Babel 2.13.0\n"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/_base.html:62
|
||||||
|
#: build/lib/pygeoapi/templates/landing_page.html:2
|
||||||
|
#: pygeoapi/templates/_base.html:67 pygeoapi/templates/landing_page.html:2
|
||||||
|
msgid "Home"
|
||||||
|
msgstr "الصفحة الرئيسية"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/_base.html:70
|
||||||
|
#: build/lib/pygeoapi/templates/_base.html:78 pygeoapi/templates/_base.html:75
|
||||||
|
#: pygeoapi/templates/_base.html:83
|
||||||
|
msgid "json"
|
||||||
|
msgstr "json"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/_base.html:73
|
||||||
|
#: build/lib/pygeoapi/templates/_base.html:81 pygeoapi/templates/_base.html:78
|
||||||
|
#: pygeoapi/templates/_base.html:86
|
||||||
|
msgid "jsonld"
|
||||||
|
msgstr "jsonld"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/_base.html:100
|
||||||
|
#: pygeoapi/templates/_base.html:107
|
||||||
|
msgid "Powered by "
|
||||||
|
msgstr "مدعوم بواسطة "
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/conformance.html:2
|
||||||
|
#: build/lib/pygeoapi/templates/conformance.html:4
|
||||||
|
#: build/lib/pygeoapi/templates/conformance.html:8
|
||||||
|
#: build/lib/pygeoapi/templates/landing_page.html:86
|
||||||
|
#: pygeoapi/templates/conformance.html:2 pygeoapi/templates/conformance.html:4
|
||||||
|
#: pygeoapi/templates/conformance.html:8
|
||||||
|
#: pygeoapi/templates/landing_page.html:95
|
||||||
|
msgid "Conformance"
|
||||||
|
msgstr "التوافق"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/exception.html:2
|
||||||
|
#: build/lib/pygeoapi/templates/exception.html:5
|
||||||
|
#: pygeoapi/templates/exception.html:2 pygeoapi/templates/exception.html:5
|
||||||
|
msgid "Exception"
|
||||||
|
msgstr "استثناء"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/landing_page.html:25
|
||||||
|
#: pygeoapi/templates/landing_page.html:25
|
||||||
|
msgid "Terms of service"
|
||||||
|
msgstr "شروط الخدمة"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/collections/collection.html:38
|
||||||
|
#: build/lib/pygeoapi/templates/landing_page.html:35
|
||||||
|
#: pygeoapi/templates/collections/collection.html:38
|
||||||
|
#: pygeoapi/templates/landing_page.html:35
|
||||||
|
msgid "License"
|
||||||
|
msgstr "الرخصة"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/collections/collection.html:6
|
||||||
|
#: build/lib/pygeoapi/templates/collections/coverage/domainset.html:4
|
||||||
|
#: build/lib/pygeoapi/templates/collections/coverage/rangetype.html:4
|
||||||
|
#: build/lib/pygeoapi/templates/collections/edr/query.html:4
|
||||||
|
#: build/lib/pygeoapi/templates/collections/index.html:2
|
||||||
|
#: build/lib/pygeoapi/templates/collections/index.html:4
|
||||||
|
#: build/lib/pygeoapi/templates/collections/items/index.html:4
|
||||||
|
#: build/lib/pygeoapi/templates/collections/items/item.html:27
|
||||||
|
#: build/lib/pygeoapi/templates/collections/queryables.html:4
|
||||||
|
#: build/lib/pygeoapi/templates/collections/tiles/index.html:4
|
||||||
|
#: build/lib/pygeoapi/templates/collections/tiles/metadata.html:4
|
||||||
|
#: build/lib/pygeoapi/templates/landing_page.html:48
|
||||||
|
#: pygeoapi/templates/collections/collection.html:6
|
||||||
|
#: pygeoapi/templates/collections/edr/query.html:4
|
||||||
|
#: pygeoapi/templates/collections/index.html:2
|
||||||
|
#: pygeoapi/templates/collections/index.html:4
|
||||||
|
#: pygeoapi/templates/collections/items/index.html:4
|
||||||
|
#: pygeoapi/templates/collections/items/item.html:27
|
||||||
|
#: pygeoapi/templates/collections/queryables.html:4
|
||||||
|
#: pygeoapi/templates/collections/schema.html:4
|
||||||
|
#: pygeoapi/templates/collections/tiles/index.html:4
|
||||||
|
#: pygeoapi/templates/collections/tiles/metadata.html:4
|
||||||
|
#: pygeoapi/templates/landing_page.html:57
|
||||||
|
#: pygeoapi/templates/stac/collection_base.html:19
|
||||||
|
msgid "Collections"
|
||||||
|
msgstr "المجموعات"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/landing_page.html:50
|
||||||
|
#: pygeoapi/templates/landing_page.html:59
|
||||||
|
msgid "View the collections in this service"
|
||||||
|
msgstr "عرض المجموعات في هذه الخدمة"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/landing_page.html:56
|
||||||
|
#: pygeoapi/templates/landing_page.html:65
|
||||||
|
msgid "SpatioTemporal Assets"
|
||||||
|
msgstr "الأصول الزمانية والمكانية (STAC)"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/landing_page.html:58
|
||||||
|
#: pygeoapi/templates/landing_page.html:67
|
||||||
|
msgid "View the SpatioTemporal Assets in this service"
|
||||||
|
msgstr "عرض الأصول الزمانية والمكانية في هذه الخدمة"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/landing_page.html:64
|
||||||
|
#: build/lib/pygeoapi/templates/processes/index.html:2
|
||||||
|
#: build/lib/pygeoapi/templates/processes/index.html:4
|
||||||
|
#: build/lib/pygeoapi/templates/processes/process.html:4
|
||||||
|
#: pygeoapi/templates/landing_page.html:73
|
||||||
|
#: pygeoapi/templates/processes/index.html:2
|
||||||
|
#: pygeoapi/templates/processes/index.html:4
|
||||||
|
#: pygeoapi/templates/processes/process.html:4
|
||||||
|
msgid "Processes"
|
||||||
|
msgstr "العمليات"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/landing_page.html:66
|
||||||
|
#: pygeoapi/templates/landing_page.html:75
|
||||||
|
msgid "View the processes in this service"
|
||||||
|
msgstr "عرض العمليات في هذه الخدمة"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/jobs/index.html:2
|
||||||
|
#: build/lib/pygeoapi/templates/jobs/index.html:4
|
||||||
|
#: build/lib/pygeoapi/templates/jobs/index.html:11
|
||||||
|
#: build/lib/pygeoapi/templates/jobs/job.html:4
|
||||||
|
#: build/lib/pygeoapi/templates/jobs/results/index.html:4
|
||||||
|
#: build/lib/pygeoapi/templates/landing_page.html:70
|
||||||
|
#: build/lib/pygeoapi/templates/processes/process.html:76
|
||||||
|
#: pygeoapi/templates/jobs/index.html:2 pygeoapi/templates/jobs/index.html:4
|
||||||
|
#: pygeoapi/templates/jobs/index.html:11 pygeoapi/templates/jobs/job.html:4
|
||||||
|
#: pygeoapi/templates/jobs/results/index.html:4
|
||||||
|
#: pygeoapi/templates/landing_page.html:79
|
||||||
|
#: pygeoapi/templates/processes/process.html:76
|
||||||
|
msgid "Jobs"
|
||||||
|
msgstr "المهام"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/landing_page.html:72
|
||||||
|
#: build/lib/pygeoapi/templates/processes/process.html:77
|
||||||
|
#: pygeoapi/templates/landing_page.html:81
|
||||||
|
#: pygeoapi/templates/processes/process.html:77
|
||||||
|
msgid "Browse jobs"
|
||||||
|
msgstr "تصفح المهام"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/landing_page.html:77
|
||||||
|
#: pygeoapi/templates/landing_page.html:86
|
||||||
|
msgid "API Definition"
|
||||||
|
msgstr "تعريف API"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/landing_page.html:79
|
||||||
|
#: pygeoapi/templates/landing_page.html:88
|
||||||
|
msgid "Documentation"
|
||||||
|
msgstr "التوثيق"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/landing_page.html:79
|
||||||
|
#: pygeoapi/templates/landing_page.html:88
|
||||||
|
msgid "Swagger UI"
|
||||||
|
msgstr "واجهة Swagger"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/landing_page.html:79
|
||||||
|
#: pygeoapi/templates/landing_page.html:88
|
||||||
|
msgid "ReDoc"
|
||||||
|
msgstr "ReDoc"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/landing_page.html:82
|
||||||
|
#: pygeoapi/templates/landing_page.html:91
|
||||||
|
msgid "OpenAPI Document"
|
||||||
|
msgstr "وثيقة OpenAPI"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/landing_page.html:88
|
||||||
|
#: pygeoapi/templates/landing_page.html:97
|
||||||
|
msgid "View the conformance classes of this service"
|
||||||
|
msgstr "عرض فئات التوافق لهذه الخدمة"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/landing_page.html:95
|
||||||
|
#: pygeoapi/templates/landing_page.html:110
|
||||||
|
msgid "Provider"
|
||||||
|
msgstr "المزود"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/landing_page.html:104
|
||||||
|
#: pygeoapi/templates/landing_page.html:119
|
||||||
|
msgid "Contact point"
|
||||||
|
msgstr "نقطة الاتصال"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/landing_page.html:107
|
||||||
|
#: pygeoapi/templates/landing_page.html:122
|
||||||
|
msgid "Address"
|
||||||
|
msgstr "العنوان"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/landing_page.html:116
|
||||||
|
#: pygeoapi/templates/landing_page.html:131
|
||||||
|
msgid "Email"
|
||||||
|
msgstr "البريد الإلكتروني"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/landing_page.html:119
|
||||||
|
#: pygeoapi/templates/landing_page.html:134
|
||||||
|
msgid "Telephone"
|
||||||
|
msgstr "الهاتف"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/landing_page.html:123
|
||||||
|
#: pygeoapi/templates/landing_page.html:138
|
||||||
|
msgid "Fax"
|
||||||
|
msgstr "الفاكس"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/landing_page.html:127
|
||||||
|
#: pygeoapi/templates/landing_page.html:142
|
||||||
|
msgid "Contact URL"
|
||||||
|
msgstr "رابط الاتصال"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/landing_page.html:131
|
||||||
|
#: pygeoapi/templates/landing_page.html:146
|
||||||
|
msgid "Hours"
|
||||||
|
msgstr "ساعات العمل"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/landing_page.html:135
|
||||||
|
#: pygeoapi/templates/landing_page.html:150
|
||||||
|
msgid "Contact instructions"
|
||||||
|
msgstr "تعليمات الاتصال"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/collections/collection.html:51
|
||||||
|
#: pygeoapi/templates/collections/collection.html:51
|
||||||
|
msgid "Browse"
|
||||||
|
msgstr "تصفح"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/collections/collection.html:55
|
||||||
|
#: pygeoapi/templates/collections/collection.html:55
|
||||||
|
msgid "Browse Items"
|
||||||
|
msgstr "تصفح العناصر"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/collections/collection.html:56
|
||||||
|
#: pygeoapi/templates/collections/collection.html:56
|
||||||
|
msgid "Browse through the items of"
|
||||||
|
msgstr "تصفح عناصر"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/collections/collection.html:59
|
||||||
|
#: build/lib/pygeoapi/templates/collections/queryables.html:6
|
||||||
|
#: build/lib/pygeoapi/templates/collections/queryables.html:17
|
||||||
|
#: pygeoapi/templates/collections/collection.html:59
|
||||||
|
#: pygeoapi/templates/collections/queryables.html:6
|
||||||
|
#: pygeoapi/templates/collections/queryables.html:17
|
||||||
|
msgid "Queryables"
|
||||||
|
msgstr "قابليات الاستعلام"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/collections/collection.html:63
|
||||||
|
#: pygeoapi/templates/collections/collection.html:63
|
||||||
|
msgid "Display Queryables"
|
||||||
|
msgstr "عرض قابليات الاستعلام"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/collections/collection.html:64
|
||||||
|
#: pygeoapi/templates/collections/collection.html:64
|
||||||
|
msgid "Display Queryables of"
|
||||||
|
msgstr "عرض قابليات الاستعلام لـ"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/collections/collection.html:69
|
||||||
|
#: build/lib/pygeoapi/templates/collections/tiles/index.html:6
|
||||||
|
#: build/lib/pygeoapi/templates/collections/tiles/metadata.html:6
|
||||||
|
#: pygeoapi/templates/collections/collection.html:77
|
||||||
|
#: pygeoapi/templates/collections/tiles/index.html:6
|
||||||
|
#: pygeoapi/templates/collections/tiles/metadata.html:6
|
||||||
|
msgid "Tiles"
|
||||||
|
msgstr "البلاطات"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/collections/collection.html:73
|
||||||
|
#: pygeoapi/templates/collections/collection.html:81
|
||||||
|
msgid "Display Tiles"
|
||||||
|
msgstr "عرض البلاطات"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/collections/collection.html:73
|
||||||
|
#: pygeoapi/templates/collections/collection.html:81
|
||||||
|
msgid "Display Tiles of"
|
||||||
|
msgstr "عرض البلاطات لـ"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/collections/collection.html:80
|
||||||
|
#: build/lib/pygeoapi/templates/jobs/job.html:50
|
||||||
|
#: build/lib/pygeoapi/templates/processes/process.html:78
|
||||||
|
#: pygeoapi/templates/collections/collection.html:107
|
||||||
|
#: pygeoapi/templates/collections/items/item.html:101
|
||||||
|
#: pygeoapi/templates/jobs/job.html:50
|
||||||
|
#: pygeoapi/templates/processes/process.html:78
|
||||||
|
msgid "Links"
|
||||||
|
msgstr "الروابط"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/collections/collection.html:90
|
||||||
|
#: pygeoapi/templates/collections/collection.html:117
|
||||||
|
msgid "Reference Systems"
|
||||||
|
msgstr "أنظمة الإسناد المرجعي"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/collections/collection.html:98
|
||||||
|
#: pygeoapi/templates/collections/collection.html:125
|
||||||
|
msgid "Storage CRS"
|
||||||
|
msgstr "نظام الإحداثيات المرجعي للتخزين"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/collections/index.html:12
|
||||||
|
#: build/lib/pygeoapi/templates/processes/index.html:14
|
||||||
|
#: build/lib/pygeoapi/templates/stac/catalog.html:17
|
||||||
|
#: build/lib/pygeoapi/templates/stac/collection.html:17
|
||||||
|
#: pygeoapi/templates/collections/index.html:12
|
||||||
|
#: pygeoapi/templates/collections/index.html:39
|
||||||
|
#: pygeoapi/templates/processes/index.html:14
|
||||||
|
#: pygeoapi/templates/stac/catalog.html:17
|
||||||
|
#: pygeoapi/templates/stac/collection.html:17
|
||||||
|
msgid "Name"
|
||||||
|
msgstr "الاسم"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/collections/index.html:13
|
||||||
|
#: build/lib/pygeoapi/templates/stac/catalog.html:18
|
||||||
|
#: pygeoapi/templates/stac/catalog.html:18
|
||||||
|
msgid "Type"
|
||||||
|
msgstr "النوع"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/collections/index.html:14
|
||||||
|
#: build/lib/pygeoapi/templates/processes/index.html:15
|
||||||
|
#: build/lib/pygeoapi/templates/processes/process.html:26
|
||||||
|
#: build/lib/pygeoapi/templates/processes/process.html:56
|
||||||
|
#: build/lib/pygeoapi/templates/stac/collection.html:18
|
||||||
|
#: pygeoapi/templates/collections/index.html:13
|
||||||
|
#: pygeoapi/templates/collections/index.html:40
|
||||||
|
#: pygeoapi/templates/processes/index.html:15
|
||||||
|
#: pygeoapi/templates/processes/process.html:26
|
||||||
|
#: pygeoapi/templates/processes/process.html:56
|
||||||
|
#: pygeoapi/templates/stac/collection.html:18
|
||||||
|
#: pygeoapi/templates/tilematrixsets/index.html:16
|
||||||
|
msgid "Description"
|
||||||
|
msgstr "الوصف"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/collections/coverage/domainset.html:11
|
||||||
|
msgid "Coverage domain set"
|
||||||
|
msgstr "مجموعة مجال التغطية"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/collections/coverage/domainset.html:12
|
||||||
|
msgid "Axis labels"
|
||||||
|
msgstr "تسميات المحاور"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/collections/coverage/domainset.html:18
|
||||||
|
msgid "Extent"
|
||||||
|
msgstr "النطاق"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/collections/coverage/domainset.html:24
|
||||||
|
msgid "Coordinate reference system"
|
||||||
|
msgstr "نظام الإحداثيات المرجعي"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/collections/coverage/domainset.html:26
|
||||||
|
#: build/lib/pygeoapi/templates/stac/catalog.html:20
|
||||||
|
#: build/lib/pygeoapi/templates/stac/item.html:34
|
||||||
|
#: pygeoapi/templates/stac/catalog.html:20
|
||||||
|
#: pygeoapi/templates/stac/collection_base.html:34
|
||||||
|
#: pygeoapi/templates/stac/item.html:34
|
||||||
|
msgid "Size"
|
||||||
|
msgstr "الحجم"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/collections/coverage/domainset.html:28
|
||||||
|
msgid "width"
|
||||||
|
msgstr "العرض"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/collections/coverage/domainset.html:29
|
||||||
|
msgid "height"
|
||||||
|
msgstr "الارتفاع"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/collections/coverage/domainset.html:31
|
||||||
|
msgid "Resolution"
|
||||||
|
msgstr "الدقة"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/collections/coverage/domainset.html:33
|
||||||
|
msgid "x"
|
||||||
|
msgstr "x"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/collections/coverage/domainset.html:34
|
||||||
|
msgid "y"
|
||||||
|
msgstr "y"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/collections/coverage/rangetype.html:11
|
||||||
|
msgid "Coverage range type"
|
||||||
|
msgstr "نوع نطاق التغطية"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/collections/coverage/rangetype.html:12
|
||||||
|
msgid "Fields"
|
||||||
|
msgstr "الحقول"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/collections/edr/query.html:11
|
||||||
|
#: build/lib/pygeoapi/templates/collections/items/index.html:11
|
||||||
|
#: build/lib/pygeoapi/templates/collections/items/item.html:33
|
||||||
|
#: pygeoapi/templates/collections/items/index.html:11
|
||||||
|
#: pygeoapi/templates/collections/items/item.html:33
|
||||||
|
msgid "Items"
|
||||||
|
msgstr "العناصر"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/collections/items/index.html:25
|
||||||
|
#: pygeoapi/templates/collections/items/index.html:38
|
||||||
|
#: pygeoapi/templates/collections/items/index.html:142
|
||||||
|
msgid "Items in this collection"
|
||||||
|
msgstr "العناصر في هذه المجموعة"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/collections/items/index.html:38
|
||||||
|
#: pygeoapi/templates/collections/items/index.html:51
|
||||||
|
msgid "Warning: Higher limits not recommended!"
|
||||||
|
msgstr "تحذير: لا يُنصح بالحدود الأعلى!"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/collections/items/index.html:43
|
||||||
|
#: pygeoapi/templates/collections/items/index.html:44
|
||||||
|
#: pygeoapi/templates/jobs/index.html:53
|
||||||
|
msgid "Limit"
|
||||||
|
msgstr "الحد"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/collections/items/index.html:45
|
||||||
|
#: pygeoapi/templates/collections/items/index.html:46
|
||||||
|
#: pygeoapi/templates/jobs/index.html:55
|
||||||
|
msgid "default"
|
||||||
|
msgstr "افتراضي"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/collections/items/index.html:66
|
||||||
|
#: build/lib/pygeoapi/templates/collections/items/item.html:62
|
||||||
|
#: pygeoapi/templates/collections/items/index.html:68
|
||||||
|
#: pygeoapi/templates/collections/items/item.html:62
|
||||||
|
#: pygeoapi/templates/jobs/index.html:76
|
||||||
|
msgid "Prev"
|
||||||
|
msgstr "السابق"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/collections/items/index.html:68
|
||||||
|
#: build/lib/pygeoapi/templates/collections/items/item.html:64
|
||||||
|
#: pygeoapi/templates/collections/items/index.html:70
|
||||||
|
#: pygeoapi/templates/collections/items/item.html:64
|
||||||
|
#: pygeoapi/templates/jobs/index.html:78
|
||||||
|
msgid "Next"
|
||||||
|
msgstr "التالي"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/collections/items/index.html:139
|
||||||
|
#: pygeoapi/templates/collections/edr/query.html:37
|
||||||
|
#: pygeoapi/templates/collections/items/index.html:147
|
||||||
|
msgid "No items"
|
||||||
|
msgstr "لا توجد عناصر"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/collections/items/item.html:77
|
||||||
|
#: build/lib/pygeoapi/templates/stac/item.html:58
|
||||||
|
#: pygeoapi/templates/collections/items/item.html:77
|
||||||
|
#: pygeoapi/templates/stac/collection_base.html:58
|
||||||
|
#: pygeoapi/templates/stac/item.html:58
|
||||||
|
msgid "Property"
|
||||||
|
msgstr "الخاصية"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/collections/items/item.html:78
|
||||||
|
#: build/lib/pygeoapi/templates/stac/item.html:59
|
||||||
|
#: pygeoapi/templates/collections/items/item.html:78
|
||||||
|
#: pygeoapi/templates/stac/collection_base.html:59
|
||||||
|
#: pygeoapi/templates/stac/item.html:59
|
||||||
|
msgid "Value"
|
||||||
|
msgstr "القيمة"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/collections/tiles/index.html:31
|
||||||
|
#: pygeoapi/templates/collections/tiles/index.html:31
|
||||||
|
msgid "Tile Matrix Set"
|
||||||
|
msgstr "مجموعة مصفوفة البلاطات"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/collections/tiles/index.html:43
|
||||||
|
#: pygeoapi/templates/collections/tiles/index.html:42
|
||||||
|
msgid "Metadata"
|
||||||
|
msgstr "البيانات الوصفية"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/collections/tiles/metadata.html:18
|
||||||
|
msgid "Tiles metadata"
|
||||||
|
msgstr "بيانات وصفية للبلاطات"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/collections/tiles/metadata.html:18
|
||||||
|
msgid "format"
|
||||||
|
msgstr "التنسيق"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/collections/tiles/metadata.html:19
|
||||||
|
msgid "Tileset"
|
||||||
|
msgstr "مجموعة البلاطات"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/jobs/index.html:14
|
||||||
|
#: pygeoapi/templates/jobs/index.html:14
|
||||||
|
msgid "Job ID"
|
||||||
|
msgstr "معرف المهمة"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/jobs/index.html:15
|
||||||
|
#: pygeoapi/templates/jobs/index.html:15
|
||||||
|
msgid "Process ID"
|
||||||
|
msgstr "معرف العملية"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/jobs/index.html:16
|
||||||
|
#: pygeoapi/templates/jobs/index.html:16
|
||||||
|
msgid "Start"
|
||||||
|
msgstr "بداية"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/jobs/index.html:17
|
||||||
|
#: build/lib/pygeoapi/templates/jobs/job.html:37
|
||||||
|
#: pygeoapi/templates/jobs/index.html:17 pygeoapi/templates/jobs/job.html:37
|
||||||
|
msgid "Duration"
|
||||||
|
msgstr "المدة"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/jobs/index.html:18
|
||||||
|
#: build/lib/pygeoapi/templates/jobs/job.html:17
|
||||||
|
#: build/lib/pygeoapi/templates/jobs/job.html:35
|
||||||
|
#: pygeoapi/templates/jobs/index.html:18 pygeoapi/templates/jobs/job.html:17
|
||||||
|
#: pygeoapi/templates/jobs/job.html:35
|
||||||
|
msgid "Progress"
|
||||||
|
msgstr "التقدم"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/jobs/index.html:19
|
||||||
|
#: build/lib/pygeoapi/templates/jobs/job.html:16
|
||||||
|
#: pygeoapi/templates/jobs/index.html:19 pygeoapi/templates/jobs/job.html:16
|
||||||
|
msgid "Status"
|
||||||
|
msgstr "الحالة"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/jobs/index.html:20
|
||||||
|
#: build/lib/pygeoapi/templates/jobs/job.html:21
|
||||||
|
#: pygeoapi/templates/jobs/index.html:20 pygeoapi/templates/jobs/job.html:21
|
||||||
|
msgid "Message"
|
||||||
|
msgstr "الرسالة"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/jobs/job.html:2
|
||||||
|
#: build/lib/pygeoapi/templates/jobs/job.html:10
|
||||||
|
#: pygeoapi/templates/jobs/job.html:2 pygeoapi/templates/jobs/job.html:10
|
||||||
|
msgid "Job status"
|
||||||
|
msgstr "حالة المهمة"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/jobs/job.html:26
|
||||||
|
#: pygeoapi/templates/jobs/job.html:26
|
||||||
|
msgid "Parameters"
|
||||||
|
msgstr "المعلمات"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/jobs/job.html:45
|
||||||
|
#: pygeoapi/templates/jobs/job.html:45
|
||||||
|
msgid "Started processing"
|
||||||
|
msgstr "بدأت المعالجة"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/jobs/job.html:47
|
||||||
|
#: pygeoapi/templates/jobs/job.html:47
|
||||||
|
msgid "Finished processing"
|
||||||
|
msgstr "انتهت المعالجة"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/jobs/results/index.html:2
|
||||||
|
#: pygeoapi/templates/jobs/results/index.html:2
|
||||||
|
msgid "Job result"
|
||||||
|
msgstr "نتيجة المهمة"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/jobs/results/index.html:6
|
||||||
|
#: pygeoapi/templates/jobs/results/index.html:6
|
||||||
|
msgid "Results"
|
||||||
|
msgstr "النتائج"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/jobs/results/index.html:10
|
||||||
|
#: pygeoapi/templates/jobs/results/index.html:10
|
||||||
|
msgid "Results of job"
|
||||||
|
msgstr "نتائج المهمة"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/processes/index.html:8
|
||||||
|
#: pygeoapi/templates/processes/index.html:8
|
||||||
|
msgid "Processes in this service"
|
||||||
|
msgstr "العمليات في هذه الخدمة"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/processes/process.html:20
|
||||||
|
#: pygeoapi/templates/processes/process.html:20
|
||||||
|
msgid "Inputs"
|
||||||
|
msgstr "المدخلات"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/processes/process.html:23
|
||||||
|
#: build/lib/pygeoapi/templates/processes/process.html:54
|
||||||
|
#: pygeoapi/templates/processes/process.html:23
|
||||||
|
#: pygeoapi/templates/processes/process.html:54
|
||||||
|
msgid "Id"
|
||||||
|
msgstr "معرف"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/processes/process.html:24
|
||||||
|
#: build/lib/pygeoapi/templates/processes/process.html:55
|
||||||
|
#: pygeoapi/templates/processes/process.html:24
|
||||||
|
#: pygeoapi/templates/processes/process.html:55
|
||||||
|
#: pygeoapi/templates/tilematrixsets/index.html:15
|
||||||
|
msgid "Title"
|
||||||
|
msgstr "العنوان"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/processes/process.html:25
|
||||||
|
#: pygeoapi/templates/processes/process.html:25
|
||||||
|
msgid "Data Type"
|
||||||
|
msgstr "نوع البيانات"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/processes/process.html:51
|
||||||
|
#: pygeoapi/templates/processes/process.html:51
|
||||||
|
msgid "Outputs"
|
||||||
|
msgstr "المخرجات"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/processes/process.html:71
|
||||||
|
#: pygeoapi/templates/processes/process.html:71
|
||||||
|
msgid "Execution modes"
|
||||||
|
msgstr "أنماط التنفيذ"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/processes/process.html:73
|
||||||
|
#: pygeoapi/templates/processes/process.html:73
|
||||||
|
msgid "Synchronous"
|
||||||
|
msgstr "متزامن"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/processes/process.html:74
|
||||||
|
#: pygeoapi/templates/processes/process.html:74
|
||||||
|
msgid "Asynchronous"
|
||||||
|
msgstr "غير متزامن"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/stac/catalog.html:4
|
||||||
|
#: build/lib/pygeoapi/templates/stac/collection.html:2
|
||||||
|
#: build/lib/pygeoapi/templates/stac/collection.html:4
|
||||||
|
#: build/lib/pygeoapi/templates/stac/item.html:4
|
||||||
|
#: pygeoapi/templates/stac/catalog.html:4
|
||||||
|
#: pygeoapi/templates/stac/collection.html:2
|
||||||
|
#: pygeoapi/templates/stac/collection.html:4
|
||||||
|
#: pygeoapi/templates/stac/collection_base.html:4
|
||||||
|
#: pygeoapi/templates/stac/item.html:4
|
||||||
|
msgid "SpatioTemporal Asset Catalog"
|
||||||
|
msgstr "كتالوج الأصول الزمانية والمكانية (STAC)"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/stac/catalog.html:19
|
||||||
|
#: pygeoapi/templates/stac/catalog.html:19
|
||||||
|
msgid "Last modified"
|
||||||
|
msgstr "آخر تعديل"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/stac/collection.html:9
|
||||||
|
#: pygeoapi/templates/stac/collection.html:9
|
||||||
|
msgid "STAC Version"
|
||||||
|
msgstr "إصدار STAC"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/stac/item.html:19
|
||||||
|
#: pygeoapi/templates/stac/item.html:19
|
||||||
|
msgid "Item"
|
||||||
|
msgstr "العنصر"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/stac/item.html:28
|
||||||
|
#: pygeoapi/templates/stac/collection_base.html:28
|
||||||
|
#: pygeoapi/templates/stac/item.html:28
|
||||||
|
msgid "Assets"
|
||||||
|
msgstr "الأصول"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/stac/item.html:32
|
||||||
|
#: pygeoapi/templates/landing_page.html:45
|
||||||
|
#: pygeoapi/templates/stac/collection_base.html:32
|
||||||
|
#: pygeoapi/templates/stac/item.html:32
|
||||||
|
msgid "URL"
|
||||||
|
msgstr "الرابط"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/stac/item.html:33
|
||||||
|
#: pygeoapi/templates/stac/collection_base.html:33
|
||||||
|
#: pygeoapi/templates/stac/item.html:33
|
||||||
|
msgid "Last Modified"
|
||||||
|
msgstr "آخر تعديل"
|
||||||
|
|
||||||
|
#: build/lib/pygeoapi/templates/stac/item.html:64
|
||||||
|
#: pygeoapi/templates/stac/collection_base.html:64
|
||||||
|
#: pygeoapi/templates/stac/item.html:64
|
||||||
|
msgid "id"
|
||||||
|
msgstr "معرف"
|
||||||
|
|
||||||
|
#: pygeoapi/templates/_base.html:2
|
||||||
|
msgid "text_direction"
|
||||||
|
msgstr "rtl"
|
||||||
|
|
||||||
|
#: pygeoapi/templates/_base.html:47
|
||||||
|
msgid "Contact"
|
||||||
|
msgstr "الاتصال"
|
||||||
|
|
||||||
|
#: pygeoapi/templates/_base.html:51
|
||||||
|
msgid "Admin"
|
||||||
|
msgstr "الإدارة"
|
||||||
|
|
||||||
|
#: pygeoapi/templates/landing_page.html:101
|
||||||
|
msgid "Tile Matrix Sets"
|
||||||
|
msgstr "مجموعات مصفوفة البلاطات"
|
||||||
|
|
||||||
|
#: pygeoapi/templates/landing_page.html:103
|
||||||
|
msgid "View the Tile Matrix Sets available on this service"
|
||||||
|
msgstr "عرض مجموعات مصفوفة البلاطات المتاحة في هذه الخدمة"
|
||||||
|
|
||||||
|
#: pygeoapi/templates/collections/collection.html:67
|
||||||
|
#: pygeoapi/templates/collections/schema.html:6
|
||||||
|
#: pygeoapi/templates/collections/schema.html:17
|
||||||
|
msgid "Schema"
|
||||||
|
msgstr "المخطط"
|
||||||
|
|
||||||
|
#: pygeoapi/templates/collections/collection.html:71
|
||||||
|
msgid "Display Schema"
|
||||||
|
msgstr "عرض المخطط"
|
||||||
|
|
||||||
|
#: pygeoapi/templates/collections/collection.html:72
|
||||||
|
msgid "Display Schema of"
|
||||||
|
msgstr "عرض مخطط لـ"
|
||||||
|
|
||||||
|
#: pygeoapi/templates/collections/collection.html:128
|
||||||
|
msgid "CRS"
|
||||||
|
msgstr "CRS"
|
||||||
|
|
||||||
|
#: pygeoapi/templates/collections/collection.html:131
|
||||||
|
msgid "Epoch"
|
||||||
|
msgstr "العصر"
|
||||||
|
|
||||||
|
#: pygeoapi/templates/collections/index.html:8
|
||||||
|
msgid "Data collections in this service"
|
||||||
|
msgstr "مجموعات البيانات في هذه الخدمة"
|
||||||
|
|
||||||
|
#: pygeoapi/templates/collections/index.html:35
|
||||||
|
msgid "Record collections in this service"
|
||||||
|
msgstr "مجموعات السجلات في هذه الخدمة"
|
||||||
|
|
||||||
|
#: pygeoapi/templates/collections/edr/query.html:11
|
||||||
|
#, python-format
|
||||||
|
msgid "%(query_type)s"
|
||||||
|
msgstr "%(query_type)s"
|
||||||
|
|
||||||
|
#: pygeoapi/templates/collections/tiles/metadata.html:15
|
||||||
|
msgid "TileJSON"
|
||||||
|
msgstr "TileJSON"
|
||||||
|
|
||||||
|
#: pygeoapi/templates/collections/tiles/metadata.html:17
|
||||||
|
msgid "JSON"
|
||||||
|
msgstr "JSON"
|
||||||
|
|
||||||
|
#: pygeoapi/templates/stac/collection_base.html:68
|
||||||
|
msgid "description"
|
||||||
|
msgstr "الوصف"
|
||||||
|
|
||||||
|
#: pygeoapi/templates/stac/collection_base.html:72
|
||||||
|
msgid "extent"
|
||||||
|
msgstr "النطاق"
|
||||||
|
|
||||||
|
#: pygeoapi/templates/stac/collection_base.html:77
|
||||||
|
msgid "cube:dimensions"
|
||||||
|
msgstr "cube:dimensions"
|
||||||
|
|
||||||
|
#: pygeoapi/templates/stac/collection_base.html:83
|
||||||
|
msgid "cube:variables"
|
||||||
|
msgstr "cube:variables"
|
||||||
|
|
||||||
|
#: pygeoapi/templates/tilematrixsets/index.html:2
|
||||||
|
#: pygeoapi/templates/tilematrixsets/index.html:4
|
||||||
|
#: pygeoapi/templates/tilematrixsets/tilematrixset.html:4
|
||||||
|
msgid "TileMatrixSets"
|
||||||
|
msgstr "مجموعات مصفوفة البلاطات"
|
||||||
|
|
||||||
|
#: pygeoapi/templates/tilematrixsets/index.html:9
|
||||||
|
msgid "Tile matrix sets available in this service"
|
||||||
|
msgstr "مجموعات مصفوفة البلاطات المتاحة في هذه الخدمة"
|
||||||
|
|
||||||
|
#: pygeoapi/templates/tilematrixsets/tilematrixset.html:2
|
||||||
|
msgid "TileMatrixSet"
|
||||||
|
msgstr "مجموعة مصفوفة البلاطات"
|
||||||
@@ -19,6 +19,10 @@ msgstr ""
|
|||||||
"Content-Transfer-Encoding: 8bit\n"
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
"Generated-By: Babel 2.14.0\n"
|
"Generated-By: Babel 2.14.0\n"
|
||||||
|
|
||||||
|
#: pygeoapi/templates/_base.html:2
|
||||||
|
msgid "text_direction"
|
||||||
|
msgstr "ltr"
|
||||||
|
|
||||||
#: pygeoapi/templates/_base.html:51
|
#: pygeoapi/templates/_base.html:51
|
||||||
msgid "Admin"
|
msgid "Admin"
|
||||||
msgstr "Admin"
|
msgstr "Admin"
|
||||||
@@ -656,3 +660,27 @@ msgstr ""
|
|||||||
|
|
||||||
msgid "not specified"
|
msgid "not specified"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Position"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Cube"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Area"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Corridor"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Trajectory"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Radius"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Locations"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Instances"
|
||||||
|
msgstr ""
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ msgstr ""
|
|||||||
"Content-Transfer-Encoding: 8bit\n"
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
"Generated-By: Babel 2.9.1\n"
|
"Generated-By: Babel 2.9.1\n"
|
||||||
|
|
||||||
|
#: pygeoapi/templates/_base.html:2
|
||||||
|
msgid "text_direction"
|
||||||
|
msgstr "ltr"
|
||||||
|
|
||||||
#: build/lib/pygeoapi/templates/_base.html:40
|
#: build/lib/pygeoapi/templates/_base.html:40
|
||||||
#: build/lib/pygeoapi/templates/landing_page.html:2
|
#: build/lib/pygeoapi/templates/landing_page.html:2
|
||||||
#: pygeoapi/templates/_base.html:40 pygeoapi/templates/landing_page.html:2
|
#: pygeoapi/templates/_base.html:40 pygeoapi/templates/landing_page.html:2
|
||||||
@@ -706,3 +710,27 @@ msgstr ""
|
|||||||
|
|
||||||
msgid "not specified"
|
msgid "not specified"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Position"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Cube"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Area"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Corridor"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Trajectory"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Radius"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Locations"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Instances"
|
||||||
|
msgstr ""
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ msgstr ""
|
|||||||
"Content-Transfer-Encoding: 8bit\n"
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
"Generated-By: Babel 2.9.1\n"
|
"Generated-By: Babel 2.9.1\n"
|
||||||
|
|
||||||
|
#: pygeoapi/templates/_base.html:2
|
||||||
|
msgid "text_direction"
|
||||||
|
msgstr "ltr"
|
||||||
|
|
||||||
#: build/lib/pygeoapi/templates/_base.html:40
|
#: build/lib/pygeoapi/templates/_base.html:40
|
||||||
#: build/lib/pygeoapi/templates/landing_page.html:2
|
#: build/lib/pygeoapi/templates/landing_page.html:2
|
||||||
#: pygeoapi/templates/_base.html:40 pygeoapi/templates/landing_page.html:2
|
#: pygeoapi/templates/_base.html:40 pygeoapi/templates/landing_page.html:2
|
||||||
@@ -708,3 +712,27 @@ msgstr ""
|
|||||||
|
|
||||||
msgid "not specified"
|
msgid "not specified"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Position"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Cube"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Area"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Corridor"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Trajectory"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Radius"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Locations"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Instances"
|
||||||
|
msgstr ""
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ msgstr ""
|
|||||||
"Content-Transfer-Encoding: 8bit\n"
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
"Generated-By: Babel 2.11.0\n"
|
"Generated-By: Babel 2.11.0\n"
|
||||||
|
|
||||||
|
#: pygeoapi/templates/_base.html:2
|
||||||
|
msgid "text_direction"
|
||||||
|
msgstr "ltr"
|
||||||
|
|
||||||
#: pygeoapi/templates/_base.html:51
|
#: pygeoapi/templates/_base.html:51
|
||||||
msgid "Admin"
|
msgid "Admin"
|
||||||
msgstr "Admin"
|
msgstr "Admin"
|
||||||
@@ -521,3 +525,27 @@ msgstr ""
|
|||||||
|
|
||||||
msgid "not specified"
|
msgid "not specified"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Position"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Cube"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Area"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Corridor"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Trajectory"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Radius"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Locations"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Instances"
|
||||||
|
msgstr ""
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ msgstr ""
|
|||||||
"Content-Transfer-Encoding: 8bit\n"
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
"Generated-By: Babel 2.9.1\n"
|
"Generated-By: Babel 2.9.1\n"
|
||||||
|
|
||||||
|
#: pygeoapi/templates/_base.html:2
|
||||||
|
msgid "text_direction"
|
||||||
|
msgstr "ltr"
|
||||||
|
|
||||||
#: build/lib/pygeoapi/templates/_base.html:40
|
#: build/lib/pygeoapi/templates/_base.html:40
|
||||||
#: build/lib/pygeoapi/templates/landing_page.html:2
|
#: build/lib/pygeoapi/templates/landing_page.html:2
|
||||||
#: pygeoapi/templates/_base.html:40 pygeoapi/templates/landing_page.html:2
|
#: pygeoapi/templates/_base.html:40 pygeoapi/templates/landing_page.html:2
|
||||||
@@ -715,3 +719,27 @@ msgstr ""
|
|||||||
|
|
||||||
msgid "not specified"
|
msgid "not specified"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Position"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Cube"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Area"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Corridor"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Trajectory"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Radius"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Locations"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Instances"
|
||||||
|
msgstr ""
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ msgstr ""
|
|||||||
"Content-Transfer-Encoding: 8bit\n"
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
"Generated-By: Babel 2.14.0\n"
|
"Generated-By: Babel 2.14.0\n"
|
||||||
|
|
||||||
|
#: pygeoapi/templates/_base.html:2
|
||||||
|
msgid "text_direction"
|
||||||
|
msgstr "ltr"
|
||||||
|
|
||||||
#: pygeoapi/templates/_base.html:51
|
#: pygeoapi/templates/_base.html:51
|
||||||
msgid "Admin"
|
msgid "Admin"
|
||||||
msgstr "Admin"
|
msgstr "Admin"
|
||||||
@@ -656,3 +660,27 @@ msgstr ""
|
|||||||
|
|
||||||
msgid "not specified"
|
msgid "not specified"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Position"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Cube"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Area"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Corridor"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Trajectory"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Radius"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Locations"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Instances"
|
||||||
|
msgstr ""
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
#
|
#
|
||||||
# =================================================================
|
# =================================================================
|
||||||
|
|
||||||
__version__ = '0.18.dev0'
|
__version__ = '0.19.dev0'
|
||||||
|
|
||||||
import click
|
import click
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ HEADERS = {
|
|||||||
|
|
||||||
CHARSET = ['utf-8']
|
CHARSET = ['utf-8']
|
||||||
F_JSON = 'json'
|
F_JSON = 'json'
|
||||||
|
F_COVERAGEJSON = 'json'
|
||||||
F_HTML = 'html'
|
F_HTML = 'html'
|
||||||
F_JSONLD = 'jsonld'
|
F_JSONLD = 'jsonld'
|
||||||
F_GZIP = 'gzip'
|
F_GZIP = 'gzip'
|
||||||
@@ -1209,6 +1210,7 @@ class API:
|
|||||||
if edr:
|
if edr:
|
||||||
# TODO: translate
|
# TODO: translate
|
||||||
LOGGER.debug('Adding EDR links')
|
LOGGER.debug('Adding EDR links')
|
||||||
|
collection['data_queries'] = {}
|
||||||
parameters = p.get_fields()
|
parameters = p.get_fields()
|
||||||
if parameters:
|
if parameters:
|
||||||
collection['parameter_names'] = {}
|
collection['parameter_names'] = {}
|
||||||
@@ -1229,6 +1231,14 @@ class API:
|
|||||||
}
|
}
|
||||||
|
|
||||||
for qt in p.get_query_types():
|
for qt in p.get_query_types():
|
||||||
|
data_query = {
|
||||||
|
'link': {
|
||||||
|
'href': f'{self.get_collections_url()}/{k}/{qt}',
|
||||||
|
'rel': 'data'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
collection['data_queries'][qt] = data_query
|
||||||
|
|
||||||
title1 = l10n.translate('query for this collection as JSON', request.locale) # noqa
|
title1 = l10n.translate('query for this collection as JSON', request.locale) # noqa
|
||||||
title1 = f'{qt} {title1}'
|
title1 = f'{qt} {title1}'
|
||||||
title2 = l10n.translate('query for this collection as HTML', request.locale) # noqa
|
title2 = l10n.translate('query for this collection as HTML', request.locale) # noqa
|
||||||
@@ -1373,6 +1383,7 @@ class API:
|
|||||||
self.config['resources'][dataset]['title'], request.locale)
|
self.config['resources'][dataset]['title'], request.locale)
|
||||||
|
|
||||||
schema['collections_path'] = self.get_collections_url()
|
schema['collections_path'] = self.get_collections_url()
|
||||||
|
schema['dataset_path'] = f'{self.get_collections_url()}/{dataset}'
|
||||||
|
|
||||||
content = render_j2_template(self.tpl_config,
|
content = render_j2_template(self.tpl_config,
|
||||||
'collections/schema.html',
|
'collections/schema.html',
|
||||||
@@ -1430,7 +1441,8 @@ class API:
|
|||||||
# Content-Language is in the system locale (ignore language settings)
|
# Content-Language is in the system locale (ignore language settings)
|
||||||
headers = request.get_response_headers(SYSTEM_LOCALE,
|
headers = request.get_response_headers(SYSTEM_LOCALE,
|
||||||
**self.api_headers)
|
**self.api_headers)
|
||||||
msg = f'Invalid format: {request.format}'
|
msg = 'Invalid format requested'
|
||||||
|
LOGGER.error(f'{msg}: {request.format}')
|
||||||
return self.get_exception(
|
return self.get_exception(
|
||||||
HTTPStatus.BAD_REQUEST, headers,
|
HTTPStatus.BAD_REQUEST, headers,
|
||||||
request.format, 'InvalidParameterValue', msg)
|
request.format, 'InvalidParameterValue', msg)
|
||||||
|
|||||||
@@ -41,10 +41,12 @@
|
|||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
import logging
|
import logging
|
||||||
from typing import Tuple
|
from typing import Tuple
|
||||||
|
import urllib
|
||||||
|
|
||||||
from shapely.errors import WKTReadingError
|
from shapely.errors import ShapelyError
|
||||||
from shapely.wkt import loads as shapely_loads
|
from shapely.wkt import loads as shapely_loads
|
||||||
|
|
||||||
|
from pygeoapi import l10n
|
||||||
from pygeoapi.plugin import load_plugin, PLUGINS
|
from pygeoapi.plugin import load_plugin, PLUGINS
|
||||||
from pygeoapi.provider.base import ProviderGenericError
|
from pygeoapi.provider.base import ProviderGenericError
|
||||||
from pygeoapi.util import (
|
from pygeoapi.util import (
|
||||||
@@ -52,7 +54,8 @@ from pygeoapi.util import (
|
|||||||
to_json, filter_dict_by_key_value
|
to_json, filter_dict_by_key_value
|
||||||
)
|
)
|
||||||
|
|
||||||
from . import APIRequest, API, F_HTML, validate_datetime, validate_bbox
|
from . import (APIRequest, API, F_COVERAGEJSON, F_HTML, F_JSONLD,
|
||||||
|
validate_datetime, validate_bbox)
|
||||||
|
|
||||||
LOGGER = logging.getLogger(__name__)
|
LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -88,6 +91,27 @@ def get_collection_edr_query(api: API, request: APIRequest,
|
|||||||
return api.get_exception(
|
return api.get_exception(
|
||||||
HTTPStatus.NOT_FOUND, headers, request.format, 'NotFound', msg)
|
HTTPStatus.NOT_FOUND, headers, request.format, 'NotFound', msg)
|
||||||
|
|
||||||
|
LOGGER.debug('Loading provider')
|
||||||
|
try:
|
||||||
|
p = load_plugin('provider', get_provider_by_type(
|
||||||
|
collections[dataset]['providers'], 'edr'))
|
||||||
|
except ProviderGenericError as err:
|
||||||
|
return api.get_exception(
|
||||||
|
err.http_status_code, headers, request.format,
|
||||||
|
err.ogc_exception_code, err.message)
|
||||||
|
|
||||||
|
if instance is not None and not p.get_instance(instance):
|
||||||
|
msg = 'Invalid instance identifier'
|
||||||
|
return api.get_exception(
|
||||||
|
HTTPStatus.BAD_REQUEST, headers,
|
||||||
|
request.format, 'InvalidParameterValue', msg)
|
||||||
|
|
||||||
|
if query_type not in p.get_query_types():
|
||||||
|
msg = 'Unsupported query type'
|
||||||
|
return api.get_exception(
|
||||||
|
HTTPStatus.BAD_REQUEST, headers, request.format,
|
||||||
|
'InvalidParameterValue', msg)
|
||||||
|
|
||||||
LOGGER.debug('Processing query parameters')
|
LOGGER.debug('Processing query parameters')
|
||||||
|
|
||||||
LOGGER.debug('Processing datetime parameter')
|
LOGGER.debug('Processing datetime parameter')
|
||||||
@@ -124,7 +148,7 @@ def get_collection_edr_query(api: API, request: APIRequest,
|
|||||||
if wkt:
|
if wkt:
|
||||||
try:
|
try:
|
||||||
wkt = shapely_loads(wkt)
|
wkt = shapely_loads(wkt)
|
||||||
except WKTReadingError:
|
except ShapelyError:
|
||||||
msg = 'invalid coords parameter'
|
msg = 'invalid coords parameter'
|
||||||
return api.get_exception(
|
return api.get_exception(
|
||||||
HTTPStatus.BAD_REQUEST, headers, request.format,
|
HTTPStatus.BAD_REQUEST, headers, request.format,
|
||||||
@@ -144,27 +168,6 @@ def get_collection_edr_query(api: API, request: APIRequest,
|
|||||||
LOGGER.debug('Processing z parameter')
|
LOGGER.debug('Processing z parameter')
|
||||||
z = request.params.get('z')
|
z = request.params.get('z')
|
||||||
|
|
||||||
LOGGER.debug('Loading provider')
|
|
||||||
try:
|
|
||||||
p = load_plugin('provider', get_provider_by_type(
|
|
||||||
collections[dataset]['providers'], 'edr'))
|
|
||||||
except ProviderGenericError as err:
|
|
||||||
return api.get_exception(
|
|
||||||
err.http_status_code, headers, request.format,
|
|
||||||
err.ogc_exception_code, err.message)
|
|
||||||
|
|
||||||
if instance is not None and not p.get_instance(instance):
|
|
||||||
msg = 'Invalid instance identifier'
|
|
||||||
return api.get_exception(
|
|
||||||
HTTPStatus.BAD_REQUEST, headers,
|
|
||||||
request.format, 'InvalidParameterValue', msg)
|
|
||||||
|
|
||||||
if query_type not in p.get_query_types():
|
|
||||||
msg = 'Unsupported query type'
|
|
||||||
return api.get_exception(
|
|
||||||
HTTPStatus.BAD_REQUEST, headers, request.format,
|
|
||||||
'InvalidParameterValue', msg)
|
|
||||||
|
|
||||||
if parameternames and not any((fld in parameternames)
|
if parameternames and not any((fld in parameternames)
|
||||||
for fld in p.get_fields().keys()):
|
for fld in p.get_fields().keys()):
|
||||||
msg = 'Invalid parameter-name'
|
msg = 'Invalid parameter-name'
|
||||||
@@ -195,6 +198,36 @@ def get_collection_edr_query(api: API, request: APIRequest,
|
|||||||
err.ogc_exception_code, err.message)
|
err.ogc_exception_code, err.message)
|
||||||
|
|
||||||
if request.format == F_HTML: # render
|
if request.format == F_HTML: # render
|
||||||
|
uri = f'{api.get_collections_url()}/{dataset}/{query_type}'
|
||||||
|
serialized_query_params = ''
|
||||||
|
for k, v in request.params.items():
|
||||||
|
if k != 'f':
|
||||||
|
serialized_query_params += '&'
|
||||||
|
serialized_query_params += urllib.parse.quote(k, safe='')
|
||||||
|
serialized_query_params += '='
|
||||||
|
serialized_query_params += urllib.parse.quote(str(v), safe=',')
|
||||||
|
|
||||||
|
data['query_type'] = query_type.capitalize()
|
||||||
|
data['query_path'] = uri
|
||||||
|
data['dataset_path'] = '/'.join(uri.split('/')[:-1])
|
||||||
|
data['collections_path'] = api.get_collections_url()
|
||||||
|
|
||||||
|
data['links'] = [{
|
||||||
|
'rel': 'collection',
|
||||||
|
'title': collections[dataset]['title'],
|
||||||
|
'href': data['dataset_path']
|
||||||
|
}, {
|
||||||
|
'type': 'application/prs.coverage+json',
|
||||||
|
'rel': request.get_linkrel(F_COVERAGEJSON),
|
||||||
|
'title': l10n.translate('This document as CoverageJSON', request.locale), # noqa
|
||||||
|
'href': f'{uri}?f={F_COVERAGEJSON}{serialized_query_params}'
|
||||||
|
}, {
|
||||||
|
'type': 'application/ld+json',
|
||||||
|
'rel': 'alternate',
|
||||||
|
'title': l10n.translate('This document as JSON-LD', request.locale), # noqa
|
||||||
|
'href': f'{uri}?f={F_JSONLD}{serialized_query_params}'
|
||||||
|
}]
|
||||||
|
|
||||||
content = render_j2_template(api.tpl_config,
|
content = render_j2_template(api.tpl_config,
|
||||||
'collections/edr/query.html', data,
|
'collections/edr/query.html', data,
|
||||||
api.default_locale)
|
api.default_locale)
|
||||||
@@ -305,11 +338,9 @@ def get_oas_30(cfg: dict, locale: str) -> tuple[list[dict[str, str]], dict[str,
|
|||||||
'tags': [k],
|
'tags': [k],
|
||||||
'operationId': f'queryLOCATIONSBYID{k.capitalize()}',
|
'operationId': f'queryLOCATIONSBYID{k.capitalize()}',
|
||||||
'parameters': [
|
'parameters': [
|
||||||
{'$ref': f"{OPENAPI_YAML['oaedr']}/parameters/{spatial_parameter}.yaml"}, # noqa
|
|
||||||
{'$ref': f"{OPENAPI_YAML['oaedr']}/parameters/locationId.yaml"}, # noqa
|
{'$ref': f"{OPENAPI_YAML['oaedr']}/parameters/locationId.yaml"}, # noqa
|
||||||
{'$ref': f"{OPENAPI_YAML['oapif-1']}#/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/parameter-name.yaml"}, # noqa
|
||||||
{'$ref': f"{OPENAPI_YAML['oaedr']}/parameters/z.yaml"}, # noqa
|
|
||||||
{'$ref': '#/components/parameters/f'}
|
{'$ref': '#/components/parameters/f'}
|
||||||
],
|
],
|
||||||
'responses': {
|
'responses': {
|
||||||
|
|||||||
+44
-31
@@ -121,23 +121,22 @@ def get_collection_queryables(api: API, request: Union[APIRequest, Any],
|
|||||||
HTTPStatus.NOT_FOUND, headers, request.format, 'NotFound', msg)
|
HTTPStatus.NOT_FOUND, headers, request.format, 'NotFound', msg)
|
||||||
|
|
||||||
LOGGER.debug('Creating collection queryables')
|
LOGGER.debug('Creating collection queryables')
|
||||||
try:
|
|
||||||
LOGGER.debug('Loading feature provider')
|
p = None
|
||||||
p = load_plugin('provider', get_provider_by_type(
|
for pt in ['feature', 'coverage', 'record']:
|
||||||
api.config['resources'][dataset]['providers'], 'feature'))
|
|
||||||
except ProviderTypeError:
|
|
||||||
try:
|
try:
|
||||||
LOGGER.debug('Loading coverage provider')
|
LOGGER.debug(f'Loading {pt} provider')
|
||||||
p = load_plugin('provider', get_provider_by_type(
|
p = load_plugin('provider', get_provider_by_type(
|
||||||
api.config['resources'][dataset]['providers'], 'coverage')) # noqa
|
api.config['resources'][dataset]['providers'], pt))
|
||||||
|
break
|
||||||
except ProviderTypeError:
|
except ProviderTypeError:
|
||||||
LOGGER.debug('Loading record provider')
|
LOGGER.debug(f'Providing type {pt} not found')
|
||||||
p = load_plugin('provider', get_provider_by_type(
|
|
||||||
api.config['resources'][dataset]['providers'], 'record'))
|
if p is None:
|
||||||
except ProviderGenericError as err:
|
msg = 'queryables not available for this collection'
|
||||||
return api.get_exception(
|
return api.get_exception(
|
||||||
err.http_status_code, headers, request.format,
|
HTTPStatus.BAD_REQUEST, headers, request.format,
|
||||||
err.ogc_exception_code, err.message)
|
'NoApplicableError', msg)
|
||||||
|
|
||||||
queryables = {
|
queryables = {
|
||||||
'type': 'object',
|
'type': 'object',
|
||||||
@@ -182,6 +181,7 @@ def get_collection_queryables(api: API, request: Union[APIRequest, Any],
|
|||||||
api.config['resources'][dataset]['title'], request.locale)
|
api.config['resources'][dataset]['title'], request.locale)
|
||||||
|
|
||||||
queryables['collections_path'] = api.get_collections_url()
|
queryables['collections_path'] = api.get_collections_url()
|
||||||
|
queryables['dataset_path'] = f'{api.get_collections_url()}/{dataset}'
|
||||||
|
|
||||||
content = render_j2_template(api.tpl_config,
|
content = render_j2_template(api.tpl_config,
|
||||||
'collections/queryables.html',
|
'collections/queryables.html',
|
||||||
@@ -380,8 +380,12 @@ def get_collection_items(
|
|||||||
|
|
||||||
LOGGER.debug('processing property parameters')
|
LOGGER.debug('processing property parameters')
|
||||||
for k, v in request.params.items():
|
for k, v in request.params.items():
|
||||||
if k not in reserved_fieldnames and k in list(p.fields.keys()):
|
if k not in reserved_fieldnames:
|
||||||
LOGGER.debug(f'Adding property filter {k}={v}')
|
if k in list(p.fields.keys()):
|
||||||
|
LOGGER.debug(f'Adding property filter {k}={v}')
|
||||||
|
else:
|
||||||
|
LOGGER.debug(f'Adding additional property filter {k}={v}')
|
||||||
|
|
||||||
properties.append((k, v))
|
properties.append((k, v))
|
||||||
|
|
||||||
LOGGER.debug('processing sort parameter')
|
LOGGER.debug('processing sort parameter')
|
||||||
@@ -444,7 +448,8 @@ def get_collection_items(
|
|||||||
geometry_column_name=provider_def.get('geom_field'),
|
geometry_column_name=provider_def.get('geom_field'),
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
msg = f'Bad CQL string : {cql_text}'
|
msg = 'Bad CQL text'
|
||||||
|
LOGGER.error(f'{msg}: {cql_text}')
|
||||||
return api.get_exception(
|
return api.get_exception(
|
||||||
HTTPStatus.BAD_REQUEST, headers, request.format,
|
HTTPStatus.BAD_REQUEST, headers, request.format,
|
||||||
'InvalidParameterValue', msg)
|
'InvalidParameterValue', msg)
|
||||||
@@ -531,17 +536,23 @@ def get_collection_items(
|
|||||||
'href': f'{uri}?offset={prev}{serialized_query_params}'
|
'href': f'{uri}?offset={prev}{serialized_query_params}'
|
||||||
})
|
})
|
||||||
|
|
||||||
if 'numberMatched' in content:
|
next_link = False
|
||||||
if content['numberMatched'] > (limit + offset):
|
|
||||||
next_ = offset + limit
|
if content.get('numberMatched', -1) > (limit + offset):
|
||||||
next_href = f'{uri}?offset={next_}{serialized_query_params}'
|
next_link = True
|
||||||
content['links'].append(
|
elif len(content['features']) == limit:
|
||||||
{
|
next_link = True
|
||||||
'type': 'application/geo+json',
|
|
||||||
'rel': 'next',
|
if next_link:
|
||||||
'title': l10n.translate('Items (next)', request.locale),
|
next_ = offset + limit
|
||||||
'href': next_href
|
next_href = f'{uri}?offset={next_}{serialized_query_params}'
|
||||||
})
|
content['links'].append(
|
||||||
|
{
|
||||||
|
'type': 'application/geo+json',
|
||||||
|
'rel': 'next',
|
||||||
|
'title': l10n.translate('Items (next)', request.locale),
|
||||||
|
'href': next_href
|
||||||
|
})
|
||||||
|
|
||||||
content['links'].append(
|
content['links'].append(
|
||||||
{
|
{
|
||||||
@@ -836,7 +847,7 @@ def post_collection_items(
|
|||||||
if (request_headers.get(
|
if (request_headers.get(
|
||||||
'Content-Type') or request_headers.get(
|
'Content-Type') or request_headers.get(
|
||||||
'content-type')) != 'application/query-cql-json':
|
'content-type')) != 'application/query-cql-json':
|
||||||
msg = ('Invalid body content-type')
|
msg = 'Invalid body content-type'
|
||||||
return api.get_exception(
|
return api.get_exception(
|
||||||
HTTPStatus.BAD_REQUEST, headers, request.format,
|
HTTPStatus.BAD_REQUEST, headers, request.format,
|
||||||
'InvalidHeaderValue', msg)
|
'InvalidHeaderValue', msg)
|
||||||
@@ -872,16 +883,18 @@ def post_collection_items(
|
|||||||
geometry_column_name=provider_def.get('geom_field')
|
geometry_column_name=provider_def.get('geom_field')
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
msg = f'Bad CQL string : {data}'
|
msg = 'Bad CQL text'
|
||||||
|
LOGGER.error(f'{msg}: {data}')
|
||||||
return api.get_exception(
|
return api.get_exception(
|
||||||
HTTPStatus.BAD_REQUEST, headers, request.format,
|
HTTPStatus.BAD_REQUEST, headers, request.format,
|
||||||
'InvalidParameterValue', msg)
|
'InvalidParameterValue', msg)
|
||||||
else:
|
else:
|
||||||
LOGGER.debug('processing Elasticsearch CQL_JSON data')
|
LOGGER.debug('processing CQL_JSON data')
|
||||||
try:
|
try:
|
||||||
filter_ = CQLModel.parse_raw(data)
|
filter_ = CQLModel.parse_raw(data)
|
||||||
except Exception:
|
except Exception:
|
||||||
msg = f'Bad CQL string : {data}'
|
msg = 'Bad CQL text'
|
||||||
|
LOGGER.error(f'{msg}: {data}')
|
||||||
return api.get_exception(
|
return api.get_exception(
|
||||||
HTTPStatus.BAD_REQUEST, headers, request.format,
|
HTTPStatus.BAD_REQUEST, headers, request.format,
|
||||||
'InvalidParameterValue', msg)
|
'InvalidParameterValue', msg)
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ from http import HTTPStatus
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from typing import Tuple
|
from typing import Tuple
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
from pygeoapi import l10n
|
from pygeoapi import l10n
|
||||||
from pygeoapi.util import (
|
from pygeoapi.util import (
|
||||||
@@ -240,10 +241,51 @@ def get_jobs(api: API, request: APIRequest,
|
|||||||
|
|
||||||
headers = request.get_response_headers(SYSTEM_LOCALE,
|
headers = request.get_response_headers(SYSTEM_LOCALE,
|
||||||
**api.api_headers)
|
**api.api_headers)
|
||||||
|
LOGGER.debug('Processing limit parameter')
|
||||||
|
try:
|
||||||
|
limit = int(request.params.get('limit'))
|
||||||
|
|
||||||
|
if limit <= 0:
|
||||||
|
msg = 'limit value should be strictly positive'
|
||||||
|
return api.get_exception(
|
||||||
|
HTTPStatus.BAD_REQUEST, headers, request.format,
|
||||||
|
'InvalidParameterValue', msg)
|
||||||
|
except TypeError:
|
||||||
|
limit = int(api.config['server']['limit'])
|
||||||
|
LOGGER.debug('returning all jobs')
|
||||||
|
except ValueError:
|
||||||
|
msg = 'limit value should be an integer'
|
||||||
|
return api.get_exception(
|
||||||
|
HTTPStatus.BAD_REQUEST, headers, request.format,
|
||||||
|
'InvalidParameterValue', msg)
|
||||||
|
|
||||||
|
LOGGER.debug('Processing offset parameter')
|
||||||
|
try:
|
||||||
|
offset = int(request.params.get('offset'))
|
||||||
|
if offset < 0:
|
||||||
|
msg = 'offset value should be positive or zero'
|
||||||
|
return api.get_exception(
|
||||||
|
HTTPStatus.BAD_REQUEST, headers, request.format,
|
||||||
|
'InvalidParameterValue', msg)
|
||||||
|
except TypeError as err:
|
||||||
|
LOGGER.warning(err)
|
||||||
|
offset = 0
|
||||||
|
except ValueError:
|
||||||
|
msg = 'offset value should be an integer'
|
||||||
|
return api.get_exception(
|
||||||
|
HTTPStatus.BAD_REQUEST, headers, request.format,
|
||||||
|
'InvalidParameterValue', msg)
|
||||||
|
|
||||||
if job_id is None:
|
if job_id is None:
|
||||||
jobs = sorted(api.manager.get_jobs(),
|
jobs_data = api.manager.get_jobs(limit=limit, offset=offset)
|
||||||
|
# TODO: For pagination to work, the provider has to do the sorting.
|
||||||
|
# Here we do sort again in case the provider doesn't support
|
||||||
|
# pagination yet and always returns all jobs.
|
||||||
|
jobs = sorted(jobs_data['jobs'],
|
||||||
key=lambda k: k['job_start_datetime'],
|
key=lambda k: k['job_start_datetime'],
|
||||||
reverse=True)
|
reverse=True)
|
||||||
|
numberMatched = jobs_data['numberMatched']
|
||||||
|
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
jobs = [api.manager.get_job(job_id)]
|
jobs = [api.manager.get_job(job_id)]
|
||||||
@@ -251,6 +293,7 @@ def get_jobs(api: API, request: APIRequest,
|
|||||||
return api.get_exception(
|
return api.get_exception(
|
||||||
HTTPStatus.NOT_FOUND, headers, request.format,
|
HTTPStatus.NOT_FOUND, headers, request.format,
|
||||||
'InvalidParameterValue', job_id)
|
'InvalidParameterValue', job_id)
|
||||||
|
numberMatched = 1
|
||||||
|
|
||||||
serialized_jobs = {
|
serialized_jobs = {
|
||||||
'jobs': [],
|
'jobs': [],
|
||||||
@@ -309,6 +352,44 @@ def get_jobs(api: API, request: APIRequest,
|
|||||||
|
|
||||||
serialized_jobs['jobs'].append(job2)
|
serialized_jobs['jobs'].append(job2)
|
||||||
|
|
||||||
|
serialized_query_params = ''
|
||||||
|
for k, v in request.params.items():
|
||||||
|
if k not in ('f', 'offset'):
|
||||||
|
serialized_query_params += '&'
|
||||||
|
serialized_query_params += urllib.parse.quote(k, safe='')
|
||||||
|
serialized_query_params += '='
|
||||||
|
serialized_query_params += urllib.parse.quote(str(v), safe=',')
|
||||||
|
|
||||||
|
uri = f'{api.base_url}/jobs'
|
||||||
|
|
||||||
|
if offset > 0:
|
||||||
|
prev = max(0, offset - limit)
|
||||||
|
serialized_jobs['links'].append(
|
||||||
|
{
|
||||||
|
'href': f'{uri}?offset={prev}{serialized_query_params}',
|
||||||
|
'type': FORMAT_TYPES[F_JSON],
|
||||||
|
'rel': 'prev',
|
||||||
|
'title': l10n.translate('Items (prev)', request.locale),
|
||||||
|
})
|
||||||
|
|
||||||
|
next_link = False
|
||||||
|
|
||||||
|
if numberMatched > (limit + offset):
|
||||||
|
next_link = True
|
||||||
|
elif len(jobs) == limit:
|
||||||
|
next_link = True
|
||||||
|
|
||||||
|
if next_link:
|
||||||
|
next_ = offset + limit
|
||||||
|
next_href = f'{uri}?offset={next_}{serialized_query_params}'
|
||||||
|
serialized_jobs['links'].append(
|
||||||
|
{
|
||||||
|
'href': next_href,
|
||||||
|
'rel': 'next',
|
||||||
|
'type': FORMAT_TYPES[F_JSON],
|
||||||
|
'title': l10n.translate('Items (next)', request.locale),
|
||||||
|
})
|
||||||
|
|
||||||
if job_id is None:
|
if job_id is None:
|
||||||
j2_template = 'jobs/index.html'
|
j2_template = 'jobs/index.html'
|
||||||
else:
|
else:
|
||||||
@@ -318,6 +399,7 @@ def get_jobs(api: API, request: APIRequest,
|
|||||||
if request.format == F_HTML:
|
if request.format == F_HTML:
|
||||||
data = {
|
data = {
|
||||||
'jobs': serialized_jobs,
|
'jobs': serialized_jobs,
|
||||||
|
'offset': offset,
|
||||||
'now': datetime.now(timezone.utc).strftime(DATETIME_FORMAT)
|
'now': datetime.now(timezone.utc).strftime(DATETIME_FORMAT)
|
||||||
}
|
}
|
||||||
response = render_j2_template(api.tpl_config, j2_template, data,
|
response = render_j2_template(api.tpl_config, j2_template, data,
|
||||||
@@ -379,6 +461,8 @@ def execute_process(api: API, request: APIRequest,
|
|||||||
requested_outputs = data.get('outputs')
|
requested_outputs = data.get('outputs')
|
||||||
LOGGER.debug(f'outputs: {requested_outputs}')
|
LOGGER.debug(f'outputs: {requested_outputs}')
|
||||||
|
|
||||||
|
requested_response = data.get('response', 'raw')
|
||||||
|
|
||||||
subscriber = None
|
subscriber = None
|
||||||
subscriber_dict = data.get('subscriber')
|
subscriber_dict = data.get('subscriber')
|
||||||
if subscriber_dict:
|
if subscriber_dict:
|
||||||
@@ -407,10 +491,14 @@ def execute_process(api: API, request: APIRequest,
|
|||||||
result = api.manager.execute_process(
|
result = api.manager.execute_process(
|
||||||
process_id, data_dict, execution_mode=execution_mode,
|
process_id, data_dict, execution_mode=execution_mode,
|
||||||
requested_outputs=requested_outputs,
|
requested_outputs=requested_outputs,
|
||||||
subscriber=subscriber)
|
subscriber=subscriber,
|
||||||
|
requested_response=requested_response)
|
||||||
job_id, mime_type, outputs, status, additional_headers = result
|
job_id, mime_type, outputs, status, additional_headers = result
|
||||||
headers.update(additional_headers or {})
|
headers.update(additional_headers or {})
|
||||||
headers['Location'] = f'{api.base_url}/jobs/{job_id}'
|
|
||||||
|
if api.manager.is_async:
|
||||||
|
headers['Location'] = f'{api.base_url}/jobs/{job_id}'
|
||||||
|
|
||||||
except ProcessorExecuteError as err:
|
except ProcessorExecuteError as err:
|
||||||
return api.get_exception(
|
return api.get_exception(
|
||||||
err.http_status_code, headers,
|
err.http_status_code, headers,
|
||||||
@@ -420,11 +508,11 @@ def execute_process(api: API, request: APIRequest,
|
|||||||
if status == JobStatus.failed:
|
if status == JobStatus.failed:
|
||||||
response = outputs
|
response = outputs
|
||||||
|
|
||||||
if data.get('response', 'raw') == 'raw':
|
if requested_response == 'raw':
|
||||||
headers['Content-Type'] = mime_type
|
headers['Content-Type'] = mime_type
|
||||||
response = outputs
|
response = outputs
|
||||||
elif status not in (JobStatus.failed, JobStatus.accepted):
|
elif status not in (JobStatus.failed, JobStatus.accepted):
|
||||||
response['outputs'] = [outputs]
|
response = outputs
|
||||||
|
|
||||||
if status == JobStatus.accepted:
|
if status == JobStatus.accepted:
|
||||||
http_status = HTTPStatus.CREATED
|
http_status = HTTPStatus.CREATED
|
||||||
@@ -433,7 +521,7 @@ def execute_process(api: API, request: APIRequest,
|
|||||||
else:
|
else:
|
||||||
http_status = HTTPStatus.OK
|
http_status = HTTPStatus.OK
|
||||||
|
|
||||||
if mime_type == 'application/json':
|
if mime_type == 'application/json' or requested_response == 'document':
|
||||||
response2 = to_json(response, api.pretty_print)
|
response2 = to_json(response, api.pretty_print)
|
||||||
else:
|
else:
|
||||||
response2 = response
|
response2 = response
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ ADMIN_BLUEPRINT = Blueprint('admin', __name__, static_folder=STATIC_FOLDER)
|
|||||||
if CONFIG['server'].get('cors', False):
|
if CONFIG['server'].get('cors', False):
|
||||||
try:
|
try:
|
||||||
from flask_cors import CORS
|
from flask_cors import CORS
|
||||||
CORS(APP)
|
CORS(APP, CORS_EXPOSE_HEADERS=['*'])
|
||||||
except ModuleNotFoundError:
|
except ModuleNotFoundError:
|
||||||
print('Python package flask-cors required for CORS support')
|
print('Python package flask-cors required for CORS support')
|
||||||
|
|
||||||
@@ -279,11 +279,7 @@ def collection_items(collection_id, item_id=None):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
if item_id is None:
|
if item_id is None:
|
||||||
if request.method == 'GET': # list items
|
if request.method == 'POST': # filter or manage items
|
||||||
return execute_from_flask(itemtypes_api.get_collection_items,
|
|
||||||
request, collection_id,
|
|
||||||
skip_valid_check=True)
|
|
||||||
elif request.method == 'POST': # filter or manage items
|
|
||||||
if request.content_type is not None:
|
if request.content_type is not None:
|
||||||
if request.content_type == 'application/geo+json':
|
if request.content_type == 'application/geo+json':
|
||||||
return execute_from_flask(
|
return execute_from_flask(
|
||||||
@@ -298,6 +294,10 @@ def collection_items(collection_id, item_id=None):
|
|||||||
return execute_from_flask(
|
return execute_from_flask(
|
||||||
itemtypes_api.manage_collection_item, request, 'options',
|
itemtypes_api.manage_collection_item, request, 'options',
|
||||||
collection_id, skip_valid_check=True)
|
collection_id, skip_valid_check=True)
|
||||||
|
else: # GET: list items
|
||||||
|
return execute_from_flask(itemtypes_api.get_collection_items,
|
||||||
|
request, collection_id,
|
||||||
|
skip_valid_check=True)
|
||||||
|
|
||||||
elif request.method == 'DELETE':
|
elif request.method == 'DELETE':
|
||||||
return execute_from_flask(itemtypes_api.manage_collection_item,
|
return execute_from_flask(itemtypes_api.manage_collection_item,
|
||||||
|
|||||||
@@ -27,11 +27,10 @@
|
|||||||
#
|
#
|
||||||
# =================================================================
|
# =================================================================
|
||||||
|
|
||||||
|
import csv
|
||||||
import io
|
import io
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
import unicodecsv as csv
|
|
||||||
|
|
||||||
from pygeoapi.formatter.base import BaseFormatter, FormatterSerializationError
|
from pygeoapi.formatter.base import BaseFormatter, FormatterSerializationError
|
||||||
|
|
||||||
LOGGER = logging.getLogger(__name__)
|
LOGGER = logging.getLogger(__name__)
|
||||||
@@ -83,10 +82,11 @@ class CSVFormatter(BaseFormatter):
|
|||||||
# TODO: implement wkt geometry serialization
|
# TODO: implement wkt geometry serialization
|
||||||
LOGGER.debug('not a point geometry, skipping')
|
LOGGER.debug('not a point geometry, skipping')
|
||||||
|
|
||||||
|
print("JJJ", fields)
|
||||||
LOGGER.debug(f'CSV fields: {fields}')
|
LOGGER.debug(f'CSV fields: {fields}')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
output = io.BytesIO()
|
output = io.StringIO()
|
||||||
writer = csv.DictWriter(output, fields)
|
writer = csv.DictWriter(output, fields)
|
||||||
writer.writeheader()
|
writer.writeheader()
|
||||||
|
|
||||||
@@ -101,7 +101,7 @@ class CSVFormatter(BaseFormatter):
|
|||||||
LOGGER.error(err)
|
LOGGER.error(err)
|
||||||
raise FormatterSerializationError('Error writing CSV output')
|
raise FormatterSerializationError('Error writing CSV output')
|
||||||
|
|
||||||
return output.getvalue()
|
return output.getvalue().encode('utf-8')
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f'<CSVFormatter> {self.name}'
|
return f'<CSVFormatter> {self.name}'
|
||||||
|
|||||||
+58
-5
@@ -134,6 +134,52 @@ def gen_response_object(description: str, media_type: str,
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def gen_contact(cfg: dict) -> dict:
|
||||||
|
"""
|
||||||
|
Generates an OpenAPI contact object with OGC extensions
|
||||||
|
based on OGC API - Records contact
|
||||||
|
|
||||||
|
:param cfg: `dict` of configuration
|
||||||
|
|
||||||
|
:returns: `dict` of OpenAPI contact object
|
||||||
|
"""
|
||||||
|
|
||||||
|
contact = {
|
||||||
|
'name': cfg['metadata']['provider']['name'],
|
||||||
|
'url': cfg['metadata']['provider']['url'],
|
||||||
|
'email': cfg['metadata']['contact']['email']
|
||||||
|
}
|
||||||
|
|
||||||
|
contact['x-ogc-serviceContact'] = {
|
||||||
|
'name': cfg['metadata']['contact']['name'],
|
||||||
|
'position': cfg['metadata']['contact']['position'],
|
||||||
|
'addresses': [{
|
||||||
|
'deliveryPoint': [cfg['metadata']['contact']['address']],
|
||||||
|
'city': cfg['metadata']['contact']['city'],
|
||||||
|
'administrativeArea': cfg['metadata']['contact']['stateorprovince'], # noqa
|
||||||
|
'postalCode': cfg['metadata']['contact']['postalcode'],
|
||||||
|
'country': cfg['metadata']['contact']['country']
|
||||||
|
}],
|
||||||
|
'phones': [{
|
||||||
|
'type': 'main', 'value': cfg['metadata']['contact']['phone']
|
||||||
|
}, {
|
||||||
|
'type': 'fax', 'value': cfg['metadata']['contact']['fax']
|
||||||
|
}],
|
||||||
|
'emails': [{
|
||||||
|
'value': cfg['metadata']['contact']['email']
|
||||||
|
}],
|
||||||
|
'contactInstructions': cfg['metadata']['contact']['instructions'],
|
||||||
|
'links': [{
|
||||||
|
'type': 'text/html',
|
||||||
|
'href': cfg['metadata']['contact']['url']
|
||||||
|
}],
|
||||||
|
'hoursOfService': cfg['metadata']['contact']['hours'],
|
||||||
|
'roles': [cfg['metadata']['contact']['role']]
|
||||||
|
}
|
||||||
|
|
||||||
|
return contact
|
||||||
|
|
||||||
|
|
||||||
def get_oas_30(cfg: dict, fail_on_invalid_collection: bool = True) -> dict:
|
def get_oas_30(cfg: dict, fail_on_invalid_collection: bool = True) -> dict:
|
||||||
"""
|
"""
|
||||||
Generates an OpenAPI 3.0 Document
|
Generates an OpenAPI 3.0 Document
|
||||||
@@ -167,11 +213,7 @@ def get_oas_30(cfg: dict, fail_on_invalid_collection: bool = True) -> dict:
|
|||||||
'x-keywords': l10n.translate(cfg['metadata']['identification']['keywords'], locale_), # noqa
|
'x-keywords': l10n.translate(cfg['metadata']['identification']['keywords'], locale_), # noqa
|
||||||
'termsOfService':
|
'termsOfService':
|
||||||
cfg['metadata']['identification']['terms_of_service'],
|
cfg['metadata']['identification']['terms_of_service'],
|
||||||
'contact': {
|
'contact': gen_contact(cfg),
|
||||||
'name': cfg['metadata']['provider']['name'],
|
|
||||||
'url': cfg['metadata']['provider']['url'],
|
|
||||||
'email': cfg['metadata']['contact']['email']
|
|
||||||
},
|
|
||||||
'license': {
|
'license': {
|
||||||
'name': cfg['metadata']['license']['name'],
|
'name': cfg['metadata']['license']['name'],
|
||||||
'url': cfg['metadata']['license']['url']
|
'url': cfg['metadata']['license']['url']
|
||||||
@@ -903,6 +945,17 @@ def load_openapi_document() -> dict:
|
|||||||
|
|
||||||
pygeoapi_openapi = os.environ.get('PYGEOAPI_OPENAPI')
|
pygeoapi_openapi = os.environ.get('PYGEOAPI_OPENAPI')
|
||||||
|
|
||||||
|
if pygeoapi_openapi is None:
|
||||||
|
msg = 'PYGEOAPI_OPENAPI environment not set'
|
||||||
|
LOGGER.error(msg)
|
||||||
|
raise RuntimeError(msg)
|
||||||
|
|
||||||
|
if not os.path.exists(pygeoapi_openapi):
|
||||||
|
msg = (f'OpenAPI document {pygeoapi_openapi} does not exist. '
|
||||||
|
'Please generate before starting pygeoapi')
|
||||||
|
LOGGER.error(msg)
|
||||||
|
raise RuntimeError(msg)
|
||||||
|
|
||||||
with open(pygeoapi_openapi, encoding='utf8') as ff:
|
with open(pygeoapi_openapi, encoding='utf8') as ff:
|
||||||
if pygeoapi_openapi.endswith(('.yaml', '.yml')):
|
if pygeoapi_openapi.endswith(('.yaml', '.yml')):
|
||||||
openapi_ = yaml_load(ff)
|
openapi_ = yaml_load(ff)
|
||||||
|
|||||||
+4
-2
@@ -51,10 +51,12 @@ PLUGINS = {
|
|||||||
'MapScript': 'pygeoapi.provider.mapscript_.MapScriptProvider',
|
'MapScript': 'pygeoapi.provider.mapscript_.MapScriptProvider',
|
||||||
'MongoDB': 'pygeoapi.provider.mongo.MongoProvider',
|
'MongoDB': 'pygeoapi.provider.mongo.MongoProvider',
|
||||||
'MVT-tippecanoe': 'pygeoapi.provider.mvt_tippecanoe.MVTTippecanoeProvider', # noqa: E501
|
'MVT-tippecanoe': 'pygeoapi.provider.mvt_tippecanoe.MVTTippecanoeProvider', # noqa: E501
|
||||||
'MVT-elastic': 'pygeoapi.provider.mvt_elastic.MVTElasticProvider', # noqa: E501
|
'MVT-elastic': 'pygeoapi.provider.mvt_elastic.MVTElasticProvider',
|
||||||
'MVT-proxy': 'pygeoapi.provider.mvt_proxy.MVTProxyProvider', # noqa: E501
|
'MVT-proxy': 'pygeoapi.provider.mvt_proxy.MVTProxyProvider',
|
||||||
'OracleDB': 'pygeoapi.provider.oracle.OracleProvider',
|
'OracleDB': 'pygeoapi.provider.oracle.OracleProvider',
|
||||||
'OGR': 'pygeoapi.provider.ogr.OGRProvider',
|
'OGR': 'pygeoapi.provider.ogr.OGRProvider',
|
||||||
|
'OpenSearch': 'pygeoapi.provider.opensearch_.OpenSearchProvider',
|
||||||
|
'Parquet': 'pygeoapi.provider.parquet.ParquetProvider',
|
||||||
'PostgreSQL': 'pygeoapi.provider.postgresql.PostgreSQLProvider',
|
'PostgreSQL': 'pygeoapi.provider.postgresql.PostgreSQLProvider',
|
||||||
'rasterio': 'pygeoapi.provider.rasterio_.RasterioProvider',
|
'rasterio': 'pygeoapi.provider.rasterio_.RasterioProvider',
|
||||||
'SensorThings': 'pygeoapi.provider.sensorthings.SensorThingsProvider',
|
'SensorThings': 'pygeoapi.provider.sensorthings.SensorThingsProvider',
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ from pygeoapi.util import (
|
|||||||
JobStatus,
|
JobStatus,
|
||||||
ProcessExecutionMode,
|
ProcessExecutionMode,
|
||||||
RequestedProcessExecutionMode,
|
RequestedProcessExecutionMode,
|
||||||
|
RequestedResponse,
|
||||||
Subscriber
|
Subscriber
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -107,14 +108,21 @@ class BaseManager:
|
|||||||
else:
|
else:
|
||||||
return load_plugin('process', process_conf['processor'])
|
return load_plugin('process', process_conf['processor'])
|
||||||
|
|
||||||
def get_jobs(self, status: JobStatus = None) -> list:
|
def get_jobs(self,
|
||||||
|
status: JobStatus = None,
|
||||||
|
limit: Optional[int] = None,
|
||||||
|
offset: Optional[int] = None
|
||||||
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Get process jobs, optionally filtered by status
|
Get process jobs, optionally filtered by status
|
||||||
|
|
||||||
:param status: job status (accepted, running, successful,
|
:param status: job status (accepted, running, successful,
|
||||||
failed, results) (default is all)
|
failed, results) (default is all)
|
||||||
|
:param limit: number of jobs to return
|
||||||
|
:param offset: pagination offset
|
||||||
|
|
||||||
:returns: `list` of jobs (identifier, status, process identifier)
|
:returns: dict of list of jobs (identifier, status, process identifier)
|
||||||
|
and numberMatched
|
||||||
"""
|
"""
|
||||||
|
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
@@ -187,6 +195,7 @@ class BaseManager:
|
|||||||
data_dict: dict,
|
data_dict: dict,
|
||||||
requested_outputs: Optional[dict] = None,
|
requested_outputs: Optional[dict] = None,
|
||||||
subscriber: Optional[Subscriber] = None,
|
subscriber: Optional[Subscriber] = None,
|
||||||
|
requested_response: Optional[RequestedResponse] = RequestedResponse.raw.value # noqa
|
||||||
) -> Tuple[str, None, JobStatus]:
|
) -> Tuple[str, None, JobStatus]:
|
||||||
"""
|
"""
|
||||||
This private execution handler executes a process in a background
|
This private execution handler executes a process in a background
|
||||||
@@ -197,27 +206,34 @@ class BaseManager:
|
|||||||
:param p: `pygeoapi.process` object
|
:param p: `pygeoapi.process` object
|
||||||
:param job_id: job identifier
|
:param job_id: job identifier
|
||||||
:param data_dict: `dict` of data parameters
|
:param data_dict: `dict` of data parameters
|
||||||
:param requested_outputs: `dict` specify the subset of required
|
:param requested_outputs: `dict` optionally specifying the subset of
|
||||||
outputs - defaults to all outputs.
|
required outputs - defaults to all outputs.
|
||||||
The value of any key may be an object and include the property
|
The value of any key may be an object and
|
||||||
`transmissionMode` - defaults to `value`.
|
include the property `transmissionMode`
|
||||||
Note: 'optional' is for backward compatibility.
|
(defaults to `value`)
|
||||||
|
Note: 'optional' is for backward
|
||||||
|
compatibility.
|
||||||
:param subscriber: optional `Subscriber` specifying callback URLs
|
:param subscriber: optional `Subscriber` specifying callback URLs
|
||||||
|
:param requested_response: `RequestedResponse` optionally specifying
|
||||||
|
raw or document (default is `raw`)
|
||||||
|
|
||||||
:returns: tuple of None (i.e. initial response payload)
|
:returns: tuple of None (i.e. initial response payload)
|
||||||
and JobStatus.accepted (i.e. initial job status)
|
and JobStatus.accepted (i.e. initial job status)
|
||||||
"""
|
"""
|
||||||
_process = dummy.Process(
|
|
||||||
target=self._execute_handler_sync,
|
args = (p, job_id, data_dict, requested_outputs, subscriber,
|
||||||
args=(p, job_id, data_dict, requested_outputs, subscriber)
|
requested_response)
|
||||||
)
|
|
||||||
|
_process = dummy.Process(target=self._execute_handler_sync, args=args)
|
||||||
_process.start()
|
_process.start()
|
||||||
|
|
||||||
return 'application/json', None, JobStatus.accepted
|
return 'application/json', None, JobStatus.accepted
|
||||||
|
|
||||||
def _execute_handler_sync(self, p: BaseProcessor, job_id: str,
|
def _execute_handler_sync(self, p: BaseProcessor, job_id: str,
|
||||||
data_dict: dict,
|
data_dict: dict,
|
||||||
requested_outputs: Optional[dict] = None,
|
requested_outputs: Optional[dict] = None,
|
||||||
subscriber: Optional[Subscriber] = None,
|
subscriber: Optional[Subscriber] = None,
|
||||||
|
requested_response: Optional[RequestedResponse] = RequestedResponse.raw.value # noqa
|
||||||
) -> Tuple[str, Any, JobStatus]:
|
) -> Tuple[str, Any, JobStatus]:
|
||||||
"""
|
"""
|
||||||
Synchronous execution handler
|
Synchronous execution handler
|
||||||
@@ -229,15 +245,27 @@ class BaseManager:
|
|||||||
:param p: `pygeoapi.process` object
|
:param p: `pygeoapi.process` object
|
||||||
:param job_id: job identifier
|
:param job_id: job identifier
|
||||||
:param data_dict: `dict` of data parameters
|
:param data_dict: `dict` of data parameters
|
||||||
:param requested_outputs: `dict` specify the subset of required
|
:param requested_outputs: `dict` optionally specifying the subset of
|
||||||
outputs - defaults to all outputs.
|
required outputs - defaults to all outputs.
|
||||||
The value of any key may be an object and include the property
|
The value of any key may be an object and
|
||||||
`transmissionMode` - defaults to `value`.
|
include the property `transmissionMode`
|
||||||
Note: 'optional' is for backward compatibility.
|
(defaults to `value`)
|
||||||
|
Note: 'optional' is for backward
|
||||||
|
compatibility.
|
||||||
:param subscriber: optional `Subscriber` specifying callback URLs
|
:param subscriber: optional `Subscriber` specifying callback URLs
|
||||||
|
:param requested_response: `RequestedResponse` optionally specifying
|
||||||
|
raw or document (default is `raw`)
|
||||||
|
|
||||||
:returns: tuple of MIME type, response payload and status
|
:returns: tuple of MIME type, response payload and status
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
extra_execute_parameters = {}
|
||||||
|
|
||||||
|
# only pass requested_outputs if supported,
|
||||||
|
# otherwise this breaks existing processes
|
||||||
|
if p.supports_outputs:
|
||||||
|
extra_execute_parameters['outputs'] = requested_outputs
|
||||||
|
|
||||||
self._send_in_progress_notification(subscriber)
|
self._send_in_progress_notification(subscriber)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -248,13 +276,12 @@ class BaseManager:
|
|||||||
job_filename = None
|
job_filename = None
|
||||||
|
|
||||||
current_status = JobStatus.running
|
current_status = JobStatus.running
|
||||||
jfmt, outputs = p.execute(
|
jfmt, outputs = p.execute(data_dict, **extra_execute_parameters)
|
||||||
data_dict,
|
|
||||||
# only pass requested_outputs if supported,
|
if requested_response == RequestedResponse.document.value:
|
||||||
# otherwise this breaks existing processes
|
outputs = {
|
||||||
**({'outputs': requested_outputs}
|
'outputs': [outputs]
|
||||||
if p.supports_outputs else {})
|
}
|
||||||
)
|
|
||||||
|
|
||||||
self.update_job(job_id, {
|
self.update_job(job_id, {
|
||||||
'status': current_status.value,
|
'status': current_status.value,
|
||||||
@@ -330,7 +357,8 @@ class BaseManager:
|
|||||||
data_dict: dict,
|
data_dict: dict,
|
||||||
execution_mode: Optional[RequestedProcessExecutionMode] = None,
|
execution_mode: Optional[RequestedProcessExecutionMode] = None,
|
||||||
requested_outputs: Optional[dict] = None,
|
requested_outputs: Optional[dict] = None,
|
||||||
subscriber: Optional[Subscriber] = None
|
subscriber: Optional[Subscriber] = None,
|
||||||
|
requested_response: Optional[RequestedResponse] = RequestedResponse.raw.value # noqa
|
||||||
) -> Tuple[str, Any, JobStatus, Optional[Dict[str, str]]]:
|
) -> Tuple[str, Any, JobStatus, Optional[Dict[str, str]]]:
|
||||||
"""
|
"""
|
||||||
Default process execution handler
|
Default process execution handler
|
||||||
@@ -339,12 +367,17 @@ class BaseManager:
|
|||||||
:param data_dict: `dict` of data parameters
|
:param data_dict: `dict` of data parameters
|
||||||
:param execution_mode: `str` optionally specifying sync or async
|
:param execution_mode: `str` optionally specifying sync or async
|
||||||
processing.
|
processing.
|
||||||
:param requested_outputs: `dict` optionally specify the subset of
|
:param requested_outputs: `dict` optionally specifying the subset of
|
||||||
required outputs - defaults to all outputs.
|
required outputs - defaults to all outputs.
|
||||||
The value of any key may be an object and include the property
|
The value of any key may be an object and
|
||||||
`transmissionMode` - defaults to `value`.
|
include the property `transmissionMode`
|
||||||
Note: 'optional' is for backward compatibility.
|
(default is `value`)
|
||||||
|
Note: 'optional' is for backward
|
||||||
|
compatibility.
|
||||||
:param subscriber: `Subscriber` optionally specifying callback urls
|
:param subscriber: `Subscriber` optionally specifying callback urls
|
||||||
|
:param requested_response: `RequestedResponse` optionally specifying
|
||||||
|
raw or document (default is `raw`)
|
||||||
|
|
||||||
|
|
||||||
:raises UnknownProcessError: if the input process_id does not
|
:raises UnknownProcessError: if the input process_id does not
|
||||||
correspond to a known process
|
correspond to a known process
|
||||||
@@ -356,6 +389,9 @@ class BaseManager:
|
|||||||
job_id = str(uuid.uuid1())
|
job_id = str(uuid.uuid1())
|
||||||
processor = self.get_processor(process_id)
|
processor = self.get_processor(process_id)
|
||||||
processor.set_job_id(job_id)
|
processor.set_job_id(job_id)
|
||||||
|
extra_execute_handler_parameters = {
|
||||||
|
'requested_response': requested_response
|
||||||
|
}
|
||||||
|
|
||||||
if execution_mode == RequestedProcessExecutionMode.respond_async:
|
if execution_mode == RequestedProcessExecutionMode.respond_async:
|
||||||
job_control_options = processor.metadata.get(
|
job_control_options = processor.metadata.get(
|
||||||
@@ -406,6 +442,11 @@ class BaseManager:
|
|||||||
}
|
}
|
||||||
self.add_job(job_metadata)
|
self.add_job(job_metadata)
|
||||||
|
|
||||||
|
# only pass subscriber if supported, otherwise this breaks
|
||||||
|
# existing managers
|
||||||
|
if self.supports_subscribing:
|
||||||
|
extra_execute_handler_parameters['subscriber'] = subscriber
|
||||||
|
|
||||||
# TODO: handler's response could also be allowed to include more HTTP
|
# TODO: handler's response could also be allowed to include more HTTP
|
||||||
# headers
|
# headers
|
||||||
mime_type, outputs, status = handler(
|
mime_type, outputs, status = handler(
|
||||||
@@ -413,10 +454,7 @@ class BaseManager:
|
|||||||
job_id,
|
job_id,
|
||||||
data_dict,
|
data_dict,
|
||||||
requested_outputs,
|
requested_outputs,
|
||||||
# only pass subscriber if supported, otherwise this breaks existing
|
**extra_execute_handler_parameters)
|
||||||
# managers
|
|
||||||
**({'subscriber': subscriber} if self.supports_subscribing else {})
|
|
||||||
)
|
|
||||||
|
|
||||||
return job_id, mime_type, outputs, status, response_headers
|
return job_id, mime_type, outputs, status, response_headers
|
||||||
|
|
||||||
|
|||||||
@@ -33,8 +33,9 @@ import uuid
|
|||||||
|
|
||||||
from pygeoapi.process.manager.base import BaseManager
|
from pygeoapi.process.manager.base import BaseManager
|
||||||
from pygeoapi.util import (
|
from pygeoapi.util import (
|
||||||
RequestedProcessExecutionMode,
|
|
||||||
JobStatus,
|
JobStatus,
|
||||||
|
RequestedProcessExecutionMode,
|
||||||
|
RequestedResponse,
|
||||||
Subscriber
|
Subscriber
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -55,17 +56,21 @@ class DummyManager(BaseManager):
|
|||||||
|
|
||||||
super().__init__(manager_def)
|
super().__init__(manager_def)
|
||||||
|
|
||||||
def get_jobs(self, status: JobStatus = None) -> list:
|
def get_jobs(self, status: JobStatus = None, limit=None, offset=None
|
||||||
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Get process jobs, optionally filtered by status
|
Get process jobs, optionally filtered by status
|
||||||
|
|
||||||
:param status: job status (accepted, running, successful,
|
:param status: job status (accepted, running, successful,
|
||||||
failed, results) (default is all)
|
failed, results) (default is all)
|
||||||
|
:param limit: number of jobs to return
|
||||||
|
:param offset: pagination offset
|
||||||
|
|
||||||
:returns: `list` of jobs (identifier, status, process identifier)
|
:returns: dict of list of jobs (identifier, status, process identifier)
|
||||||
|
and numberMatched
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return []
|
return {'jobs': [], 'numberMatched': 0}
|
||||||
|
|
||||||
def execute_process(
|
def execute_process(
|
||||||
self,
|
self,
|
||||||
@@ -73,7 +78,8 @@ class DummyManager(BaseManager):
|
|||||||
data_dict: dict,
|
data_dict: dict,
|
||||||
execution_mode: Optional[RequestedProcessExecutionMode] = None,
|
execution_mode: Optional[RequestedProcessExecutionMode] = None,
|
||||||
requested_outputs: Optional[dict] = None,
|
requested_outputs: Optional[dict] = None,
|
||||||
subscriber: Optional[Subscriber] = None
|
subscriber: Optional[Subscriber] = None,
|
||||||
|
requested_response: Optional[RequestedResponse] = RequestedResponse.raw.value # noqa
|
||||||
) -> Tuple[str, str, Any, JobStatus, Optional[Dict[str, str]]]:
|
) -> Tuple[str, str, Any, JobStatus, Optional[Dict[str, str]]]:
|
||||||
"""
|
"""
|
||||||
Default process execution handler
|
Default process execution handler
|
||||||
@@ -81,9 +87,19 @@ class DummyManager(BaseManager):
|
|||||||
:param process_id: process identifier
|
:param process_id: process identifier
|
||||||
:param data_dict: `dict` of data parameters
|
:param data_dict: `dict` of data parameters
|
||||||
:param execution_mode: requested execution mode
|
:param execution_mode: requested execution mode
|
||||||
|
:param requested_outputs: `dict` optionally specify the subset of
|
||||||
|
required outputs - defaults to all outputs.
|
||||||
|
The value of any key may be an object and include the property
|
||||||
|
`transmissionMode` - defaults to `value`.
|
||||||
|
Note: 'optional' is for backward compatibility.
|
||||||
|
:param subscriber: `Subscriber` optionally specifying callback urls
|
||||||
|
:param requested_response: `RequestedResponse` optionally specifying
|
||||||
|
raw or document (default is `raw`)
|
||||||
|
|
||||||
|
:raises UnknownProcessError: if the input process_id does not
|
||||||
|
correspond to a known process
|
||||||
:returns: tuple of job_id, MIME type, response payload, status and
|
:returns: tuple of job_id, MIME type, response payload, status and
|
||||||
optionally additional HTTP headers to include in the
|
optionally additional HTTP headers to include in the final
|
||||||
response
|
response
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -100,7 +116,8 @@ class DummyManager(BaseManager):
|
|||||||
self._send_in_progress_notification(subscriber)
|
self._send_in_progress_notification(subscriber)
|
||||||
processor = self.get_processor(process_id)
|
processor = self.get_processor(process_id)
|
||||||
try:
|
try:
|
||||||
jfmt, outputs = processor.execute(data_dict)
|
jfmt, outputs = processor.execute(
|
||||||
|
data_dict, outputs=requested_outputs)
|
||||||
current_status = JobStatus.successful
|
current_status = JobStatus.successful
|
||||||
self._send_success_notification(subscriber, outputs)
|
self._send_success_notification(subscriber, outputs)
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
@@ -111,6 +128,12 @@ class DummyManager(BaseManager):
|
|||||||
current_status = JobStatus.failed
|
current_status = JobStatus.failed
|
||||||
LOGGER.exception(err)
|
LOGGER.exception(err)
|
||||||
self._send_failed_notification(subscriber)
|
self._send_failed_notification(subscriber)
|
||||||
|
|
||||||
|
if requested_response == RequestedResponse.document.value:
|
||||||
|
outputs = {
|
||||||
|
'outputs': [outputs]
|
||||||
|
}
|
||||||
|
|
||||||
job_id = str(uuid.uuid1())
|
job_id = str(uuid.uuid1())
|
||||||
return job_id, jfmt, outputs, current_status, response_headers
|
return job_id, jfmt, outputs, current_status, response_headers
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import traceback
|
|||||||
|
|
||||||
from pymongo import MongoClient
|
from pymongo import MongoClient
|
||||||
|
|
||||||
|
from pygeoapi.api import FORMAT_TYPES, F_JSON, F_JSONLD
|
||||||
from pygeoapi.process.base import (
|
from pygeoapi.process.base import (
|
||||||
JobNotFoundError,
|
JobNotFoundError,
|
||||||
JobResultNotFoundError,
|
JobResultNotFoundError,
|
||||||
@@ -70,7 +71,7 @@ class MongoDBManager(BaseManager):
|
|||||||
exc_info=(traceback))
|
exc_info=(traceback))
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def get_jobs(self, status=None):
|
def get_jobs(self, status=None, limit=None, offset=None):
|
||||||
try:
|
try:
|
||||||
self._connect()
|
self._connect()
|
||||||
database = self.db.job_manager_pygeoapi
|
database = self.db.job_manager_pygeoapi
|
||||||
@@ -80,7 +81,10 @@ class MongoDBManager(BaseManager):
|
|||||||
else:
|
else:
|
||||||
jobs = list(collection.find({}))
|
jobs = list(collection.find({}))
|
||||||
LOGGER.info("JOBMANAGER - MongoDB jobs queried")
|
LOGGER.info("JOBMANAGER - MongoDB jobs queried")
|
||||||
return jobs
|
return {
|
||||||
|
'jobs': jobs,
|
||||||
|
'numberMatched': len(jobs)
|
||||||
|
}
|
||||||
except Exception:
|
except Exception:
|
||||||
LOGGER.error("JOBMANAGER - get_jobs error",
|
LOGGER.error("JOBMANAGER - get_jobs error",
|
||||||
exc_info=(traceback))
|
exc_info=(traceback))
|
||||||
@@ -148,8 +152,16 @@ class MongoDBManager(BaseManager):
|
|||||||
if entry["status"] != "successful":
|
if entry["status"] != "successful":
|
||||||
LOGGER.info("JOBMANAGER - job not finished or failed")
|
LOGGER.info("JOBMANAGER - job not finished or failed")
|
||||||
return (None,)
|
return (None,)
|
||||||
with open(entry["location"], "r") as file:
|
if not entry["location"]:
|
||||||
data = json.load(file)
|
LOGGER.warning(f"job {job_id!r} - unknown result location")
|
||||||
|
raise JobResultNotFoundError()
|
||||||
|
if entry["mimetype"] in (None, FORMAT_TYPES[F_JSON],
|
||||||
|
FORMAT_TYPES[F_JSONLD]):
|
||||||
|
with open(entry["location"], "r") as file:
|
||||||
|
data = json.load(file)
|
||||||
|
else:
|
||||||
|
with open(entry["location"], "rb") as file:
|
||||||
|
data = file.read()
|
||||||
LOGGER.info("JOBMANAGER - MongoDB job result queried")
|
LOGGER.info("JOBMANAGER - MongoDB job result queried")
|
||||||
return entry["mimetype"], data
|
return entry["mimetype"], data
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
|
|||||||
@@ -46,8 +46,10 @@ from pathlib import Path
|
|||||||
from typing import Any, Tuple
|
from typing import Any, Tuple
|
||||||
|
|
||||||
from sqlalchemy import insert, update, delete
|
from sqlalchemy import insert, update, delete
|
||||||
|
from sqlalchemy.engine import make_url
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from pygeoapi.api import FORMAT_TYPES, F_JSON, F_JSONLD
|
||||||
from pygeoapi.process.base import (
|
from pygeoapi.process.base import (
|
||||||
JobNotFoundError,
|
JobNotFoundError,
|
||||||
JobResultNotFoundError,
|
JobResultNotFoundError,
|
||||||
@@ -83,12 +85,18 @@ class PostgreSQLManager(BaseManager):
|
|||||||
self.db_search_path = tuple(self.connection.get('search_path',
|
self.db_search_path = tuple(self.connection.get('search_path',
|
||||||
['public']))
|
['public']))
|
||||||
except Exception:
|
except Exception:
|
||||||
self.db_search_path = 'public'
|
self.db_search_path = ('public',)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
LOGGER.debug('Connecting to database')
|
LOGGER.debug('Connecting to database')
|
||||||
if isinstance(self.connection, str):
|
if isinstance(self.connection, str):
|
||||||
self._engine = get_engine(self.connection)
|
_url = make_url(self.connection)
|
||||||
|
self._engine = get_engine(
|
||||||
|
_url.host,
|
||||||
|
_url.port,
|
||||||
|
_url.database,
|
||||||
|
_url.username,
|
||||||
|
_url.password)
|
||||||
else:
|
else:
|
||||||
self._engine = get_engine(**self.connection)
|
self._engine = get_engine(**self.connection)
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
@@ -109,16 +117,18 @@ class PostgreSQLManager(BaseManager):
|
|||||||
LOGGER.error(f'{msg}: {err}')
|
LOGGER.error(f'{msg}: {err}')
|
||||||
raise ProcessorGenericError(msg)
|
raise ProcessorGenericError(msg)
|
||||||
|
|
||||||
def get_jobs(self, status: JobStatus = None) -> list:
|
def get_jobs(self, status: JobStatus = None, limit=None, offset=None
|
||||||
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Get jobs
|
Get jobs
|
||||||
|
|
||||||
:param status: job status (accepted, running, successful,
|
:param status: job status (accepted, running, successful,
|
||||||
failed, results) (default is all)
|
failed, results) (default is all)
|
||||||
|
:param limit: number of jobs to return
|
||||||
|
:param offset: pagination offset
|
||||||
|
|
||||||
:returns: 'list` of jobs (type (default='process'), identifier,
|
:returns: dict of list of jobs (identifier, status, process identifier)
|
||||||
status, process_id, job_start_datetime, job_end_datetime, location,
|
and numberMatched
|
||||||
mimetype, message, progress)
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
LOGGER.debug('Querying for jobs')
|
LOGGER.debug('Querying for jobs')
|
||||||
@@ -128,7 +138,11 @@ class PostgreSQLManager(BaseManager):
|
|||||||
column = getattr(self.table_model, 'status')
|
column = getattr(self.table_model, 'status')
|
||||||
results = results.filter(column == status.value)
|
results = results.filter(column == status.value)
|
||||||
|
|
||||||
return [r.__dict__ for r in results.all()]
|
jobs = [r.__dict__ for r in results.all()]
|
||||||
|
return {
|
||||||
|
'jobs': jobs,
|
||||||
|
'numberMatched': len(jobs)
|
||||||
|
}
|
||||||
|
|
||||||
def add_job(self, job_metadata: dict) -> str:
|
def add_job(self, job_metadata: dict) -> str:
|
||||||
"""
|
"""
|
||||||
@@ -279,8 +293,13 @@ class PostgreSQLManager(BaseManager):
|
|||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
location = Path(location)
|
location = Path(location)
|
||||||
with location.open(encoding='utf-8') as fh:
|
if mimetype in (None, FORMAT_TYPES[F_JSON],
|
||||||
result = json.load(fh)
|
FORMAT_TYPES[F_JSONLD]):
|
||||||
|
with location.open('r', encoding='utf-8') as fh:
|
||||||
|
result = json.load(fh)
|
||||||
|
else:
|
||||||
|
with location.open('rb') as fh:
|
||||||
|
result = fh.read()
|
||||||
except (TypeError, FileNotFoundError, json.JSONDecodeError):
|
except (TypeError, FileNotFoundError, json.JSONDecodeError):
|
||||||
raise JobResultNotFoundError()
|
raise JobResultNotFoundError()
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ from typing import Any, Tuple
|
|||||||
import tinydb
|
import tinydb
|
||||||
from filelock import FileLock
|
from filelock import FileLock
|
||||||
|
|
||||||
|
from pygeoapi.api import FORMAT_TYPES, F_JSON, F_JSONLD
|
||||||
from pygeoapi.process.base import (
|
from pygeoapi.process.base import (
|
||||||
JobNotFoundError,
|
JobNotFoundError,
|
||||||
JobResultNotFoundError,
|
JobResultNotFoundError,
|
||||||
@@ -82,20 +83,35 @@ class TinyDBManager(BaseManager):
|
|||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def get_jobs(self, status: JobStatus = None) -> list:
|
def get_jobs(self, status: JobStatus = None, limit=None, offset=None
|
||||||
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Get jobs
|
Get jobs
|
||||||
|
|
||||||
:param status: job status (accepted, running, successful,
|
:param status: job status (accepted, running, successful,
|
||||||
failed, results) (default is all)
|
failed, results) (default is all)
|
||||||
|
:param limit: number of jobs to return
|
||||||
|
:param offset: pagination offset
|
||||||
|
|
||||||
:returns: 'list` of jobs (identifier, status, process identifier)
|
:returns: dict of list of jobs (identifier, status, process identifier)
|
||||||
|
and numberMatched
|
||||||
"""
|
"""
|
||||||
|
|
||||||
with self._db() as db:
|
with self._db() as db:
|
||||||
jobs_list = db.all()
|
jobs_list = db.all()
|
||||||
|
|
||||||
return jobs_list
|
number_matched = len(jobs_list)
|
||||||
|
|
||||||
|
if offset:
|
||||||
|
jobs_list = jobs_list[offset:]
|
||||||
|
|
||||||
|
if limit:
|
||||||
|
jobs_list = jobs_list[:limit]
|
||||||
|
|
||||||
|
return {
|
||||||
|
'jobs': jobs_list,
|
||||||
|
'numberMatched': number_matched
|
||||||
|
}
|
||||||
|
|
||||||
def add_job(self, job_metadata: dict) -> str:
|
def add_job(self, job_metadata: dict) -> str:
|
||||||
"""
|
"""
|
||||||
@@ -196,8 +212,13 @@ class TinyDBManager(BaseManager):
|
|||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
location = Path(location)
|
location = Path(location)
|
||||||
with location.open('r', encoding='utf-8') as filehandler:
|
if mimetype in (None, FORMAT_TYPES[F_JSON],
|
||||||
result = json.load(filehandler)
|
FORMAT_TYPES[F_JSONLD]):
|
||||||
|
with location.open('r', encoding='utf-8') as filehandler:
|
||||||
|
result = json.load(filehandler)
|
||||||
|
else:
|
||||||
|
with location.open('rb') as filehandler:
|
||||||
|
result = filehandler.read()
|
||||||
except (TypeError, FileNotFoundError, json.JSONDecodeError):
|
except (TypeError, FileNotFoundError, json.JSONDecodeError):
|
||||||
raise JobResultNotFoundError()
|
raise JobResultNotFoundError()
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ class BaseProvider:
|
|||||||
self.title_field = provider_def.get('title_field')
|
self.title_field = provider_def.get('title_field')
|
||||||
self.properties = provider_def.get('properties', [])
|
self.properties = provider_def.get('properties', [])
|
||||||
self.file_types = provider_def.get('file_types', [])
|
self.file_types = provider_def.get('file_types', [])
|
||||||
self.fields = {}
|
self._fields = {}
|
||||||
self.filename = None
|
self.filename = None
|
||||||
|
|
||||||
# for coverage providers
|
# for coverage providers
|
||||||
@@ -85,13 +85,31 @@ class BaseProvider:
|
|||||||
"""
|
"""
|
||||||
Get provider field information (names, types)
|
Get provider field information (names, types)
|
||||||
|
|
||||||
Example response: {'field1': 'string', 'field2': 'number'}}
|
Example response:
|
||||||
|
{'field1': {'type': 'string'}, 'field2': {'type': 'number'}}
|
||||||
|
|
||||||
:returns: dict of field names and their associated JSON Schema types
|
:returns: dict of field names and their associated JSON Schema types
|
||||||
"""
|
"""
|
||||||
|
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def fields(self) -> dict:
|
||||||
|
"""
|
||||||
|
Store provider field information (names, types)
|
||||||
|
|
||||||
|
Example response:
|
||||||
|
{'field1': {'type': 'string'}, 'field2': {'type': 'number'}}
|
||||||
|
|
||||||
|
:returns: dict of dicts (field names and their
|
||||||
|
associated JSON Schema definitions)
|
||||||
|
"""
|
||||||
|
|
||||||
|
if hasattr(self, '_fields'):
|
||||||
|
return self._fields
|
||||||
|
else:
|
||||||
|
return self.get_fields()
|
||||||
|
|
||||||
def get_schema(self, schema_type: SchemaType = SchemaType.item):
|
def get_schema(self, schema_type: SchemaType = SchemaType.item):
|
||||||
"""
|
"""
|
||||||
Get provider schema model
|
Get provider schema model
|
||||||
|
|||||||
@@ -29,10 +29,14 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from pygeoapi.provider.base import BaseProvider
|
from pygeoapi.provider.base import BaseProvider, ProviderInvalidDataError
|
||||||
|
|
||||||
LOGGER = logging.getLogger(__name__)
|
LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
EDR_QUERY_TYPES = ['position', 'radius', 'area', 'cube',
|
||||||
|
'trajectory', 'corridor', 'items',
|
||||||
|
'locations', 'instances']
|
||||||
|
|
||||||
|
|
||||||
class BaseEDRProvider(BaseProvider):
|
class BaseEDRProvider(BaseProvider):
|
||||||
"""Base EDR Provider"""
|
"""Base EDR Provider"""
|
||||||
@@ -55,6 +59,11 @@ class BaseEDRProvider(BaseProvider):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def register(cls):
|
def register(cls):
|
||||||
def inner(fn):
|
def inner(fn):
|
||||||
|
if fn.__name__ not in EDR_QUERY_TYPES:
|
||||||
|
msg = 'Invalid EDR Query type'
|
||||||
|
LOGGER.error(msg)
|
||||||
|
raise ProviderInvalidDataError(msg)
|
||||||
|
|
||||||
cls.query_types.append(fn.__name__)
|
cls.query_types.append(fn.__name__)
|
||||||
return fn
|
return fn
|
||||||
return inner
|
return inner
|
||||||
|
|||||||
+22
-23
@@ -54,7 +54,7 @@ class CSVProvider(BaseProvider):
|
|||||||
super().__init__(provider_def)
|
super().__init__(provider_def)
|
||||||
self.geometry_x = provider_def['geometry']['x_field']
|
self.geometry_x = provider_def['geometry']['x_field']
|
||||||
self.geometry_y = provider_def['geometry']['y_field']
|
self.geometry_y = provider_def['geometry']['y_field']
|
||||||
self.fields = self.get_fields()
|
self.get_fields()
|
||||||
|
|
||||||
def get_fields(self):
|
def get_fields(self):
|
||||||
"""
|
"""
|
||||||
@@ -62,32 +62,31 @@ class CSVProvider(BaseProvider):
|
|||||||
|
|
||||||
:returns: dict of fields
|
:returns: dict of fields
|
||||||
"""
|
"""
|
||||||
|
if not self._fields:
|
||||||
|
LOGGER.debug('Treating all columns as string types')
|
||||||
|
with open(self.data) as ff:
|
||||||
|
LOGGER.debug('Serializing DictReader')
|
||||||
|
data_ = csv.DictReader(ff)
|
||||||
|
|
||||||
LOGGER.debug('Treating all columns as string types')
|
row = next(data_)
|
||||||
with open(self.data) as ff:
|
|
||||||
LOGGER.debug('Serializing DictReader')
|
|
||||||
data_ = csv.DictReader(ff)
|
|
||||||
fields = {}
|
|
||||||
|
|
||||||
row = next(data_)
|
for key, value in row.items():
|
||||||
|
LOGGER.debug(f'key: {key}, value: {value}')
|
||||||
|
value2 = get_typed_value(value)
|
||||||
|
if key in [self.geometry_x, self.geometry_y]:
|
||||||
|
continue
|
||||||
|
if key == self.id_field:
|
||||||
|
type_ = 'string'
|
||||||
|
elif isinstance(value2, float):
|
||||||
|
type_ = 'number'
|
||||||
|
elif isinstance(value2, int):
|
||||||
|
type_ = 'integer'
|
||||||
|
else:
|
||||||
|
type_ = 'string'
|
||||||
|
|
||||||
for key, value in row.items():
|
self._fields[key] = {'type': type_}
|
||||||
LOGGER.debug(f'key: {key}, value: {value}')
|
|
||||||
value2 = get_typed_value(value)
|
|
||||||
if key in [self.geometry_x, self.geometry_y]:
|
|
||||||
continue
|
|
||||||
if key == self.id_field:
|
|
||||||
type_ = 'string'
|
|
||||||
elif isinstance(value2, float):
|
|
||||||
type_ = 'number'
|
|
||||||
elif isinstance(value2, int):
|
|
||||||
type_ = 'integer'
|
|
||||||
else:
|
|
||||||
type_ = 'string'
|
|
||||||
|
|
||||||
fields[key] = {'type': type_}
|
return self._fields
|
||||||
|
|
||||||
return fields
|
|
||||||
|
|
||||||
def _load(self, offset=0, limit=10, resulttype='results',
|
def _load(self, offset=0, limit=10, resulttype='results',
|
||||||
identifier=None, bbox=[], datetime_=None, properties=[],
|
identifier=None, bbox=[], datetime_=None, properties=[],
|
||||||
|
|||||||
@@ -69,7 +69,8 @@ class CSWFacadeProvider(BaseProvider):
|
|||||||
'language': ('dc:language', 'language')
|
'language': ('dc:language', 'language')
|
||||||
}
|
}
|
||||||
|
|
||||||
self.fields = self.get_fields()
|
self._fields = {}
|
||||||
|
self.get_fields()
|
||||||
|
|
||||||
def get_fields(self):
|
def get_fields(self):
|
||||||
"""
|
"""
|
||||||
@@ -78,17 +79,17 @@ class CSWFacadeProvider(BaseProvider):
|
|||||||
:returns: dict of fields
|
:returns: dict of fields
|
||||||
"""
|
"""
|
||||||
|
|
||||||
fields = {}
|
if not self._fields:
|
||||||
date_fields = ['date', 'created', 'updated']
|
date_fields = ['date', 'created', 'updated']
|
||||||
|
|
||||||
for key in self.record_mappings.keys():
|
for key in self.record_mappings.keys():
|
||||||
LOGGER.debug(f'key: {key}')
|
LOGGER.debug(f'key: {key}')
|
||||||
fields[key] = {'type': 'string'}
|
self._fields[key] = {'type': 'string'}
|
||||||
|
|
||||||
if key in date_fields:
|
if key in date_fields:
|
||||||
fields[key]['format'] = 'date-time'
|
self._fields[key]['format'] = 'date-time'
|
||||||
|
|
||||||
return fields
|
return self._fields
|
||||||
|
|
||||||
@crs_transform
|
@crs_transform
|
||||||
def query(self, offset=0, limit=10, resulttype='results',
|
def query(self, offset=0, limit=10, resulttype='results',
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ class ElasticsearchProvider(BaseProvider):
|
|||||||
|
|
||||||
LOGGER.debug('Grabbing field information')
|
LOGGER.debug('Grabbing field information')
|
||||||
try:
|
try:
|
||||||
self.fields = self.get_fields()
|
self.get_fields()
|
||||||
except exceptions.NotFoundError as err:
|
except exceptions.NotFoundError as err:
|
||||||
LOGGER.error(err)
|
LOGGER.error(err)
|
||||||
raise ProviderQueryError(err)
|
raise ProviderQueryError(err)
|
||||||
@@ -98,38 +98,40 @@ class ElasticsearchProvider(BaseProvider):
|
|||||||
|
|
||||||
:returns: dict of fields
|
:returns: dict of fields
|
||||||
"""
|
"""
|
||||||
|
if not self._fields:
|
||||||
|
ii = self.es.indices.get(index=self.index_name,
|
||||||
|
allow_no_indices=False)
|
||||||
|
|
||||||
fields_ = {}
|
LOGGER.debug(f'Response: {ii}')
|
||||||
ii = self.es.indices.get(index=self.index_name, allow_no_indices=False)
|
try:
|
||||||
|
if '*' not in self.index_name:
|
||||||
LOGGER.debug(f'Response: {ii}')
|
mappings = ii[self.index_name]['mappings']
|
||||||
try:
|
p = mappings['properties']['properties']
|
||||||
if '*' not in self.index_name:
|
|
||||||
p = ii[self.index_name]['mappings']['properties']['properties']
|
|
||||||
else:
|
|
||||||
LOGGER.debug('Wildcard index; setting from first match')
|
|
||||||
index_name_ = list(ii.keys())[0]
|
|
||||||
p = ii[index_name_]['mappings']['properties']['properties']
|
|
||||||
except KeyError:
|
|
||||||
LOGGER.warning('Trying for alias')
|
|
||||||
alias_name = next(iter(ii))
|
|
||||||
p = ii[alias_name]['mappings']['properties']['properties']
|
|
||||||
except IndexError:
|
|
||||||
LOGGER.warning('could not get fields; returning empty set')
|
|
||||||
return {}
|
|
||||||
|
|
||||||
for k, v in p['properties'].items():
|
|
||||||
if 'type' in v:
|
|
||||||
if v['type'] == 'text':
|
|
||||||
fields_[k] = {'type': 'string'}
|
|
||||||
elif v['type'] == 'date':
|
|
||||||
fields_[k] = {'type': 'string', 'format': 'date'}
|
|
||||||
elif v['type'] in ('float', 'long'):
|
|
||||||
fields_[k] = {'type': 'number', 'format': v['type']}
|
|
||||||
else:
|
else:
|
||||||
fields_[k] = {'type': v['type']}
|
LOGGER.debug('Wildcard index; setting from first match')
|
||||||
|
index_name_ = list(ii.keys())[0]
|
||||||
|
p = ii[index_name_]['mappings']['properties']['properties']
|
||||||
|
except KeyError:
|
||||||
|
LOGGER.warning('Trying for alias')
|
||||||
|
alias_name = next(iter(ii))
|
||||||
|
p = ii[alias_name]['mappings']['properties']['properties']
|
||||||
|
except IndexError:
|
||||||
|
LOGGER.warning('could not get fields; returning empty set')
|
||||||
|
return {}
|
||||||
|
|
||||||
return fields_
|
for k, v in p['properties'].items():
|
||||||
|
if 'type' in v:
|
||||||
|
if v['type'] == 'text':
|
||||||
|
self._fields[k] = {'type': 'string'}
|
||||||
|
elif v['type'] == 'date':
|
||||||
|
self._fields[k] = {'type': 'string', 'format': 'date'}
|
||||||
|
elif v['type'] in ('float', 'long'):
|
||||||
|
self._fields[k] = {'type': 'number',
|
||||||
|
'format': v['type']}
|
||||||
|
else:
|
||||||
|
self._fields[k] = {'type': v['type']}
|
||||||
|
|
||||||
|
return self._fields
|
||||||
|
|
||||||
@crs_transform
|
@crs_transform
|
||||||
def query(self, offset=0, limit=10, resulttype='results',
|
def query(self, offset=0, limit=10, resulttype='results',
|
||||||
|
|||||||
+13
-12
@@ -62,24 +62,25 @@ class TabledapProvider(BaseProvider):
|
|||||||
|
|
||||||
LOGGER.debug('Setting provider query filters')
|
LOGGER.debug('Setting provider query filters')
|
||||||
self.filters = self.options.get('filters')
|
self.filters = self.options.get('filters')
|
||||||
self.fields = self.get_fields()
|
self.get_fields()
|
||||||
|
|
||||||
def get_fields(self):
|
def get_fields(self):
|
||||||
LOGGER.debug('Fetching one feature for field definitions')
|
if not self._fields:
|
||||||
properties = self.query(limit=1)['features'][0]['properties']
|
LOGGER.debug('Fetching one feature for field definitions')
|
||||||
|
properties = self.query(limit=1)['features'][0]['properties']
|
||||||
|
|
||||||
for key, value in properties.items():
|
for key, value in properties.items():
|
||||||
LOGGER.debug(f'Field: {key}={value}')
|
LOGGER.debug(f'Field: {key}={value}')
|
||||||
|
|
||||||
data_type = type(value).__name__
|
data_type = type(value).__name__
|
||||||
|
|
||||||
if data_type == 'str':
|
if data_type == 'str':
|
||||||
data_type = 'string'
|
data_type = 'string'
|
||||||
if data_type == 'float':
|
if data_type == 'float':
|
||||||
data_type = 'number'
|
data_type = 'number'
|
||||||
properties[key] = {'type': data_type}
|
self._fields[key] = {'type': data_type}
|
||||||
|
|
||||||
return properties
|
return self._fields
|
||||||
|
|
||||||
@crs_transform
|
@crs_transform
|
||||||
def query(self, offset=0, limit=10, resulttype='results',
|
def query(self, offset=0, limit=10, resulttype='results',
|
||||||
|
|||||||
@@ -62,8 +62,9 @@ class ESRIServiceProvider(BaseProvider):
|
|||||||
self.crs = provider_def.get('crs', '4326')
|
self.crs = provider_def.get('crs', '4326')
|
||||||
self.username = provider_def.get('username')
|
self.username = provider_def.get('username')
|
||||||
self.password = provider_def.get('password')
|
self.password = provider_def.get('password')
|
||||||
|
self.token_url = provider_def.get('token_service', ARCGIS_URL)
|
||||||
|
self.token_referer = provider_def.get('referer', GENERATE_TOKEN_URL)
|
||||||
self.token = None
|
self.token = None
|
||||||
|
|
||||||
self.session = Session()
|
self.session = Session()
|
||||||
|
|
||||||
self.login()
|
self.login()
|
||||||
@@ -76,7 +77,7 @@ class ESRIServiceProvider(BaseProvider):
|
|||||||
:returns: `dict` of fields
|
:returns: `dict` of fields
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if not self.fields:
|
if not self._fields:
|
||||||
# Load fields
|
# Load fields
|
||||||
params = {'f': 'pjson'}
|
params = {'f': 'pjson'}
|
||||||
resp = self.get_response(self.data, params=params)
|
resp = self.get_response(self.data, params=params)
|
||||||
@@ -102,9 +103,9 @@ class ESRIServiceProvider(BaseProvider):
|
|||||||
raise ProviderTypeError(msg)
|
raise ProviderTypeError(msg)
|
||||||
|
|
||||||
for _ in resp['fields']:
|
for _ in resp['fields']:
|
||||||
self.fields.update({_['name']: {'type': _['type']}})
|
self._fields.update({_['name']: {'type': _['type']}})
|
||||||
|
|
||||||
return self.fields
|
return self._fields
|
||||||
|
|
||||||
@crs_transform
|
@crs_transform
|
||||||
def query(self, offset=0, limit=10, resulttype='results',
|
def query(self, offset=0, limit=10, resulttype='results',
|
||||||
@@ -194,16 +195,15 @@ class ESRIServiceProvider(BaseProvider):
|
|||||||
msg = 'Missing ESRI login information, not setting token'
|
msg = 'Missing ESRI login information, not setting token'
|
||||||
LOGGER.debug(msg)
|
LOGGER.debug(msg)
|
||||||
return
|
return
|
||||||
|
|
||||||
params = {
|
params = {
|
||||||
'f': 'pjson',
|
'f': 'pjson',
|
||||||
'username': self.username,
|
'username': self.username,
|
||||||
'password': self.password,
|
'password': self.password,
|
||||||
'referer': ARCGIS_URL
|
'referer': self.token_referer
|
||||||
}
|
}
|
||||||
|
|
||||||
LOGGER.debug('Logging in')
|
LOGGER.debug('Logging in')
|
||||||
with self.session.post(GENERATE_TOKEN_URL, data=params) as r:
|
with self.session.post(self.token_url, data=params) as r:
|
||||||
self.token = r.json().get('token')
|
self.token = r.json().get('token')
|
||||||
# https://enterprise.arcgis.com/en/server/latest/administer/windows/about-arcgis-tokens.htm
|
# https://enterprise.arcgis.com/en/server/latest/administer/windows/about-arcgis-tokens.htm
|
||||||
self.session.headers.update({
|
self.session.headers.update({
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ class GeoJSONProvider(BaseProvider):
|
|||||||
"""initializer"""
|
"""initializer"""
|
||||||
|
|
||||||
super().__init__(provider_def)
|
super().__init__(provider_def)
|
||||||
self.fields = self.get_fields()
|
self.get_fields()
|
||||||
|
|
||||||
def get_fields(self):
|
def get_fields(self):
|
||||||
"""
|
"""
|
||||||
@@ -77,23 +77,24 @@ class GeoJSONProvider(BaseProvider):
|
|||||||
:returns: dict of fields
|
:returns: dict of fields
|
||||||
"""
|
"""
|
||||||
|
|
||||||
fields = {}
|
if not self._fields:
|
||||||
LOGGER.debug('Treating all columns as string types')
|
LOGGER.debug('Treating all columns as string types')
|
||||||
if os.path.exists(self.data):
|
if os.path.exists(self.data):
|
||||||
with open(self.data) as src:
|
with open(self.data) as src:
|
||||||
data = json.loads(src.read())
|
data = json.loads(src.read())
|
||||||
for key, value in data['features'][0]['properties'].items():
|
for key, value in data['features'][0]['properties'].items():
|
||||||
if isinstance(value, float):
|
if isinstance(value, float):
|
||||||
type_ = 'number'
|
type_ = 'number'
|
||||||
elif isinstance(value, int):
|
elif isinstance(value, int):
|
||||||
type_ = 'integer'
|
type_ = 'integer'
|
||||||
else:
|
else:
|
||||||
type_ = 'string'
|
type_ = 'string'
|
||||||
|
|
||||||
fields[key] = {'type': type_}
|
self._fields[key] = {'type': type_}
|
||||||
else:
|
else:
|
||||||
LOGGER.warning(f'File {self.data} does not exist.')
|
LOGGER.warning(f'File {self.data} does not exist.')
|
||||||
return fields
|
|
||||||
|
return self._fields
|
||||||
|
|
||||||
def _load(self, skip_geometry=None, properties=[], select_properties=[]):
|
def _load(self, skip_geometry=None, properties=[], select_properties=[]):
|
||||||
"""Load and validate the source GeoJSON file
|
"""Load and validate the source GeoJSON file
|
||||||
|
|||||||
+16
-17
@@ -66,7 +66,7 @@ class MongoProvider(BaseProvider):
|
|||||||
self.featuredb = dbclient.get_default_database()
|
self.featuredb = dbclient.get_default_database()
|
||||||
self.collection = provider_def['collection']
|
self.collection = provider_def['collection']
|
||||||
self.featuredb[self.collection].create_index([("geometry", GEOSPHERE)])
|
self.featuredb[self.collection].create_index([("geometry", GEOSPHERE)])
|
||||||
self.fields = self.get_fields()
|
self.get_fields()
|
||||||
|
|
||||||
def get_fields(self):
|
def get_fields(self):
|
||||||
"""
|
"""
|
||||||
@@ -75,25 +75,24 @@ class MongoProvider(BaseProvider):
|
|||||||
:returns: dict of fields
|
:returns: dict of fields
|
||||||
"""
|
"""
|
||||||
|
|
||||||
pipeline = [
|
if not self._fields:
|
||||||
{"$project": {"properties": 1}},
|
pipeline = [
|
||||||
{"$unwind": "$properties"},
|
{"$project": {"properties": 1}},
|
||||||
{"$group": {"_id": "$properties", "count": {"$sum": 1}}},
|
{"$unwind": "$properties"},
|
||||||
{"$project": {"_id": 1}}
|
{"$group": {"_id": "$properties", "count": {"$sum": 1}}},
|
||||||
]
|
{"$project": {"_id": 1}}
|
||||||
|
]
|
||||||
|
|
||||||
result = list(self.featuredb[self.collection].aggregate(pipeline))
|
result = list(self.featuredb[self.collection].aggregate(pipeline))
|
||||||
|
|
||||||
# prepare a dictionary with fields
|
# prepare a dictionary with fields
|
||||||
# set the field type to 'string'.
|
# set the field type to 'string'.
|
||||||
# by operating without a schema, mongo can query any data type.
|
# by operating without a schema, mongo can query any data type.
|
||||||
fields = {}
|
for i in result:
|
||||||
|
for key in result[0]['_id'].keys():
|
||||||
|
self._fields[key] = {'type': 'string'}
|
||||||
|
|
||||||
for i in result:
|
return self._fields
|
||||||
for key in result[0]['_id'].keys():
|
|
||||||
fields[key] = {'type': 'string'}
|
|
||||||
|
|
||||||
return fields
|
|
||||||
|
|
||||||
def _get_feature_list(self, filterObj, sortList=[], skip=0, maxitems=1,
|
def _get_feature_list(self, filterObj, sortList=[], skip=0, maxitems=1,
|
||||||
skip_geometry=False):
|
skip_geometry=False):
|
||||||
|
|||||||
+30
-30
@@ -188,7 +188,7 @@ class OGRProvider(BaseProvider):
|
|||||||
self.conn = None
|
self.conn = None
|
||||||
|
|
||||||
LOGGER.debug('Grabbing field information')
|
LOGGER.debug('Grabbing field information')
|
||||||
self.fields = self.get_fields()
|
self.get_fields()
|
||||||
|
|
||||||
def _list_open_options(self):
|
def _list_open_options(self):
|
||||||
return [
|
return [
|
||||||
@@ -260,43 +260,43 @@ class OGRProvider(BaseProvider):
|
|||||||
:returns: dict of fields
|
:returns: dict of fields
|
||||||
"""
|
"""
|
||||||
|
|
||||||
fields = {}
|
if not self._fields:
|
||||||
try:
|
try:
|
||||||
layer_defn = self._get_layer().GetLayerDefn()
|
layer_defn = self._get_layer().GetLayerDefn()
|
||||||
for fld in range(layer_defn.GetFieldCount()):
|
for fld in range(layer_defn.GetFieldCount()):
|
||||||
field_defn = layer_defn.GetFieldDefn(fld)
|
field_defn = layer_defn.GetFieldDefn(fld)
|
||||||
fieldName = field_defn.GetName()
|
fieldName = field_defn.GetName()
|
||||||
fieldTypeCode = field_defn.GetType()
|
fieldTypeCode = field_defn.GetType()
|
||||||
fieldType = field_defn.GetFieldTypeName(fieldTypeCode)
|
fieldType = field_defn.GetFieldTypeName(fieldTypeCode)
|
||||||
|
|
||||||
fieldName2 = fieldType.lower()
|
fieldName2 = fieldType.lower()
|
||||||
|
|
||||||
if fieldName2 == 'integer64':
|
if fieldName2 == 'integer64':
|
||||||
fieldName2 = 'integer'
|
fieldName2 = 'integer'
|
||||||
elif fieldName2 == 'real':
|
elif fieldName2 == 'real':
|
||||||
fieldName2 = 'number'
|
fieldName2 = 'number'
|
||||||
|
|
||||||
fields[fieldName] = {'type': fieldName2}
|
self._fields[fieldName] = {'type': fieldName2}
|
||||||
|
|
||||||
if fieldName2 == 'datetime':
|
if fieldName2 == 'datetime':
|
||||||
fields[fieldName] = {
|
self._fields[fieldName] = {
|
||||||
'type': 'string',
|
'type': 'string',
|
||||||
'format': 'date-time'
|
'format': 'date-time'
|
||||||
}
|
}
|
||||||
|
|
||||||
# fieldWidth = layer_defn.GetFieldDefn(fld).GetWidth()
|
# fieldWidth = layer_defn.GetFieldDefn(fld).GetWidth()
|
||||||
# GetPrecision = layer_defn.GetFieldDefn(fld).GetPrecision()
|
# GetPrecision = layer_defn.GetFieldDefn(fld).GetPrecision() # noqa
|
||||||
|
|
||||||
except RuntimeError as err:
|
except RuntimeError as err:
|
||||||
LOGGER.error(err)
|
LOGGER.error(err)
|
||||||
raise ProviderConnectionError(err)
|
raise ProviderConnectionError(err)
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
LOGGER.error(err)
|
LOGGER.error(err)
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
self._close()
|
self._close()
|
||||||
|
|
||||||
return fields
|
return self._fields
|
||||||
|
|
||||||
def query(self, offset=0, limit=10, resulttype='results',
|
def query(self, offset=0, limit=10, resulttype='results',
|
||||||
bbox=[], datetime_=None, properties=[], sortby=[],
|
bbox=[], datetime_=None, properties=[], sortby=[],
|
||||||
|
|||||||
@@ -0,0 +1,742 @@
|
|||||||
|
# =================================================================
|
||||||
|
#
|
||||||
|
# Authors: Tom Kralidis <tomkralidis@gmail.com>
|
||||||
|
# Francesco Bartoli <xbartolone@gmail.com>
|
||||||
|
#
|
||||||
|
# Copyright (c) 2024 Tom Kralidis
|
||||||
|
# Copyright (c) 2024 Francesco Bartoli
|
||||||
|
#
|
||||||
|
# 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 typing import Dict
|
||||||
|
from collections import OrderedDict
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from opensearchpy import OpenSearch, helpers
|
||||||
|
from opensearch_dsl import Search, Q
|
||||||
|
|
||||||
|
from pygeoapi.provider.base import (BaseProvider, ProviderConnectionError,
|
||||||
|
ProviderQueryError,
|
||||||
|
ProviderItemNotFoundError)
|
||||||
|
from pygeoapi.models.cql import CQLModel, get_next_node
|
||||||
|
from pygeoapi.util import get_envelope, crs_transform
|
||||||
|
|
||||||
|
|
||||||
|
LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class OpenSearchProvider(BaseProvider):
|
||||||
|
"""OpenSearch Provider"""
|
||||||
|
|
||||||
|
def __init__(self, provider_def):
|
||||||
|
"""
|
||||||
|
Initialize object
|
||||||
|
|
||||||
|
:param provider_def: provider definition
|
||||||
|
|
||||||
|
:returns: pygeoapi.provider.opensearch_.OpenSearchProvider
|
||||||
|
"""
|
||||||
|
|
||||||
|
super().__init__(provider_def)
|
||||||
|
|
||||||
|
self.select_properties = []
|
||||||
|
|
||||||
|
self.os_host, self.index_name = self.data.rsplit('/', 1)
|
||||||
|
|
||||||
|
LOGGER.debug('Setting OpenSearch properties')
|
||||||
|
|
||||||
|
LOGGER.debug(f'host: {self.os_host}')
|
||||||
|
LOGGER.debug(f'index: {self.index_name}')
|
||||||
|
|
||||||
|
LOGGER.debug('Connecting to OpenSearch')
|
||||||
|
self.os_ = OpenSearch(self.os_host, verify_certs=0)
|
||||||
|
if not self.os_.ping():
|
||||||
|
msg = f'Cannot connect to OpenSearch: {self.os_host}'
|
||||||
|
LOGGER.error(msg)
|
||||||
|
raise ProviderConnectionError(msg)
|
||||||
|
|
||||||
|
LOGGER.debug('Determining OpenSearch version')
|
||||||
|
v = self.os_.info()['version']['number'][:3]
|
||||||
|
LOGGER.debug(f'OpenSearch version: {v}')
|
||||||
|
|
||||||
|
LOGGER.debug('Grabbing field information')
|
||||||
|
try:
|
||||||
|
self.get_fields()
|
||||||
|
except Exception as err:
|
||||||
|
LOGGER.error(err)
|
||||||
|
raise ProviderQueryError(err)
|
||||||
|
|
||||||
|
def get_fields(self):
|
||||||
|
"""
|
||||||
|
Get provider field information (names, types)
|
||||||
|
|
||||||
|
:returns: dict of fields
|
||||||
|
"""
|
||||||
|
if not self._fields:
|
||||||
|
ii = self.os_.indices.get(index=self.index_name,
|
||||||
|
allow_no_indices=False)
|
||||||
|
|
||||||
|
LOGGER.debug(f'Response: {ii}')
|
||||||
|
try:
|
||||||
|
if '*' not in self.index_name:
|
||||||
|
mappings = ii[self.index_name]['mappings']
|
||||||
|
p = mappings['properties']['properties']
|
||||||
|
else:
|
||||||
|
LOGGER.debug('Wildcard index; setting from first match')
|
||||||
|
index_name_ = list(ii.keys())[0]
|
||||||
|
p = ii[index_name_]['mappings']['properties']['properties']
|
||||||
|
except KeyError:
|
||||||
|
LOGGER.warning('Trying for alias')
|
||||||
|
alias_name = next(iter(ii))
|
||||||
|
p = ii[alias_name]['mappings']['properties']['properties']
|
||||||
|
except IndexError:
|
||||||
|
LOGGER.warning('could not get fields; returning empty set')
|
||||||
|
return {}
|
||||||
|
|
||||||
|
for k, v in p['properties'].items():
|
||||||
|
if 'type' in v:
|
||||||
|
if v['type'] == 'text':
|
||||||
|
self._fields[k] = {'type': 'string'}
|
||||||
|
elif v['type'] == 'date':
|
||||||
|
self._fields[k] = {'type': 'string', 'format': 'date'}
|
||||||
|
elif v['type'] in ('float', 'long'):
|
||||||
|
self._fields[k] = {'type': 'number',
|
||||||
|
'format': v['type']}
|
||||||
|
else:
|
||||||
|
self._fields[k] = {'type': v['type']}
|
||||||
|
|
||||||
|
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,
|
||||||
|
filterq=None, **kwargs):
|
||||||
|
"""
|
||||||
|
query OpenSearch index
|
||||||
|
|
||||||
|
:param offset: starting record to return (default 0)
|
||||||
|
:param limit: number of records to return (default 10)
|
||||||
|
:param resulttype: return results or hit limit (default results)
|
||||||
|
:param bbox: bounding box [minx,miny,maxx,maxy]
|
||||||
|
:param datetime_: temporal (datestamp or extent)
|
||||||
|
:param properties: list of tuples (name, value)
|
||||||
|
:param sortby: list of dicts (property, order)
|
||||||
|
: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 filterq: filter object
|
||||||
|
|
||||||
|
:returns: dict of 0..n GeoJSON features
|
||||||
|
"""
|
||||||
|
|
||||||
|
self.select_properties = select_properties
|
||||||
|
|
||||||
|
query = {'track_total_hits': True, 'query': {'bool': {'filter': []}}}
|
||||||
|
filter_ = []
|
||||||
|
|
||||||
|
feature_collection = {
|
||||||
|
'type': 'FeatureCollection',
|
||||||
|
'features': []
|
||||||
|
}
|
||||||
|
|
||||||
|
if resulttype == 'hits':
|
||||||
|
LOGGER.debug('hits only specified')
|
||||||
|
limit = 0
|
||||||
|
|
||||||
|
if bbox:
|
||||||
|
LOGGER.debug('processing bbox parameter')
|
||||||
|
minx, miny, maxx, maxy = bbox
|
||||||
|
bbox_filter = {
|
||||||
|
'geo_shape': {
|
||||||
|
'geometry': {
|
||||||
|
'shape': {
|
||||||
|
'type': 'envelope',
|
||||||
|
'coordinates': [[minx, maxy], [maxx, miny]]
|
||||||
|
},
|
||||||
|
'relation': 'intersects'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
query['query']['bool']['filter'].append(bbox_filter)
|
||||||
|
|
||||||
|
if datetime_ is not None:
|
||||||
|
LOGGER.debug('processing datetime parameter')
|
||||||
|
if self.time_field is None:
|
||||||
|
LOGGER.error('time_field not enabled for collection')
|
||||||
|
raise ProviderQueryError()
|
||||||
|
|
||||||
|
time_field = self.mask_prop(self.time_field)
|
||||||
|
|
||||||
|
if '/' in datetime_: # envelope
|
||||||
|
LOGGER.debug('detected time range')
|
||||||
|
time_begin, time_end = datetime_.split('/')
|
||||||
|
|
||||||
|
range_ = {
|
||||||
|
'range': {
|
||||||
|
time_field: {
|
||||||
|
'gte': time_begin,
|
||||||
|
'lte': time_end
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if time_begin == '..':
|
||||||
|
range_['range'][time_field].pop('gte')
|
||||||
|
elif time_end == '..':
|
||||||
|
range_['range'][time_field].pop('lte')
|
||||||
|
|
||||||
|
filter_.append(range_)
|
||||||
|
|
||||||
|
else: # time instant
|
||||||
|
LOGGER.debug('detected time instant')
|
||||||
|
filter_.append({'match': {time_field: datetime_}})
|
||||||
|
|
||||||
|
LOGGER.debug(filter_)
|
||||||
|
query['query']['bool']['filter'].append(*filter_)
|
||||||
|
|
||||||
|
if properties:
|
||||||
|
LOGGER.debug('processing properties')
|
||||||
|
for prop in properties:
|
||||||
|
prop_name = self.mask_prop(prop[0])
|
||||||
|
pf = {
|
||||||
|
'match': {
|
||||||
|
prop_name: {
|
||||||
|
'query': prop[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
query['query']['bool']['filter'].append(pf)
|
||||||
|
|
||||||
|
if '|' not in prop[1]:
|
||||||
|
pf['match'][prop_name]['minimum_should_match'] = '100%'
|
||||||
|
|
||||||
|
if sortby:
|
||||||
|
LOGGER.debug('processing sortby')
|
||||||
|
query['sort'] = []
|
||||||
|
for sort in sortby:
|
||||||
|
LOGGER.debug(f'processing sort object: {sort}')
|
||||||
|
|
||||||
|
sp = sort['property']
|
||||||
|
|
||||||
|
if (self.fields[sp]['type'] == 'string'
|
||||||
|
and self.fields[sp].get('format') != 'date'):
|
||||||
|
LOGGER.debug('setting OpenSearch .raw on property')
|
||||||
|
sort_property = f'{self.mask_prop(sp)}.raw'
|
||||||
|
else:
|
||||||
|
sort_property = self.mask_prop(sp)
|
||||||
|
|
||||||
|
sort_order = 'asc'
|
||||||
|
if sort['order'] == '-':
|
||||||
|
sort_order = 'desc'
|
||||||
|
|
||||||
|
sort_ = {
|
||||||
|
sort_property: {
|
||||||
|
'order': sort_order
|
||||||
|
}
|
||||||
|
}
|
||||||
|
query['sort'].append(sort_)
|
||||||
|
|
||||||
|
if q is not None:
|
||||||
|
LOGGER.debug('Adding free-text search')
|
||||||
|
query['query']['bool']['must'] = {'query_string': {'query': q}}
|
||||||
|
|
||||||
|
query['_source'] = {
|
||||||
|
'excludes': [
|
||||||
|
'properties._metadata-payload',
|
||||||
|
'properties._metadata-schema',
|
||||||
|
'properties._metadata-format'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.properties or self.select_properties:
|
||||||
|
LOGGER.debug('filtering properties')
|
||||||
|
|
||||||
|
all_properties = self.get_properties()
|
||||||
|
|
||||||
|
query['_source'] = {
|
||||||
|
'includes': list(map(self.mask_prop, all_properties))
|
||||||
|
}
|
||||||
|
|
||||||
|
query['_source']['includes'].append('id')
|
||||||
|
query['_source']['includes'].append('type')
|
||||||
|
query['_source']['includes'].append('geometry')
|
||||||
|
|
||||||
|
if skip_geometry:
|
||||||
|
LOGGER.debug('excluding geometry')
|
||||||
|
try:
|
||||||
|
query['_source']['excludes'] = ['geometry']
|
||||||
|
except KeyError:
|
||||||
|
query['_source'] = {'excludes': ['geometry']}
|
||||||
|
try:
|
||||||
|
LOGGER.debug('querying OpenSearch')
|
||||||
|
if filterq:
|
||||||
|
LOGGER.debug(f'adding cql object: {filterq.json()}')
|
||||||
|
query = update_query(input_query=query, cql=filterq)
|
||||||
|
LOGGER.debug(json.dumps(query, indent=4))
|
||||||
|
|
||||||
|
LOGGER.debug('Testing for OpenSearch scrolling')
|
||||||
|
if offset + limit > 10000:
|
||||||
|
gen = helpers.scan(client=self.os_, query=query,
|
||||||
|
preserve_order=True,
|
||||||
|
index=self.index_name)
|
||||||
|
results = {'hits': {'total': limit, 'hits': []}}
|
||||||
|
for i in range(offset + limit):
|
||||||
|
try:
|
||||||
|
if i >= offset:
|
||||||
|
results['hits']['hits'].append(next(gen))
|
||||||
|
else:
|
||||||
|
next(gen)
|
||||||
|
except StopIteration:
|
||||||
|
break
|
||||||
|
|
||||||
|
matched = len(results['hits']['hits']) + offset
|
||||||
|
returned = len(results['hits']['hits'])
|
||||||
|
else:
|
||||||
|
es_results = self.os_.search(index=self.index_name,
|
||||||
|
from_=offset, size=limit,
|
||||||
|
body=query)
|
||||||
|
results = es_results
|
||||||
|
matched = es_results['hits']['total']['value']
|
||||||
|
returned = len(es_results['hits']['hits'])
|
||||||
|
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
feature_collection['numberMatched'] = matched
|
||||||
|
|
||||||
|
if resulttype == 'hits':
|
||||||
|
return feature_collection
|
||||||
|
|
||||||
|
feature_collection['numberReturned'] = returned
|
||||||
|
|
||||||
|
LOGGER.debug('serializing features')
|
||||||
|
for feature in results['hits']['hits']:
|
||||||
|
feature_ = self.osdoc2geojson(feature)
|
||||||
|
feature_collection['features'].append(feature_)
|
||||||
|
|
||||||
|
return feature_collection
|
||||||
|
|
||||||
|
@crs_transform
|
||||||
|
def get(self, identifier, **kwargs):
|
||||||
|
"""
|
||||||
|
Get OpenSearch document by id
|
||||||
|
|
||||||
|
:param identifier: feature id
|
||||||
|
|
||||||
|
:returns: dict of single GeoJSON feature
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
LOGGER.debug(f'Fetching identifier {identifier}')
|
||||||
|
result = self.os_.get(index=self.index_name, id=identifier)
|
||||||
|
LOGGER.debug('Serializing feature')
|
||||||
|
feature_ = self.osdoc2geojson(result)
|
||||||
|
except Exception as err:
|
||||||
|
LOGGER.debug(f'Not found via OpenSearch id query: {err}')
|
||||||
|
LOGGER.debug('Trying via a real query')
|
||||||
|
|
||||||
|
query = {
|
||||||
|
'query': {
|
||||||
|
'bool': {
|
||||||
|
'filter': [{
|
||||||
|
'match_phrase': {
|
||||||
|
'_id': identifier
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LOGGER.debug(f'Query: {query}')
|
||||||
|
try:
|
||||||
|
result = self.os_search(index=self.index_name, **query)
|
||||||
|
if len(result['hits']['hits']) == 0:
|
||||||
|
LOGGER.error(err)
|
||||||
|
raise ProviderItemNotFoundError(err)
|
||||||
|
LOGGER.debug('Serializing feature')
|
||||||
|
feature_ = self.osdoc2geojson(result['hits']['hits'][0])
|
||||||
|
except Exception as err2:
|
||||||
|
LOGGER.error(err2)
|
||||||
|
raise ProviderItemNotFoundError(err2)
|
||||||
|
except Exception as err:
|
||||||
|
LOGGER.error(err)
|
||||||
|
return None
|
||||||
|
|
||||||
|
return feature_
|
||||||
|
|
||||||
|
def create(self, item):
|
||||||
|
"""
|
||||||
|
Create a new item
|
||||||
|
|
||||||
|
:param item: `dict` of new item
|
||||||
|
|
||||||
|
:returns: identifier of created item
|
||||||
|
"""
|
||||||
|
|
||||||
|
identifier, json_data = self._load_and_prepare_item(
|
||||||
|
item, accept_missing_identifier=True)
|
||||||
|
if identifier is None:
|
||||||
|
# If there is no incoming identifier, allocate a random one
|
||||||
|
identifier = str(uuid.uuid4())
|
||||||
|
json_data["id"] = identifier
|
||||||
|
|
||||||
|
LOGGER.debug(f'Inserting data with identifier {identifier}')
|
||||||
|
_ = self.os_.index(index=self.index_name, id=identifier,
|
||||||
|
body=json_data)
|
||||||
|
LOGGER.debug('Item added')
|
||||||
|
|
||||||
|
return identifier
|
||||||
|
|
||||||
|
def update(self, identifier, item):
|
||||||
|
"""
|
||||||
|
Updates an existing item
|
||||||
|
|
||||||
|
:param identifier: feature id
|
||||||
|
:param item: `dict` of partial or full item
|
||||||
|
|
||||||
|
:returns: `bool` of update result
|
||||||
|
"""
|
||||||
|
|
||||||
|
LOGGER.debug(f'Updating item {identifier}')
|
||||||
|
identifier, json_data = self._load_and_prepare_item(
|
||||||
|
item, identifier, raise_if_exists=False)
|
||||||
|
|
||||||
|
_ = self.os_index(index=self.index_name, id=identifier, body=json_data)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def delete(self, identifier):
|
||||||
|
"""
|
||||||
|
Deletes an existing item
|
||||||
|
|
||||||
|
:param identifier: item id
|
||||||
|
|
||||||
|
:returns: `bool` of deletion result
|
||||||
|
"""
|
||||||
|
|
||||||
|
LOGGER.debug(f'Deleting item {identifier}')
|
||||||
|
_ = self.os_delete(index=self.index_name, id=identifier)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def osdoc2geojson(self, doc):
|
||||||
|
"""
|
||||||
|
generate GeoJSON `dict` from OpenSearch document
|
||||||
|
:param doc: `dict` of OpenSearch document
|
||||||
|
|
||||||
|
:returns: GeoJSON `dict`
|
||||||
|
"""
|
||||||
|
|
||||||
|
feature_ = {}
|
||||||
|
feature_thinned = {}
|
||||||
|
|
||||||
|
LOGGER.debug('Fetching id and geometry from GeoJSON document')
|
||||||
|
feature_ = doc['_source']
|
||||||
|
|
||||||
|
try:
|
||||||
|
id_ = doc['_source']['properties'][self.id_field]
|
||||||
|
except KeyError as err:
|
||||||
|
LOGGER.debug(f'Missing field: {err}')
|
||||||
|
id_ = doc['_source'].get('id', doc['_id'])
|
||||||
|
|
||||||
|
feature_['id'] = id_
|
||||||
|
feature_['geometry'] = doc['_source'].get('geometry')
|
||||||
|
|
||||||
|
if self.properties or self.select_properties:
|
||||||
|
LOGGER.debug('Filtering properties')
|
||||||
|
all_properties = self.get_properties()
|
||||||
|
|
||||||
|
feature_thinned = {
|
||||||
|
'id': id_,
|
||||||
|
'type': feature_['type'],
|
||||||
|
'geometry': feature_.get('geometry'),
|
||||||
|
'properties': OrderedDict()
|
||||||
|
}
|
||||||
|
for p in all_properties:
|
||||||
|
try:
|
||||||
|
feature_thinned['properties'][p] = feature_['properties'][p] # noqa
|
||||||
|
except KeyError as err:
|
||||||
|
LOGGER.error(err)
|
||||||
|
raise ProviderQueryError()
|
||||||
|
|
||||||
|
if feature_thinned:
|
||||||
|
return feature_thinned
|
||||||
|
else:
|
||||||
|
return feature_
|
||||||
|
|
||||||
|
def mask_prop(self, property_name):
|
||||||
|
"""
|
||||||
|
generate property name based on OpenSearch backend setup
|
||||||
|
|
||||||
|
:param property_name: property name
|
||||||
|
|
||||||
|
:returns: masked property name
|
||||||
|
"""
|
||||||
|
|
||||||
|
return f'properties.{property_name}'
|
||||||
|
|
||||||
|
def get_properties(self):
|
||||||
|
all_properties = []
|
||||||
|
|
||||||
|
LOGGER.debug(f'configured properties: {self.properties}')
|
||||||
|
LOGGER.debug(f'selected properties: {self.select_properties}')
|
||||||
|
|
||||||
|
if not self.properties and not self.select_properties:
|
||||||
|
all_properties = self.get_fields()
|
||||||
|
if self.properties and self.select_properties:
|
||||||
|
all_properties = self.properties and self.select_properties
|
||||||
|
else:
|
||||||
|
all_properties = self.properties or self.select_properties
|
||||||
|
|
||||||
|
LOGGER.debug(f'resulting properties: {all_properties}')
|
||||||
|
return all_properties
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<OpenSearchProvider> {self.data}'
|
||||||
|
|
||||||
|
|
||||||
|
class OpenSearchCatalogueProvider(OpenSearchProvider):
|
||||||
|
"""OpenSearch Provider"""
|
||||||
|
|
||||||
|
def __init__(self, provider_def):
|
||||||
|
super().__init__(provider_def)
|
||||||
|
|
||||||
|
def _excludes(self):
|
||||||
|
return [
|
||||||
|
'properties._metadata-anytext'
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_fields(self):
|
||||||
|
fields = super().get_fields()
|
||||||
|
for i in self._excludes():
|
||||||
|
if i in fields:
|
||||||
|
del fields[i]
|
||||||
|
|
||||||
|
fields['q'] = {'type': 'string'}
|
||||||
|
|
||||||
|
return fields
|
||||||
|
|
||||||
|
def query(self, offset=0, limit=10, resulttype='results',
|
||||||
|
bbox=[], datetime_=None, properties=[], sortby=[],
|
||||||
|
select_properties=[], skip_geometry=False, q=None,
|
||||||
|
filterq=None, **kwargs):
|
||||||
|
|
||||||
|
records = super().query(
|
||||||
|
offset=offset, limit=limit,
|
||||||
|
resulttype=resulttype, bbox=bbox,
|
||||||
|
datetime_=datetime_, properties=properties,
|
||||||
|
sortby=sortby,
|
||||||
|
select_properties=select_properties,
|
||||||
|
skip_geometry=skip_geometry,
|
||||||
|
q=q)
|
||||||
|
|
||||||
|
return records
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<OpenSearchCatalogueProvider> {self.data}'
|
||||||
|
|
||||||
|
|
||||||
|
class OpenSearchQueryBuilder:
|
||||||
|
def __init__(self):
|
||||||
|
self._operation = None
|
||||||
|
self.must_value = {}
|
||||||
|
self.should_value = {}
|
||||||
|
self.mustnot_value = {}
|
||||||
|
self.filter_value = {}
|
||||||
|
|
||||||
|
def must(self, must_value):
|
||||||
|
self.must_value = must_value
|
||||||
|
return self
|
||||||
|
|
||||||
|
def should(self, should_value):
|
||||||
|
self.should_value = should_value
|
||||||
|
return self
|
||||||
|
|
||||||
|
def must_not(self, mustnot_value):
|
||||||
|
self.mustnot_value = mustnot_value
|
||||||
|
return self
|
||||||
|
|
||||||
|
def filter(self, filter_value):
|
||||||
|
self.filter_value = filter_value
|
||||||
|
return self
|
||||||
|
|
||||||
|
@property
|
||||||
|
def operation(self):
|
||||||
|
return self._operation
|
||||||
|
|
||||||
|
@operation.setter
|
||||||
|
def operation(self, value):
|
||||||
|
self._operation = value
|
||||||
|
|
||||||
|
def build(self):
|
||||||
|
if self.must_value:
|
||||||
|
must_clause = self.must_value or {}
|
||||||
|
if self.should_value:
|
||||||
|
should_clause = self.should_value or {}
|
||||||
|
if self.mustnot_value:
|
||||||
|
mustnot_clause = self.mustnot_value or {}
|
||||||
|
if self.filter_value:
|
||||||
|
filter_clause = self.filter_value or {}
|
||||||
|
else:
|
||||||
|
filter_clause = {}
|
||||||
|
|
||||||
|
# to figure out how to deal with logical operations
|
||||||
|
# return match_clause & range_clause
|
||||||
|
clauses = must_clause or should_clause or mustnot_clause
|
||||||
|
filters = filter_clause
|
||||||
|
if self.operation == 'and':
|
||||||
|
res = Q(
|
||||||
|
'bool',
|
||||||
|
must=[clause for clause in clauses],
|
||||||
|
filter=[filter for filter in filters])
|
||||||
|
elif self.operation == 'or':
|
||||||
|
res = Q(
|
||||||
|
'bool',
|
||||||
|
should=[clause for clause in clauses],
|
||||||
|
filter=[filter for filter in filters])
|
||||||
|
elif self.operation == 'not':
|
||||||
|
res = Q(
|
||||||
|
'bool',
|
||||||
|
must_not=[clause for clause in clauses],
|
||||||
|
filter=[filter for filter in filters])
|
||||||
|
else:
|
||||||
|
if filters:
|
||||||
|
res = Q(
|
||||||
|
'bool',
|
||||||
|
must=[clauses],
|
||||||
|
filter=[filters])
|
||||||
|
else:
|
||||||
|
res = Q(
|
||||||
|
'bool',
|
||||||
|
must=[clauses])
|
||||||
|
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
def _build_query(q, cql):
|
||||||
|
|
||||||
|
# this would be handled by the AST with the traverse of CQL model
|
||||||
|
op, node = get_next_node(cql.__root__)
|
||||||
|
q.operation = op
|
||||||
|
if isinstance(node, list):
|
||||||
|
query_list = []
|
||||||
|
for elem in node:
|
||||||
|
op, next_node = get_next_node(elem)
|
||||||
|
if not getattr(next_node, 'between', 0) == 0:
|
||||||
|
property = next_node.between.value.__root__.__root__.property
|
||||||
|
lower = next_node.between.lower.__root__.__root__
|
||||||
|
upper = next_node.between.upper.__root__.__root__
|
||||||
|
query_list.append(Q(
|
||||||
|
{
|
||||||
|
'range':
|
||||||
|
{
|
||||||
|
f'{property}': {
|
||||||
|
'gte': lower, 'lte': upper
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
))
|
||||||
|
if not getattr(next_node, '__root__', 0) == 0:
|
||||||
|
scalars = tuple(next_node.__root__.eq.__root__)
|
||||||
|
property = scalars[0].__root__.property
|
||||||
|
value = scalars[1].__root__.__root__
|
||||||
|
query_list.append(Q(
|
||||||
|
{'match': {f'{property}': f'{value}'}}
|
||||||
|
))
|
||||||
|
q.must(query_list)
|
||||||
|
elif not getattr(node, 'between', 0) == 0:
|
||||||
|
property = node.between.value.__root__.__root__.property
|
||||||
|
lower = None
|
||||||
|
if not getattr(node.between.lower,
|
||||||
|
'__root__', 0) == 0:
|
||||||
|
lower = node.between.lower.__root__.__root__
|
||||||
|
upper = None
|
||||||
|
if not getattr(node.between.upper,
|
||||||
|
'__root__', 0) == 0:
|
||||||
|
upper = node.between.upper.__root__.__root__
|
||||||
|
query = Q(
|
||||||
|
{
|
||||||
|
'range':
|
||||||
|
{
|
||||||
|
f'{property}': {
|
||||||
|
'gte': lower, 'lte': upper
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
q.must(query)
|
||||||
|
elif not getattr(node, '__root__', 0) == 0:
|
||||||
|
next_op, next_node = get_next_node(node)
|
||||||
|
if not getattr(next_node, 'eq', 0) == 0:
|
||||||
|
scalars = tuple(next_node.eq.__root__)
|
||||||
|
property = scalars[0].__root__.property
|
||||||
|
value = scalars[1].__root__.__root__
|
||||||
|
query = Q(
|
||||||
|
{'match': {f'{property}': f'{value}'}}
|
||||||
|
)
|
||||||
|
q.must(query)
|
||||||
|
elif not getattr(node, 'intersects', 0) == 0:
|
||||||
|
property = node.intersects.__root__[0].__root__.property
|
||||||
|
if property == 'geometry':
|
||||||
|
geom_type = node.intersects.__root__[
|
||||||
|
1].__root__.__root__.__root__.type
|
||||||
|
if geom_type == 'Polygon':
|
||||||
|
coordinates = node.intersects.__root__[
|
||||||
|
1].__root__.__root__.__root__.coordinates
|
||||||
|
coords_list = [
|
||||||
|
poly_coords.__root__ for poly_coords in coordinates[0]
|
||||||
|
]
|
||||||
|
filter_ = Q(
|
||||||
|
{
|
||||||
|
'geo_shape': {
|
||||||
|
'geometry': {
|
||||||
|
'shape': {
|
||||||
|
'type': 'envelope',
|
||||||
|
'coordinates': get_envelope(
|
||||||
|
coords_list)
|
||||||
|
},
|
||||||
|
'relation': 'intersects'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
query_all = Q(
|
||||||
|
{'match_all': {}}
|
||||||
|
)
|
||||||
|
q.must(query_all)
|
||||||
|
q.filter(filter_)
|
||||||
|
return q.build()
|
||||||
|
|
||||||
|
|
||||||
|
def update_query(input_query: Dict, cql: CQLModel):
|
||||||
|
s = Search.from_dict(input_query)
|
||||||
|
query = OpenSearchQueryBuilder()
|
||||||
|
output_query = _build_query(query, cql)
|
||||||
|
s = s.query(output_query)
|
||||||
|
|
||||||
|
LOGGER.debug(f'Enhanced query: {json.dumps(s.to_dict())}')
|
||||||
|
return s.to_dict()
|
||||||
+41
-13
@@ -66,17 +66,31 @@ class DatabaseConnection:
|
|||||||
"""Initialize the connection pool for the class
|
"""Initialize the connection pool for the class
|
||||||
Lock is implemented before function call at __init__"""
|
Lock is implemented before function call at __init__"""
|
||||||
dsn = cls._make_dsn(conn_dict)
|
dsn = cls._make_dsn(conn_dict)
|
||||||
# Create the pool
|
|
||||||
|
|
||||||
p = oracledb.create_pool(
|
connect_kwargs = {
|
||||||
user=conn_dict["user"],
|
'dsn': dsn,
|
||||||
password=conn_dict["password"],
|
'min': oracle_pool_min,
|
||||||
dsn=dsn,
|
'max': oracle_pool_max,
|
||||||
min=oracle_pool_min,
|
'increment': 1
|
||||||
max=oracle_pool_max,
|
}
|
||||||
increment=1,
|
|
||||||
)
|
# Create the pool
|
||||||
LOGGER.debug("Connection pool created successfully.")
|
if conn_dict.get("external_auth") == "wallet":
|
||||||
|
# If Auth is via Wallet you need to save a wallet under
|
||||||
|
# the directory returned by this bash command if apache is used
|
||||||
|
# cat /etc/passwd |grep apache
|
||||||
|
# except another directory is specified in the sqlnet.ora file
|
||||||
|
LOGGER.debug("Connection pool from wallet.")
|
||||||
|
connect_kwargs["externalauth"] = True
|
||||||
|
connect_kwargs["homogeneous"] = False
|
||||||
|
|
||||||
|
else:
|
||||||
|
LOGGER.debug("Connection pool from user and password.")
|
||||||
|
connect_kwargs["user"] = conn_dict["user"]
|
||||||
|
connect_kwargs["password"] = conn_dict["password"]
|
||||||
|
|
||||||
|
p = oracledb.create_pool(**connect_kwargs)
|
||||||
|
LOGGER.debug("Connection pool created successfully")
|
||||||
|
|
||||||
return p
|
return p
|
||||||
|
|
||||||
@@ -435,12 +449,12 @@ class OracleProvider(BaseProvider):
|
|||||||
"""
|
"""
|
||||||
LOGGER.debug("Get available fields/properties")
|
LOGGER.debug("Get available fields/properties")
|
||||||
|
|
||||||
if not self.fields:
|
if not self._fields:
|
||||||
with DatabaseConnection(
|
with DatabaseConnection(
|
||||||
self.conn_dic, self.table, properties=self.properties
|
self.conn_dic, self.table, properties=self.properties
|
||||||
) as db:
|
) as db:
|
||||||
self.fields = db.fields
|
self._fields = db.fields
|
||||||
return self.fields
|
return self._fields
|
||||||
|
|
||||||
def _get_where_clauses(
|
def _get_where_clauses(
|
||||||
self,
|
self,
|
||||||
@@ -633,6 +647,19 @@ class OracleProvider(BaseProvider):
|
|||||||
|
|
||||||
:returns: GeoJSON FeaturesCollection
|
:returns: GeoJSON FeaturesCollection
|
||||||
"""
|
"""
|
||||||
|
LOGGER.debug(f"properties contains: {properties}")
|
||||||
|
|
||||||
|
# NOTE: properties contains field keys plus extra params
|
||||||
|
# need to split them up here
|
||||||
|
filtered_properties = []
|
||||||
|
extra_params = {}
|
||||||
|
for (key, value) in properties:
|
||||||
|
if key in self.fields.keys():
|
||||||
|
filtered_properties.append((key, value))
|
||||||
|
else:
|
||||||
|
extra_params[key] = value
|
||||||
|
|
||||||
|
properties = filtered_properties
|
||||||
|
|
||||||
# Check mandatory filter properties
|
# Check mandatory filter properties
|
||||||
property_dict = dict(properties)
|
property_dict = dict(properties)
|
||||||
@@ -790,6 +817,7 @@ class OracleProvider(BaseProvider):
|
|||||||
q,
|
q,
|
||||||
language,
|
language,
|
||||||
filterq,
|
filterq,
|
||||||
|
extra_params=extra_params
|
||||||
)
|
)
|
||||||
|
|
||||||
# Clean up placeholders that aren't used by the
|
# Clean up placeholders that aren't used by the
|
||||||
|
|||||||
@@ -0,0 +1,458 @@
|
|||||||
|
# =================================================================
|
||||||
|
#
|
||||||
|
# Authors: Leo Ghignone <leo.ghignone@gmail.com>
|
||||||
|
#
|
||||||
|
# Copyright (c) 2024 Leo Ghignone
|
||||||
|
#
|
||||||
|
# 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 itertools import chain
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from dateutil.parser import isoparse
|
||||||
|
import geopandas as gpd
|
||||||
|
import pyarrow
|
||||||
|
import pyarrow.compute as pc
|
||||||
|
import pyarrow.dataset
|
||||||
|
import s3fs
|
||||||
|
|
||||||
|
from pygeoapi.provider.base import (
|
||||||
|
BaseProvider,
|
||||||
|
ProviderConnectionError,
|
||||||
|
ProviderGenericError,
|
||||||
|
ProviderItemNotFoundError,
|
||||||
|
ProviderQueryError,
|
||||||
|
)
|
||||||
|
from pygeoapi.util import crs_transform
|
||||||
|
|
||||||
|
LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def arrow_to_pandas_type(arrow_type):
|
||||||
|
pd_type = arrow_type.to_pandas_dtype()
|
||||||
|
try:
|
||||||
|
# Needed for specific types such as dtype('<M8[ns]')
|
||||||
|
pd_type = pd_type.type
|
||||||
|
except AttributeError:
|
||||||
|
pd_type = pd_type
|
||||||
|
return pd_type
|
||||||
|
|
||||||
|
|
||||||
|
class ParquetProvider(BaseProvider):
|
||||||
|
def __init__(self, provider_def):
|
||||||
|
"""
|
||||||
|
Initialize object
|
||||||
|
|
||||||
|
# Typical ParquetProvider YAML config:
|
||||||
|
|
||||||
|
provider:
|
||||||
|
name: Parquet
|
||||||
|
data:
|
||||||
|
source: s3://example.com/parquet_directory/
|
||||||
|
|
||||||
|
id_field: gml_id
|
||||||
|
|
||||||
|
|
||||||
|
:param provider_def: provider definition
|
||||||
|
|
||||||
|
:returns: pygeoapi.provider.parquet.ParquetProvider
|
||||||
|
"""
|
||||||
|
|
||||||
|
super().__init__(provider_def)
|
||||||
|
|
||||||
|
# Source url is required
|
||||||
|
self.source = self.data.get('source')
|
||||||
|
if not self.source:
|
||||||
|
msg = "Need explicit 'source' attr " \
|
||||||
|
"in data field of provider config"
|
||||||
|
LOGGER.error(msg)
|
||||||
|
raise Exception(msg)
|
||||||
|
|
||||||
|
# Manage AWS S3 sources
|
||||||
|
if self.source.startswith('s3'):
|
||||||
|
self.source = self.source.split('://', 1)[1]
|
||||||
|
self.fs = s3fs.S3FileSystem(default_cache_type='none')
|
||||||
|
else:
|
||||||
|
self.fs = None
|
||||||
|
|
||||||
|
# Build pyarrow dataset pointing to the data
|
||||||
|
self.ds = pyarrow.dataset.dataset(self.source, filesystem=self.fs)
|
||||||
|
|
||||||
|
LOGGER.debug('Grabbing field information')
|
||||||
|
self.fields = self.get_fields() # Must be set to visualise queryables
|
||||||
|
|
||||||
|
# Column names for bounding box data.
|
||||||
|
if None in [self.x_field, self.y_field]:
|
||||||
|
self.has_geometry = False
|
||||||
|
else:
|
||||||
|
self.has_geometry = True
|
||||||
|
if isinstance(self.x_field, str):
|
||||||
|
self.minx = self.x_field
|
||||||
|
self.maxx = self.x_field
|
||||||
|
else:
|
||||||
|
self.minx, self.maxx = self.x_field
|
||||||
|
|
||||||
|
if isinstance(self.y_field, str):
|
||||||
|
self.miny = self.y_field
|
||||||
|
self.maxy = self.y_field
|
||||||
|
else:
|
||||||
|
self.miny, self.maxy = self.y_field
|
||||||
|
self.bb = [self.minx, self.miny, self.maxx, self.maxy]
|
||||||
|
|
||||||
|
# Get the CRS of the data
|
||||||
|
geo_metadata = json.loads(self.ds.schema.metadata[b'geo'])
|
||||||
|
geom_column = geo_metadata['primary_column']
|
||||||
|
# if the CRS is not set default to EPSG:4326, per geoparquet spec
|
||||||
|
self.crs = (geo_metadata['columns'][geom_column]['crs']
|
||||||
|
or 'EPSG:4326')
|
||||||
|
|
||||||
|
def _read_parquet(self, return_scanner=False, **kwargs):
|
||||||
|
"""
|
||||||
|
Scan a Parquet dataset with the given arguments
|
||||||
|
|
||||||
|
:returns: generator of RecordBatch with the queried values
|
||||||
|
"""
|
||||||
|
scanner = pyarrow.dataset.Scanner.from_dataset(self.ds, **kwargs)
|
||||||
|
batches = scanner.to_batches()
|
||||||
|
if return_scanner:
|
||||||
|
return batches, scanner
|
||||||
|
else:
|
||||||
|
return batches
|
||||||
|
|
||||||
|
def get_fields(self):
|
||||||
|
"""
|
||||||
|
Get provider field information (names, types)
|
||||||
|
|
||||||
|
:returns: dict of fields
|
||||||
|
"""
|
||||||
|
|
||||||
|
fields = dict()
|
||||||
|
|
||||||
|
for field_name, field_type in zip(self.ds.schema.names,
|
||||||
|
self.ds.schema.types):
|
||||||
|
# Geometry is managed as a special case by pygeoapi
|
||||||
|
if field_name == 'geometry':
|
||||||
|
continue
|
||||||
|
|
||||||
|
field_type = str(field_type)
|
||||||
|
converted_type = None
|
||||||
|
converted_format = None
|
||||||
|
if field_type.startswith(('int', 'uint')):
|
||||||
|
converted_type = 'integer'
|
||||||
|
converted_format = field_type
|
||||||
|
elif field_type == 'double' or field_type.startswith('float'):
|
||||||
|
converted_type = 'number'
|
||||||
|
converted_format = field_type
|
||||||
|
elif field_type == 'string':
|
||||||
|
converted_type = 'string'
|
||||||
|
elif field_type == 'bool':
|
||||||
|
converted_type = 'boolean'
|
||||||
|
elif field_type.startswith('timestamp'):
|
||||||
|
converted_type = 'string'
|
||||||
|
converted_format = 'date-time'
|
||||||
|
else:
|
||||||
|
LOGGER.error(f'Unsupported field type {field_type}')
|
||||||
|
|
||||||
|
if converted_format is None:
|
||||||
|
fields[field_name] = {'type': converted_type}
|
||||||
|
else:
|
||||||
|
fields[field_name] = {
|
||||||
|
'type': converted_type,
|
||||||
|
'format': converted_format,
|
||||||
|
}
|
||||||
|
|
||||||
|
return fields
|
||||||
|
|
||||||
|
@crs_transform
|
||||||
|
def query(
|
||||||
|
self,
|
||||||
|
offset=0,
|
||||||
|
limit=10,
|
||||||
|
resulttype='results',
|
||||||
|
bbox=[],
|
||||||
|
datetime_=None,
|
||||||
|
properties=[],
|
||||||
|
select_properties=[],
|
||||||
|
skip_geometry=False,
|
||||||
|
q=None,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Query Parquet source
|
||||||
|
|
||||||
|
:param offset: starting record to return (default 0)
|
||||||
|
:param limit: number of records to return (default 10)
|
||||||
|
:param resulttype: return results or hit limit (default results)
|
||||||
|
:param bbox: bounding box [minx,miny,maxx,maxy]
|
||||||
|
:param datetime_: temporal (datestamp or extent) following ISO-8601
|
||||||
|
:param properties: list of tuples (field, comparison, value)
|
||||||
|
:param select_properties: list of property names
|
||||||
|
:param skip_geometry: bool of whether to skip geometry (default False)
|
||||||
|
|
||||||
|
:returns: dict of 0..n GeoJSON features
|
||||||
|
"""
|
||||||
|
result = None
|
||||||
|
try:
|
||||||
|
filter = pc.scalar(True)
|
||||||
|
if bbox:
|
||||||
|
if self.has_geometry is False:
|
||||||
|
msg = (
|
||||||
|
'Dataset does not have a geometry field, '
|
||||||
|
'querying by bbox is not supported.'
|
||||||
|
)
|
||||||
|
raise ProviderQueryError(msg)
|
||||||
|
LOGGER.debug('processing bbox parameter')
|
||||||
|
if any(b is None for b in bbox):
|
||||||
|
msg = 'Dataset does not support bbox filtering'
|
||||||
|
raise ProviderQueryError(msg)
|
||||||
|
|
||||||
|
minx, miny, maxx, maxy = [float(b) for b in bbox]
|
||||||
|
filter = (
|
||||||
|
(pc.field(self.minx) > pc.scalar(minx))
|
||||||
|
& (pc.field(self.miny) > pc.scalar(miny))
|
||||||
|
& (pc.field(self.maxx) < pc.scalar(maxx))
|
||||||
|
& (pc.field(self.maxy) < pc.scalar(maxy))
|
||||||
|
)
|
||||||
|
|
||||||
|
if datetime_ is not None:
|
||||||
|
if self.time_field is None:
|
||||||
|
msg = (
|
||||||
|
'Dataset does not have a time field, '
|
||||||
|
'querying by datetime is not supported.'
|
||||||
|
)
|
||||||
|
raise ProviderQueryError(msg)
|
||||||
|
timefield = pc.field(self.time_field)
|
||||||
|
if '/' in datetime_:
|
||||||
|
begin, end = datetime_.split('/')
|
||||||
|
if begin != '..':
|
||||||
|
begin = isoparse(begin)
|
||||||
|
filter = filter & (timefield >= begin)
|
||||||
|
if end != '..':
|
||||||
|
end = isoparse(end)
|
||||||
|
filter = filter & (timefield <= end)
|
||||||
|
else:
|
||||||
|
target_time = isoparse(datetime_)
|
||||||
|
filter = filter & (timefield == target_time)
|
||||||
|
|
||||||
|
if properties:
|
||||||
|
LOGGER.debug('processing properties')
|
||||||
|
for name, value in properties:
|
||||||
|
field = self.ds.schema.field(name)
|
||||||
|
pd_type = arrow_to_pandas_type(field.type)
|
||||||
|
expr = pc.field(name) == pc.scalar(pd_type(value))
|
||||||
|
|
||||||
|
filter = filter & expr
|
||||||
|
|
||||||
|
if len(select_properties) == 0:
|
||||||
|
select_properties = self.ds.schema.names
|
||||||
|
else: # Load id and geometry together with any specified columns
|
||||||
|
if self.has_geometry and 'geometry' not in select_properties:
|
||||||
|
select_properties.append('geometry')
|
||||||
|
if self.id_field not in select_properties:
|
||||||
|
select_properties.insert(0, self.id_field)
|
||||||
|
|
||||||
|
if skip_geometry:
|
||||||
|
select_properties.remove('geometry')
|
||||||
|
|
||||||
|
# Make response based on resulttype specified
|
||||||
|
if resulttype == 'hits':
|
||||||
|
LOGGER.debug('hits only specified')
|
||||||
|
result = self._response_feature_hits(filter)
|
||||||
|
elif resulttype == 'results':
|
||||||
|
LOGGER.debug('results specified')
|
||||||
|
result = self._response_feature_collection(
|
||||||
|
filter, offset, limit, columns=select_properties
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
LOGGER.error(f'Invalid resulttype: {resulttype}')
|
||||||
|
|
||||||
|
except RuntimeError as err:
|
||||||
|
LOGGER.error(err)
|
||||||
|
raise ProviderQueryError(err)
|
||||||
|
except ProviderConnectionError as err:
|
||||||
|
LOGGER.error(err)
|
||||||
|
raise ProviderConnectionError(err)
|
||||||
|
except Exception as err:
|
||||||
|
LOGGER.error(err)
|
||||||
|
raise ProviderGenericError(err)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
@crs_transform
|
||||||
|
def get(self, identifier, **kwargs):
|
||||||
|
"""
|
||||||
|
Get Feature by id
|
||||||
|
|
||||||
|
:param identifier: feature id
|
||||||
|
|
||||||
|
:returns: a single feature
|
||||||
|
"""
|
||||||
|
result = None
|
||||||
|
try:
|
||||||
|
LOGGER.debug(f'Fetching identifier {identifier}')
|
||||||
|
id_type = arrow_to_pandas_type(
|
||||||
|
self.ds.schema.field(self.id_field).type)
|
||||||
|
batches = self._read_parquet(
|
||||||
|
filter=(
|
||||||
|
pc.field(self.id_field) == pc.scalar(id_type(identifier))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for batch in batches:
|
||||||
|
if batch.num_rows > 0:
|
||||||
|
assert (
|
||||||
|
batch.num_rows == 1
|
||||||
|
), f'Multiple items found with ID {identifier}'
|
||||||
|
row = batch.to_pandas()
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
raise ProviderItemNotFoundError(f'ID {identifier} not found')
|
||||||
|
|
||||||
|
if self.has_geometry:
|
||||||
|
geom = gpd.GeoSeries.from_wkb(row['geometry'], crs=self.crs)
|
||||||
|
else:
|
||||||
|
geom = [None]
|
||||||
|
gdf = gpd.GeoDataFrame(row, geometry=geom)
|
||||||
|
LOGGER.debug('results computed')
|
||||||
|
|
||||||
|
# Grab the collection from geopandas geo_interface
|
||||||
|
result = gdf.__geo_interface__['features'][0]
|
||||||
|
|
||||||
|
except RuntimeError as err:
|
||||||
|
LOGGER.error(err)
|
||||||
|
raise ProviderQueryError(err)
|
||||||
|
except ProviderConnectionError as err:
|
||||||
|
LOGGER.error(err)
|
||||||
|
raise ProviderConnectionError(err)
|
||||||
|
except ProviderItemNotFoundError as err:
|
||||||
|
LOGGER.error(err)
|
||||||
|
raise ProviderItemNotFoundError(err)
|
||||||
|
except Exception as err:
|
||||||
|
LOGGER.error(err)
|
||||||
|
raise ProviderGenericError(err)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<ParquetProvider> {self.data}'
|
||||||
|
|
||||||
|
def _response_feature_collection(self, filter, offset, limit,
|
||||||
|
columns=None):
|
||||||
|
"""
|
||||||
|
Assembles output from query as
|
||||||
|
GeoJSON FeatureCollection structure.
|
||||||
|
|
||||||
|
:returns: GeoJSON FeatureCollection
|
||||||
|
"""
|
||||||
|
|
||||||
|
LOGGER.debug(f'offset:{offset}, limit:{limit}')
|
||||||
|
|
||||||
|
try:
|
||||||
|
batches, scanner = self._read_parquet(
|
||||||
|
filter=filter, columns=columns, return_scanner=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Discard batches until offset is reached
|
||||||
|
counted = 0
|
||||||
|
for batch in batches:
|
||||||
|
if counted + batch.num_rows > offset:
|
||||||
|
# Slice current batch to start from the requested row
|
||||||
|
batch = batch.slice(offset=offset - counted)
|
||||||
|
# Build a new generator yielding the current batch
|
||||||
|
# and all following ones
|
||||||
|
|
||||||
|
batches = chain([batch], batches)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
counted += batch.num_rows
|
||||||
|
|
||||||
|
# batches is a generator, it will now be either fully spent
|
||||||
|
# or set to the new generator starting from offset
|
||||||
|
|
||||||
|
# Get the next `limit+1` rows
|
||||||
|
# The extra row is used to check if a "next" link is needed
|
||||||
|
# (when numberMatched > offset + limit)
|
||||||
|
batches_list = []
|
||||||
|
read = 0
|
||||||
|
|
||||||
|
for batch in batches:
|
||||||
|
read += batch.num_rows
|
||||||
|
if read > limit:
|
||||||
|
batches_list.append(batch.slice(0, limit + 1))
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
batches_list.append(batch)
|
||||||
|
|
||||||
|
# Passing schema from scanner in case no rows are returned
|
||||||
|
table = pyarrow.Table.from_batches(
|
||||||
|
batches_list, schema=scanner.projected_schema
|
||||||
|
)
|
||||||
|
|
||||||
|
rp = table.to_pandas()
|
||||||
|
|
||||||
|
number_matched = offset + len(rp)
|
||||||
|
|
||||||
|
# Remove the extra row
|
||||||
|
if len(rp) > limit:
|
||||||
|
rp = rp.iloc[:-1]
|
||||||
|
|
||||||
|
if 'geometry' not in rp.columns:
|
||||||
|
# We need a null geometry column to create a GeoDataFrame
|
||||||
|
rp['geometry'] = None
|
||||||
|
geom = gpd.GeoSeries.from_wkb(rp['geometry'])
|
||||||
|
else:
|
||||||
|
geom = gpd.GeoSeries.from_wkb(rp['geometry'], crs=self.crs)
|
||||||
|
|
||||||
|
gdf = gpd.GeoDataFrame(rp, geometry=geom)
|
||||||
|
LOGGER.debug('results computed')
|
||||||
|
result = gdf.__geo_interface__
|
||||||
|
|
||||||
|
# Add numberMatched to generate "next" link
|
||||||
|
result['numberMatched'] = number_matched
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except RuntimeError as error:
|
||||||
|
LOGGER.error(error)
|
||||||
|
raise error
|
||||||
|
|
||||||
|
def _response_feature_hits(self, filter):
|
||||||
|
"""
|
||||||
|
Assembles GeoJSON hits from row count
|
||||||
|
|
||||||
|
:returns: GeoJSON FeaturesCollection
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
scanner = pyarrow.dataset.Scanner.from_dataset(self.ds,
|
||||||
|
filter=filter)
|
||||||
|
return {
|
||||||
|
'type': 'FeatureCollection',
|
||||||
|
'numberMatched': scanner.count_rows(),
|
||||||
|
'features': [],
|
||||||
|
}
|
||||||
|
except Exception as error:
|
||||||
|
LOGGER.error(error)
|
||||||
|
raise error
|
||||||
@@ -62,7 +62,8 @@ import pyproj
|
|||||||
import shapely
|
import shapely
|
||||||
from sqlalchemy import create_engine, MetaData, PrimaryKeyConstraint, asc, desc
|
from sqlalchemy import create_engine, MetaData, PrimaryKeyConstraint, asc, desc
|
||||||
from sqlalchemy.engine import URL
|
from sqlalchemy.engine import URL
|
||||||
from sqlalchemy.exc import InvalidRequestError, OperationalError
|
from sqlalchemy.exc import ConstraintColumnNotFoundError, \
|
||||||
|
InvalidRequestError, OperationalError
|
||||||
from sqlalchemy.ext.automap import automap_base
|
from sqlalchemy.ext.automap import automap_base
|
||||||
from sqlalchemy.orm import Session, load_only
|
from sqlalchemy.orm import Session, load_only
|
||||||
from sqlalchemy.sql.expression import and_
|
from sqlalchemy.sql.expression import and_
|
||||||
@@ -124,7 +125,7 @@ class PostgreSQLProvider(BaseProvider):
|
|||||||
)
|
)
|
||||||
|
|
||||||
LOGGER.debug(f'DB connection: {repr(self._engine.url)}')
|
LOGGER.debug(f'DB connection: {repr(self._engine.url)}')
|
||||||
self.fields = self.get_fields()
|
self.get_fields()
|
||||||
|
|
||||||
def query(self, offset=0, limit=10, resulttype='results',
|
def query(self, offset=0, limit=10, resulttype='results',
|
||||||
bbox=[], datetime_=None, properties=[], sortby=[],
|
bbox=[], datetime_=None, properties=[], sortby=[],
|
||||||
@@ -204,8 +205,6 @@ class PostgreSQLProvider(BaseProvider):
|
|||||||
|
|
||||||
LOGGER.debug('Get available fields/properties')
|
LOGGER.debug('Get available fields/properties')
|
||||||
|
|
||||||
fields = {}
|
|
||||||
|
|
||||||
# sql-schema only allows these types, so we need to map from sqlalchemy
|
# sql-schema only allows these types, so we need to map from sqlalchemy
|
||||||
# string, number, integer, object, array, boolean, null,
|
# string, number, integer, object, array, boolean, null,
|
||||||
# https://json-schema.org/understanding-json-schema/reference/type.html
|
# https://json-schema.org/understanding-json-schema/reference/type.html
|
||||||
@@ -248,17 +247,18 @@ class PostgreSQLProvider(BaseProvider):
|
|||||||
LOGGER.debug('No string format detected')
|
LOGGER.debug('No string format detected')
|
||||||
return None
|
return None
|
||||||
|
|
||||||
for column in self.table_model.__table__.columns:
|
if not self._fields:
|
||||||
LOGGER.debug(f'Testing {column.name}')
|
for column in self.table_model.__table__.columns:
|
||||||
if column.name == self.geom:
|
LOGGER.debug(f'Testing {column.name}')
|
||||||
continue
|
if column.name == self.geom:
|
||||||
|
continue
|
||||||
|
|
||||||
fields[str(column.name)] = {
|
self._fields[str(column.name)] = {
|
||||||
'type': _column_type_to_json_schema_type(column.type),
|
'type': _column_type_to_json_schema_type(column.type),
|
||||||
'format': _column_format_to_json_schema_format(column.type)
|
'format': _column_format_to_json_schema_format(column.type)
|
||||||
}
|
}
|
||||||
|
|
||||||
return fields
|
return self._fields
|
||||||
|
|
||||||
def get(self, identifier, crs_transform_spec=None, **kwargs):
|
def get(self, identifier, crs_transform_spec=None, **kwargs):
|
||||||
"""
|
"""
|
||||||
@@ -516,7 +516,7 @@ def get_table_model(
|
|||||||
sqlalchemy_table_def = metadata.tables[f'{schema}.{table_name}']
|
sqlalchemy_table_def = metadata.tables[f'{schema}.{table_name}']
|
||||||
try:
|
try:
|
||||||
sqlalchemy_table_def.append_constraint(PrimaryKeyConstraint(id_field))
|
sqlalchemy_table_def.append_constraint(PrimaryKeyConstraint(id_field))
|
||||||
except KeyError:
|
except (ConstraintColumnNotFoundError, KeyError):
|
||||||
raise ProviderQueryError(
|
raise ProviderQueryError(
|
||||||
f"No such id_field column ({id_field}) on {schema}.{table_name}.")
|
f"No such id_field column ({id_field}) on {schema}.{table_name}.")
|
||||||
|
|
||||||
|
|||||||
@@ -59,38 +59,39 @@ class RasterioProvider(BaseProvider):
|
|||||||
self.axes = self._coverage_properties['axes']
|
self.axes = self._coverage_properties['axes']
|
||||||
self.crs = self._coverage_properties['bbox_crs']
|
self.crs = self._coverage_properties['bbox_crs']
|
||||||
self.num_bands = self._coverage_properties['num_bands']
|
self.num_bands = self._coverage_properties['num_bands']
|
||||||
self.fields = self.get_fields()
|
self.get_fields()
|
||||||
self.native_format = provider_def['format']['name']
|
self.native_format = provider_def['format']['name']
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
LOGGER.warning(err)
|
LOGGER.warning(err)
|
||||||
raise ProviderConnectionError(err)
|
raise ProviderConnectionError(err)
|
||||||
|
|
||||||
def get_fields(self):
|
def get_fields(self):
|
||||||
fields = {}
|
if not self._fields:
|
||||||
|
for i, dtype in zip(self._data.indexes, self._data.dtypes):
|
||||||
|
LOGGER.debug(f'Adding field for band {i}')
|
||||||
|
i2 = str(i)
|
||||||
|
|
||||||
for i, dtype in zip(self._data.indexes, self._data.dtypes):
|
parameter = _get_parameter_metadata(
|
||||||
LOGGER.debug(f'Adding field for band {i}')
|
self._data.profile['driver'], self._data.tags(i))
|
||||||
i2 = str(i)
|
|
||||||
|
|
||||||
parameter = _get_parameter_metadata(
|
name = parameter['description']
|
||||||
self._data.profile['driver'], self._data.tags(i))
|
units = parameter.get('unit_label')
|
||||||
|
|
||||||
name = parameter['description']
|
dtype2 = dtype
|
||||||
units = parameter.get('unit_label')
|
if dtype.startswith('float'):
|
||||||
|
dtype2 = 'number'
|
||||||
|
elif dtype.startswith('int'):
|
||||||
|
dtype2 = 'integer'
|
||||||
|
|
||||||
dtype2 = dtype
|
self._fields[i2] = {
|
||||||
if dtype.startswith('float'):
|
'title': name,
|
||||||
dtype2 = 'number'
|
'type': dtype2,
|
||||||
|
'_meta': self._data.tags(i)
|
||||||
|
}
|
||||||
|
if units is not None:
|
||||||
|
self._fields[i2]['x-ogc-unit'] = units
|
||||||
|
|
||||||
fields[i2] = {
|
return self._fields
|
||||||
'title': name,
|
|
||||||
'type': dtype2,
|
|
||||||
'_meta': self._data.tags(i)
|
|
||||||
}
|
|
||||||
if units is not None:
|
|
||||||
fields[i2]['x-ogc-unit'] = units
|
|
||||||
|
|
||||||
return fields
|
|
||||||
|
|
||||||
def query(self, properties=[], subsets={}, bbox=None, bbox_crs=4326,
|
def query(self, properties=[], subsets={}, bbox=None, bbox_crs=4326,
|
||||||
datetime_=None, format_='json', **kwargs):
|
datetime_=None, format_='json', **kwargs):
|
||||||
@@ -241,16 +242,15 @@ class RasterioProvider(BaseProvider):
|
|||||||
out_meta['units'] = _data.units
|
out_meta['units'] = _data.units
|
||||||
|
|
||||||
LOGGER.debug('Serializing data in memory')
|
LOGGER.debug('Serializing data in memory')
|
||||||
with MemoryFile() as memfile:
|
if format_ == 'json':
|
||||||
with memfile.open(**out_meta) as dest:
|
LOGGER.debug('Creating output in CoverageJSON')
|
||||||
dest.write(out_image)
|
out_meta['bands'] = args['indexes']
|
||||||
|
return self.gen_covjson(out_meta, out_image)
|
||||||
|
|
||||||
if format_ == 'json':
|
else: # return data in native format
|
||||||
LOGGER.debug('Creating output in CoverageJSON')
|
with MemoryFile() as memfile:
|
||||||
out_meta['bands'] = args['indexes']
|
with memfile.open(**out_meta) as dest:
|
||||||
return self.gen_covjson(out_meta, out_image)
|
dest.write(out_image)
|
||||||
|
|
||||||
else: # return data in native format
|
|
||||||
LOGGER.debug('Returning data in native format')
|
LOGGER.debug('Returning data in native format')
|
||||||
return memfile.read()
|
return memfile.read()
|
||||||
|
|
||||||
|
|||||||
+147
-112
@@ -30,14 +30,14 @@
|
|||||||
# =================================================================
|
# =================================================================
|
||||||
|
|
||||||
from json.decoder import JSONDecodeError
|
from json.decoder import JSONDecodeError
|
||||||
import os
|
|
||||||
import logging
|
import logging
|
||||||
from requests import Session
|
from requests import Session
|
||||||
|
|
||||||
|
from pygeoapi.config import get_config
|
||||||
from pygeoapi.provider.base import (
|
from pygeoapi.provider.base import (
|
||||||
BaseProvider, ProviderQueryError, ProviderConnectionError)
|
BaseProvider, ProviderQueryError, ProviderConnectionError)
|
||||||
from pygeoapi.util import (
|
from pygeoapi.util import (
|
||||||
yaml_load, url_join, get_provider_default, crs_transform, get_base_url)
|
url_join, get_provider_default, crs_transform, get_base_url)
|
||||||
|
|
||||||
LOGGER = logging.getLogger(__name__)
|
LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -51,10 +51,10 @@ ENTITY = {
|
|||||||
_EXPAND = {
|
_EXPAND = {
|
||||||
'Things': 'Locations,Datastreams',
|
'Things': 'Locations,Datastreams',
|
||||||
'Observations': 'Datastream,FeatureOfInterest',
|
'Observations': 'Datastream,FeatureOfInterest',
|
||||||
|
'ObservedProperties': 'Datastreams/Thing/Locations',
|
||||||
'Datastreams': """
|
'Datastreams': """
|
||||||
Sensor
|
Sensor
|
||||||
,ObservedProperty
|
,ObservedProperty
|
||||||
,Thing
|
|
||||||
,Thing/Locations
|
,Thing/Locations
|
||||||
,Observations(
|
,Observations(
|
||||||
$select=@iot.id;
|
$select=@iot.id;
|
||||||
@@ -71,6 +71,7 @@ EXPAND = {k: ''.join(v.split()).replace('_', ' ')
|
|||||||
|
|
||||||
class SensorThingsProvider(BaseProvider):
|
class SensorThingsProvider(BaseProvider):
|
||||||
"""SensorThings API (STA) Provider"""
|
"""SensorThings API (STA) Provider"""
|
||||||
|
expand = EXPAND
|
||||||
|
|
||||||
def __init__(self, provider_def):
|
def __init__(self, provider_def):
|
||||||
"""
|
"""
|
||||||
@@ -82,64 +83,12 @@ class SensorThingsProvider(BaseProvider):
|
|||||||
:returns: pygeoapi.provider.sensorthings.SensorThingsProvider
|
:returns: pygeoapi.provider.sensorthings.SensorThingsProvider
|
||||||
"""
|
"""
|
||||||
LOGGER.debug('Setting SensorThings API (STA) provider')
|
LOGGER.debug('Setting SensorThings API (STA) provider')
|
||||||
|
self.linked_entity = {}
|
||||||
super().__init__(provider_def)
|
super().__init__(provider_def)
|
||||||
self.data.rstrip('/')
|
|
||||||
try:
|
self._generate_mappings(provider_def)
|
||||||
self.entity = provider_def['entity']
|
|
||||||
self._url = url_join(self.data, self.entity)
|
|
||||||
except KeyError:
|
|
||||||
LOGGER.debug('Attempting to parse Entity from provider data')
|
|
||||||
if not self._get_entity(self.data):
|
|
||||||
raise RuntimeError('Entity type required')
|
|
||||||
self.entity = self._get_entity(self.data)
|
|
||||||
self._url = self.data
|
|
||||||
self.data = self._url.rstrip(f'/{self.entity}')
|
|
||||||
LOGGER.debug(f'STA endpoint: {self.data}, Entity: {self.entity}')
|
LOGGER.debug(f'STA endpoint: {self.data}, Entity: {self.entity}')
|
||||||
|
|
||||||
# Default id
|
|
||||||
if self.id_field:
|
|
||||||
LOGGER.debug(f'Using id field: {self.id_field}')
|
|
||||||
else:
|
|
||||||
LOGGER.debug('Using default @iot.id for id field')
|
|
||||||
self.id_field = '@iot.id'
|
|
||||||
|
|
||||||
# Create intra-links
|
|
||||||
self.links = {}
|
|
||||||
self.intralink = provider_def.get('intralink', False)
|
|
||||||
if self.intralink and provider_def.get('rel_link'):
|
|
||||||
# For pytest
|
|
||||||
self.rel_link = provider_def['rel_link']
|
|
||||||
|
|
||||||
elif self.intralink:
|
|
||||||
# Read from pygeoapi config
|
|
||||||
with open(os.getenv('PYGEOAPI_CONFIG'), encoding='utf8') as fh:
|
|
||||||
CONFIG = yaml_load(fh)
|
|
||||||
self.rel_link = get_base_url(CONFIG)
|
|
||||||
|
|
||||||
for (name, rs) in CONFIG['resources'].items():
|
|
||||||
pvs = rs.get('providers')
|
|
||||||
p = get_provider_default(pvs)
|
|
||||||
e = p.get('entity') or self._get_entity(p['data'])
|
|
||||||
if any([
|
|
||||||
not pvs, # No providers in resource
|
|
||||||
not p.get('intralink'), # No configuration for intralinks
|
|
||||||
not e, # No STA entity found
|
|
||||||
self.data not in p.get('data') # No common STA endpoint
|
|
||||||
]):
|
|
||||||
continue
|
|
||||||
|
|
||||||
if p.get('uri_field'):
|
|
||||||
LOGGER.debug(f'Linking {e} with field: {p["uri_field"]}')
|
|
||||||
else:
|
|
||||||
LOGGER.debug(f'Linking {e} with collection: {name}')
|
|
||||||
|
|
||||||
self.links[e] = {
|
|
||||||
'cnm': name, # OAPI collection name,
|
|
||||||
'cid': p.get('id_field', '@iot.id'), # OAPI id_field
|
|
||||||
'uri': p.get('uri_field') # STA uri_field
|
|
||||||
}
|
|
||||||
|
|
||||||
# Start session
|
# Start session
|
||||||
self.http = Session()
|
self.http = Session()
|
||||||
self.get_fields()
|
self.get_fields()
|
||||||
@@ -150,7 +99,7 @@ class SensorThingsProvider(BaseProvider):
|
|||||||
|
|
||||||
:returns: dict of fields
|
:returns: dict of fields
|
||||||
"""
|
"""
|
||||||
if not self.fields:
|
if not self._fields:
|
||||||
r = self._get_response(self._url, {'$top': 1})
|
r = self._get_response(self._url, {'$top': 1})
|
||||||
try:
|
try:
|
||||||
results = r['value'][0]
|
results = r['value'][0]
|
||||||
@@ -161,11 +110,11 @@ class SensorThingsProvider(BaseProvider):
|
|||||||
for (n, v) in results.items():
|
for (n, v) in results.items():
|
||||||
if isinstance(v, (int, float)) or \
|
if isinstance(v, (int, float)) or \
|
||||||
(isinstance(v, (dict, list)) and n in ENTITY):
|
(isinstance(v, (dict, list)) and n in ENTITY):
|
||||||
self.fields[n] = {'type': 'number'}
|
self._fields[n] = {'type': 'number'}
|
||||||
elif isinstance(v, str):
|
elif isinstance(v, str):
|
||||||
self.fields[n] = {'type': 'string'}
|
self._fields[n] = {'type': 'string'}
|
||||||
|
|
||||||
return self.fields
|
return self._fields
|
||||||
|
|
||||||
@crs_transform
|
@crs_transform
|
||||||
def query(self, offset=0, limit=10, resulttype='results',
|
def query(self, offset=0, limit=10, resulttype='results',
|
||||||
@@ -272,17 +221,19 @@ class SensorThingsProvider(BaseProvider):
|
|||||||
|
|
||||||
return fc
|
return fc
|
||||||
|
|
||||||
def _make_feature(self, entity, select_properties=[], skip_geometry=False):
|
def _make_feature(self, feature, select_properties=[], skip_geometry=False,
|
||||||
|
entity=None):
|
||||||
"""
|
"""
|
||||||
Private function: Create feature from entity
|
Private function: Create feature from entity
|
||||||
|
|
||||||
:param entity: `dict` of STA entity
|
:param feature: `dict` of STA entity
|
||||||
:param select_properties: list of property names
|
:param select_properties: list of property names
|
||||||
:param skip_geometry: bool of whether to skip geometry (default False)
|
:param skip_geometry: bool of whether to skip geometry (default False)
|
||||||
|
:param entity: SensorThings entity name
|
||||||
|
|
||||||
:returns: dict of GeoJSON Feature
|
:returns: dict of GeoJSON Feature
|
||||||
"""
|
"""
|
||||||
_ = entity.pop(self.id_field)
|
_ = feature.pop(self.id_field)
|
||||||
id = f"'{_}'" if isinstance(_, str) else str(_)
|
id = f"'{_}'" if isinstance(_, str) else str(_)
|
||||||
f = {
|
f = {
|
||||||
'type': 'Feature', 'id': id, 'properties': {}, 'geometry': None
|
'type': 'Feature', 'id': id, 'properties': {}, 'geometry': None
|
||||||
@@ -290,28 +241,35 @@ class SensorThingsProvider(BaseProvider):
|
|||||||
|
|
||||||
# Make geometry
|
# Make geometry
|
||||||
if not skip_geometry:
|
if not skip_geometry:
|
||||||
f['geometry'] = self._geometry(entity)
|
f['geometry'] = self._geometry(feature, entity)
|
||||||
|
|
||||||
# Fill properties block
|
# Fill properties block
|
||||||
try:
|
try:
|
||||||
f['properties'] = self._expand_properties(
|
f['properties'] = self._expand_properties(
|
||||||
entity, select_properties)
|
feature, select_properties, entity)
|
||||||
except KeyError as err:
|
except KeyError as err:
|
||||||
LOGGER.error(err)
|
LOGGER.error(err)
|
||||||
raise ProviderQueryError(err)
|
raise ProviderQueryError(err)
|
||||||
|
|
||||||
return f
|
return f
|
||||||
|
|
||||||
def _get_response(self, url, params={}):
|
def _get_response(self, url, params={}, entity=None, expand=None):
|
||||||
"""
|
"""
|
||||||
Private function: Get STA response
|
Private function: Get STA response
|
||||||
|
|
||||||
:param url: request url
|
:param url: request url
|
||||||
:param params: query parameters
|
:param params: query parameters
|
||||||
|
:param entity: SensorThings entity name
|
||||||
|
:param expand: SensorThings expand query
|
||||||
|
|
||||||
|
|
||||||
:returns: STA response
|
:returns: STA response
|
||||||
"""
|
"""
|
||||||
params.update({'$expand': EXPAND[self.entity]})
|
if expand:
|
||||||
|
params.update({'$expand': expand})
|
||||||
|
else:
|
||||||
|
entity_ = entity or self.entity
|
||||||
|
params.update({'$expand': self.expand[entity_]})
|
||||||
|
|
||||||
r = self.http.get(url, params=params)
|
r = self.http.get(url, params=params)
|
||||||
|
|
||||||
@@ -327,13 +285,15 @@ class SensorThingsProvider(BaseProvider):
|
|||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
def _make_filter(self, properties, bbox=[], datetime_=None):
|
def _make_filter(self, properties, bbox=[], datetime_=None,
|
||||||
|
entity=None):
|
||||||
"""
|
"""
|
||||||
Private function: Make STA filter from query properties
|
Private function: Make STA filter from query properties
|
||||||
|
|
||||||
:param properties: list of tuples (name, value)
|
:param properties: list of tuples (name, value)
|
||||||
:param bbox: bounding box [minx,miny,maxx,maxy]
|
:param bbox: bounding box [minx,miny,maxx,maxy]
|
||||||
:param datetime_: temporal (datestamp or extent)
|
:param datetime_: temporal (datestamp or extent)
|
||||||
|
:param entity: SensorThings entity name
|
||||||
|
|
||||||
:returns: STA $filter string of properties
|
:returns: STA $filter string of properties
|
||||||
"""
|
"""
|
||||||
@@ -345,16 +305,8 @@ class SensorThingsProvider(BaseProvider):
|
|||||||
ret.append(f'{name} eq {value}')
|
ret.append(f'{name} eq {value}')
|
||||||
|
|
||||||
if bbox:
|
if bbox:
|
||||||
minx, miny, maxx, maxy = bbox
|
entity_ = entity or self.entity
|
||||||
bbox_ = f'POLYGON (({minx} {miny}, {maxx} {miny}, \
|
ret.append(self._make_bbox(bbox, entity_))
|
||||||
{maxx} {maxy}, {minx} {maxy}, {minx} {miny}))'
|
|
||||||
if self.entity == 'Things':
|
|
||||||
loc = 'Locations/location'
|
|
||||||
elif self.entity == 'Datastreams':
|
|
||||||
loc = 'Thing/Locations/location'
|
|
||||||
elif self.entity == 'Observations':
|
|
||||||
loc = 'FeatureOfInterest/feature'
|
|
||||||
ret.append(f"st_within({loc}, geography'{bbox_}')")
|
|
||||||
|
|
||||||
if datetime_ is not None:
|
if datetime_ is not None:
|
||||||
if self.time_field is None:
|
if self.time_field is None:
|
||||||
@@ -373,6 +325,20 @@ class SensorThingsProvider(BaseProvider):
|
|||||||
|
|
||||||
return ' and '.join(ret)
|
return ' and '.join(ret)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _make_bbox(bbox, entity):
|
||||||
|
minx, miny, maxx, maxy = bbox
|
||||||
|
bbox_ = f'POLYGON(({minx} {miny},{maxx} {miny},{maxx} {maxy},{minx} {maxy},{minx} {miny}))' # noqa
|
||||||
|
if entity == 'Things':
|
||||||
|
loc = 'Locations/location'
|
||||||
|
elif entity == 'Datastreams':
|
||||||
|
loc = 'Thing/Locations/location'
|
||||||
|
elif entity == 'Observations':
|
||||||
|
loc = 'FeatureOfInterest/feature'
|
||||||
|
elif entity == 'ObservedProperties':
|
||||||
|
loc = 'Datastreams/observedArea'
|
||||||
|
return f"st_within({loc},geography'{bbox_}')"
|
||||||
|
|
||||||
def _make_orderby(self, sortby):
|
def _make_orderby(self, sortby):
|
||||||
"""
|
"""
|
||||||
Private function: Make STA filter from query properties
|
Private function: Make STA filter from query properties
|
||||||
@@ -393,79 +359,85 @@ class SensorThingsProvider(BaseProvider):
|
|||||||
|
|
||||||
return ','.join(ret)
|
return ','.join(ret)
|
||||||
|
|
||||||
def _geometry(self, entity):
|
def _geometry(self, feature, entity=None):
|
||||||
"""
|
"""
|
||||||
Private function: Retrieve STA geometry
|
Private function: Retrieve STA geometry
|
||||||
|
|
||||||
:param entity: SensorThings entity
|
:param feature: SensorThings entity
|
||||||
|
:param entity: SensorThings entity name
|
||||||
|
|
||||||
:returns: GeoJSON Geometry for feature
|
:returns: GeoJSON Geometry for feature
|
||||||
"""
|
"""
|
||||||
|
entity_ = entity or self.entity
|
||||||
try:
|
try:
|
||||||
if self.entity == 'Things':
|
if entity_ == 'Things':
|
||||||
return entity['Locations'][0]['location']
|
return feature['Locations'][0]['location']
|
||||||
|
|
||||||
elif self.entity == 'Observations':
|
elif entity_ == 'Observations':
|
||||||
return entity['FeatureOfInterest'].pop('feature')
|
return feature['FeatureOfInterest'].pop('feature')
|
||||||
|
|
||||||
elif self.entity == 'Datastreams':
|
elif entity_ == 'Datastreams':
|
||||||
try:
|
try:
|
||||||
return entity['Observations'][0]['FeatureOfInterest'].pop('feature') # noqa
|
return feature['Observations'][0]['FeatureOfInterest'].pop('feature') # noqa
|
||||||
except (KeyError, IndexError):
|
except (KeyError, IndexError):
|
||||||
return entity['Thing'].pop('Locations')[0]['location']
|
return feature['Thing'].pop('Locations')[0]['location']
|
||||||
|
|
||||||
|
elif entity_ == 'ObservedProperties':
|
||||||
|
return feature['Datastreams'][0]['Thing']['Locations'][0]['location'] # noqa
|
||||||
|
|
||||||
except (KeyError, IndexError):
|
except (KeyError, IndexError):
|
||||||
LOGGER.warning('No geometry found')
|
LOGGER.warning('No geometry found')
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _expand_properties(self, entity, keys=(), uri=''):
|
def _expand_properties(self, feature, keys=(), uri='',
|
||||||
|
entity=None):
|
||||||
"""
|
"""
|
||||||
Private function: Parse STA entity into feature
|
Private function: Parse STA entity into feature
|
||||||
|
|
||||||
:param entity: SensorThings entity
|
:param feature: `dict` of SensorThings entity
|
||||||
:param keys: keys used in properties block
|
:param keys: keys used in properties block
|
||||||
:param uri: uri of STA entity
|
:param uri: uri of STA entity
|
||||||
|
:param entity: SensorThings entity name
|
||||||
|
|
||||||
:returns: dict of SensorThings feature properties
|
:returns: dict of SensorThings feature properties
|
||||||
"""
|
"""
|
||||||
LOGGER.debug('Adding extra properties')
|
|
||||||
|
|
||||||
# Properties filter & display
|
# Properties filter & display
|
||||||
keys = (() if not self.properties and not keys else
|
keys = (() if not self.properties and not keys else
|
||||||
set(self.properties) | set(keys))
|
set(self.properties) | set(keys))
|
||||||
|
|
||||||
if self.entity == 'Things':
|
entity = entity or self.entity
|
||||||
self._expand_location(entity)
|
if entity == 'Things':
|
||||||
elif 'Thing' in entity.keys():
|
self._expand_location(feature)
|
||||||
self._expand_location(entity['Thing'])
|
elif 'Thing' in feature.keys():
|
||||||
|
self._expand_location(feature['Thing'])
|
||||||
|
|
||||||
# Retain URI if present
|
# Retain URI if present
|
||||||
if entity.get('properties') and self.uri_field:
|
if feature.get('properties') and self.uri_field:
|
||||||
uri = entity['properties']
|
uri = feature['properties']
|
||||||
|
|
||||||
# Create intra links
|
# Create intra links
|
||||||
LOGGER.debug('Creating intralinks')
|
for k, v in feature.items():
|
||||||
for k, v in entity.items():
|
if k in self.linked_entity:
|
||||||
if k in self.links:
|
feature[k] = [self._get_uri(_v, **self.linked_entity[k])
|
||||||
entity[k] = [self._get_uri(_v, **self.links[k]) for _v in v]
|
for _v in v]
|
||||||
LOGGER.debug(f'Created link for {k}')
|
LOGGER.debug(f'Created link for {k}')
|
||||||
elif f'{k}s' in self.links:
|
elif f'{k}s' in self.linked_entity:
|
||||||
entity[k] = self._get_uri(v, **self.links[f'{k}s'])
|
feature[k] = \
|
||||||
|
self._get_uri(v, **self.linked_entity[f'{k}s'])
|
||||||
LOGGER.debug(f'Created link for {k}')
|
LOGGER.debug(f'Created link for {k}')
|
||||||
|
|
||||||
# Make properties block
|
# Make properties block
|
||||||
LOGGER.debug('Making properties block')
|
if feature.get('properties'):
|
||||||
if entity.get('properties'):
|
feature.update(feature.pop('properties'))
|
||||||
entity.update(entity.pop('properties'))
|
|
||||||
|
|
||||||
if keys:
|
if keys:
|
||||||
ret = {k: entity.pop(k) for k in keys}
|
ret = {k: feature.pop(k) for k in keys}
|
||||||
entity = ret
|
feature = ret
|
||||||
|
|
||||||
if self.uri_field is not None and uri != '':
|
if self.uri_field is not None and uri != '':
|
||||||
entity[self.uri_field] = uri
|
feature[self.uri_field] = uri
|
||||||
|
|
||||||
return entity
|
return feature
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _expand_location(entity):
|
def _expand_location(entity):
|
||||||
@@ -517,5 +489,68 @@ class SensorThingsProvider(BaseProvider):
|
|||||||
else:
|
else:
|
||||||
return ''
|
return ''
|
||||||
|
|
||||||
|
def _generate_mappings(self, provider_def: dict):
|
||||||
|
"""
|
||||||
|
Generate mappings for the STA entity and set up intra-links.
|
||||||
|
|
||||||
|
This function sets up the necessary mappings and configurations for
|
||||||
|
the STA entity based on the provided provider definition.
|
||||||
|
|
||||||
|
:param provider_def: `dict` of provider definition containing
|
||||||
|
configuration details for the STA entity.
|
||||||
|
"""
|
||||||
|
self.data.rstrip('/')
|
||||||
|
try:
|
||||||
|
self.entity = provider_def['entity']
|
||||||
|
self._url = url_join(self.data, self.entity)
|
||||||
|
except KeyError:
|
||||||
|
LOGGER.debug('Attempting to parse Entity from provider data')
|
||||||
|
if not self._get_entity(self.data):
|
||||||
|
raise RuntimeError('Entity type required')
|
||||||
|
self.entity = self._get_entity(self.data)
|
||||||
|
self._url = self.data
|
||||||
|
self.data = self._url.rstrip(f'/{self.entity}')
|
||||||
|
|
||||||
|
# Default id
|
||||||
|
if self.id_field:
|
||||||
|
LOGGER.debug(f'Using id field: {self.id_field}')
|
||||||
|
else:
|
||||||
|
LOGGER.debug('Using default @iot.id for id field')
|
||||||
|
self.id_field = '@iot.id'
|
||||||
|
|
||||||
|
# Create intra-links
|
||||||
|
self.intralink = provider_def.get('intralink', False)
|
||||||
|
if self.intralink and provider_def.get('rel_link'):
|
||||||
|
# For pytest
|
||||||
|
self.rel_link = provider_def['rel_link']
|
||||||
|
|
||||||
|
elif self.intralink:
|
||||||
|
# Read from pygeoapi config
|
||||||
|
CONFIG = get_config()
|
||||||
|
self.rel_link = get_base_url(CONFIG)
|
||||||
|
|
||||||
|
for name, rs in CONFIG['resources'].items():
|
||||||
|
pvs = rs.get('providers')
|
||||||
|
p = get_provider_default(pvs)
|
||||||
|
e = p.get('entity') or self._get_entity(p['data'])
|
||||||
|
if any([
|
||||||
|
not pvs, # No providers in resource
|
||||||
|
not p.get('intralink'), # No configuration for intralinks
|
||||||
|
not e, # No STA entity found
|
||||||
|
self.data not in p.get('data') # No common STA endpoint
|
||||||
|
]):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if p.get('uri_field'):
|
||||||
|
LOGGER.debug(f'Linking {e} with field: {p["uri_field"]}')
|
||||||
|
else:
|
||||||
|
LOGGER.debug(f'Linking {e} with collection: {name}')
|
||||||
|
|
||||||
|
self.linked_entity[e] = {
|
||||||
|
'cnm': name, # OAPI collection name,
|
||||||
|
'cid': p.get('id_field', '@iot.id'), # OAPI id_field
|
||||||
|
'uri': p.get('uri_field') # STA uri_field
|
||||||
|
}
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f'<SensorThingsProvider> {self.data}, {self.entity}'
|
return f'<SensorThingsProvider> {self.data}, {self.entity}'
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ class SODAServiceProvider(BaseProvider):
|
|||||||
:returns: dict of fields
|
:returns: dict of fields
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if not self.fields:
|
if not self._fields:
|
||||||
|
|
||||||
try:
|
try:
|
||||||
[dataset] = self.client.datasets(ids=[self.resource_id])
|
[dataset] = self.client.datasets(ids=[self.resource_id])
|
||||||
@@ -87,9 +87,9 @@ class SODAServiceProvider(BaseProvider):
|
|||||||
fields = self.properties or resource[FIELD_NAME]
|
fields = self.properties or resource[FIELD_NAME]
|
||||||
for field in fields:
|
for field in fields:
|
||||||
idx = resource[FIELD_NAME].index(field)
|
idx = resource[FIELD_NAME].index(field)
|
||||||
self.fields[field] = {'type': resource[DATA_TYPE][idx]}
|
self._fields[field] = {'type': resource[DATA_TYPE][idx]}
|
||||||
|
|
||||||
return self.fields
|
return self._fields
|
||||||
|
|
||||||
@crs_transform
|
@crs_transform
|
||||||
def query(self, offset=0, limit=10, resulttype='results',
|
def query(self, offset=0, limit=10, resulttype='results',
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ class SQLiteGPKGProvider(BaseProvider):
|
|||||||
:returns: dict of fields
|
:returns: dict of fields
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if not self.fields:
|
if not self._fields:
|
||||||
results = self.cursor.execute(
|
results = self.cursor.execute(
|
||||||
f'PRAGMA table_info({self.table})').fetchall()
|
f'PRAGMA table_info({self.table})').fetchall()
|
||||||
for item in results:
|
for item in results:
|
||||||
@@ -100,9 +100,9 @@ class SQLiteGPKGProvider(BaseProvider):
|
|||||||
json_type = 'string'
|
json_type = 'string'
|
||||||
|
|
||||||
if json_type is not None:
|
if json_type is not None:
|
||||||
self.fields[item['name']] = {'type': json_type}
|
self._fields[item['name']] = {'type': json_type}
|
||||||
|
|
||||||
return self.fields
|
return self._fields
|
||||||
|
|
||||||
def __get_where_clauses(self, properties=[], bbox=[]):
|
def __get_where_clauses(self, properties=[], bbox=[]):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ class TinyDBProvider(BaseProvider):
|
|||||||
else:
|
else:
|
||||||
self.db = TinyDB(self.data)
|
self.db = TinyDB(self.data)
|
||||||
|
|
||||||
self.fields = self.get_fields()
|
self.get_fields()
|
||||||
|
|
||||||
def get_fields(self):
|
def get_fields(self):
|
||||||
"""
|
"""
|
||||||
@@ -83,38 +83,37 @@ class TinyDBProvider(BaseProvider):
|
|||||||
:returns: dict of fields
|
:returns: dict of fields
|
||||||
"""
|
"""
|
||||||
|
|
||||||
fields = {}
|
if not self._fields:
|
||||||
|
try:
|
||||||
|
r = self.db.all()[0]
|
||||||
|
except IndexError as err:
|
||||||
|
LOGGER.debug(err)
|
||||||
|
return {}
|
||||||
|
|
||||||
try:
|
for key, value in r['properties'].items():
|
||||||
r = self.db.all()[0]
|
if key not in self._excludes:
|
||||||
except IndexError as err:
|
typed_value = get_typed_value(str(value))
|
||||||
LOGGER.debug(err)
|
if isinstance(typed_value, float):
|
||||||
return fields
|
typed_value_type = 'number'
|
||||||
|
elif isinstance(typed_value, int):
|
||||||
for key, value in r['properties'].items():
|
typed_value_type = 'integer'
|
||||||
if key not in self._excludes:
|
|
||||||
typed_value = get_typed_value(str(value))
|
|
||||||
if isinstance(typed_value, float):
|
|
||||||
typed_value_type = 'number'
|
|
||||||
elif isinstance(typed_value, int):
|
|
||||||
typed_value_type = 'integer'
|
|
||||||
else:
|
|
||||||
typed_value_type = 'string'
|
|
||||||
|
|
||||||
fields[key] = {'type': typed_value_type}
|
|
||||||
|
|
||||||
try:
|
|
||||||
LOGGER.debug('Attempting to detect date types')
|
|
||||||
_ = parse_date(value)
|
|
||||||
if len(value) > 11:
|
|
||||||
fields[key]['format'] = 'date-time'
|
|
||||||
else:
|
else:
|
||||||
fields[key]['format'] = 'date'
|
typed_value_type = 'string'
|
||||||
except Exception:
|
|
||||||
LOGGER.debug('No date types detected')
|
|
||||||
pass
|
|
||||||
|
|
||||||
return fields
|
self._fields[key] = {'type': typed_value_type}
|
||||||
|
|
||||||
|
try:
|
||||||
|
LOGGER.debug('Attempting to detect date types')
|
||||||
|
_ = parse_date(value)
|
||||||
|
if len(value) > 11:
|
||||||
|
self._fields[key]['format'] = 'date-time'
|
||||||
|
else:
|
||||||
|
self._fields[key]['format'] = 'date'
|
||||||
|
except Exception:
|
||||||
|
LOGGER.debug('No date types detected')
|
||||||
|
pass
|
||||||
|
|
||||||
|
return self._fields
|
||||||
|
|
||||||
@crs_transform
|
@crs_transform
|
||||||
def query(self, offset=0, limit=10, resulttype='results',
|
def query(self, offset=0, limit=10, resulttype='results',
|
||||||
@@ -349,7 +348,10 @@ class TinyDBCatalogueProvider(TinyDBProvider):
|
|||||||
def __init__(self, provider_def):
|
def __init__(self, provider_def):
|
||||||
super().__init__(provider_def)
|
super().__init__(provider_def)
|
||||||
|
|
||||||
|
LOGGER.debug('Refreshing fields')
|
||||||
self._excludes = ['_metadata-anytext']
|
self._excludes = ['_metadata-anytext']
|
||||||
|
self._fields = {}
|
||||||
|
self.get_fields()
|
||||||
|
|
||||||
def get_fields(self):
|
def get_fields(self):
|
||||||
fields = super().get_fields()
|
fields = super().get_fields()
|
||||||
|
|||||||
@@ -84,7 +84,9 @@ class WMSFacadeProvider(BaseProvider):
|
|||||||
|
|
||||||
self._transparent = 'TRUE'
|
self._transparent = 'TRUE'
|
||||||
|
|
||||||
if crs in [4326, 'CRS;84']:
|
version = self.options.get('version', '1.3.0')
|
||||||
|
|
||||||
|
if crs in [4326, 'CRS;84'] and version == '1.3.0':
|
||||||
LOGGER.debug('Swapping 4326 axis order to WMS 1.3 mode (yx)')
|
LOGGER.debug('Swapping 4326 axis order to WMS 1.3 mode (yx)')
|
||||||
bbox2 = ','.join(str(c) for c in
|
bbox2 = ','.join(str(c) for c in
|
||||||
[bbox[1], bbox[0], bbox[3], bbox[2]])
|
[bbox[1], bbox[0], bbox[3], bbox[2]])
|
||||||
@@ -106,12 +108,14 @@ class WMSFacadeProvider(BaseProvider):
|
|||||||
if not transparent:
|
if not transparent:
|
||||||
self._transparent = 'FALSE'
|
self._transparent = 'FALSE'
|
||||||
|
|
||||||
|
crs_param = 'crs' if version == '1.3.0' else 'srs'
|
||||||
|
|
||||||
params = {
|
params = {
|
||||||
'version': '1.3.0',
|
'version': version,
|
||||||
'service': 'WMS',
|
'service': 'WMS',
|
||||||
'request': 'GetMap',
|
'request': 'GetMap',
|
||||||
'bbox': bbox2,
|
'bbox': bbox2,
|
||||||
'crs': CRS_CODES[crs],
|
crs_param: CRS_CODES[crs],
|
||||||
'layers': self.options['layer'],
|
'layers': self.options['layer'],
|
||||||
'styles': self.options.get('style', 'default'),
|
'styles': self.options.get('style', 'default'),
|
||||||
'width': width,
|
'width': width,
|
||||||
@@ -128,7 +132,7 @@ class WMSFacadeProvider(BaseProvider):
|
|||||||
else:
|
else:
|
||||||
request_url = '?'.join([self.data, urlencode(params)])
|
request_url = '?'.join([self.data, urlencode(params)])
|
||||||
|
|
||||||
LOGGER.debug(f'WMS 1.3.0 request url: {request_url}')
|
LOGGER.debug(f'WMS {version} request url: {request_url}')
|
||||||
|
|
||||||
response = requests.get(request_url)
|
response = requests.get(request_url)
|
||||||
|
|
||||||
|
|||||||
+210
-80
@@ -37,12 +37,16 @@ import zipfile
|
|||||||
import xarray
|
import xarray
|
||||||
import fsspec
|
import fsspec
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
import pyproj
|
||||||
|
from pyproj.exceptions import CRSError
|
||||||
|
|
||||||
|
from pygeoapi.api import DEFAULT_STORAGE_CRS
|
||||||
|
|
||||||
from pygeoapi.provider.base import (BaseProvider,
|
from pygeoapi.provider.base import (BaseProvider,
|
||||||
ProviderConnectionError,
|
ProviderConnectionError,
|
||||||
ProviderNoDataError,
|
ProviderNoDataError,
|
||||||
ProviderQueryError)
|
ProviderQueryError)
|
||||||
from pygeoapi.util import read_data
|
from pygeoapi.util import get_crs_from_uri, read_data
|
||||||
|
|
||||||
LOGGER = logging.getLogger(__name__)
|
LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -81,35 +85,43 @@ class XarrayProvider(BaseProvider):
|
|||||||
else:
|
else:
|
||||||
data_to_open = self.data
|
data_to_open = self.data
|
||||||
|
|
||||||
self._data = open_func(data_to_open)
|
try:
|
||||||
|
self._data = open_func(data_to_open)
|
||||||
|
except ValueError as err:
|
||||||
|
# Manage non-cf-compliant time dimensions
|
||||||
|
if 'time' in str(err):
|
||||||
|
self._data = open_func(self.data, decode_times=False)
|
||||||
|
else:
|
||||||
|
raise err
|
||||||
|
|
||||||
|
self.storage_crs = self._parse_storage_crs(provider_def)
|
||||||
self._coverage_properties = self._get_coverage_properties()
|
self._coverage_properties = self._get_coverage_properties()
|
||||||
|
|
||||||
self.axes = [self._coverage_properties['x_axis_label'],
|
self.axes = self._coverage_properties['axes']
|
||||||
self._coverage_properties['y_axis_label'],
|
|
||||||
self._coverage_properties['time_axis_label']]
|
|
||||||
|
|
||||||
self.fields = self.get_fields()
|
self.get_fields()
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
LOGGER.warning(err)
|
LOGGER.warning(err)
|
||||||
raise ProviderConnectionError(err)
|
raise ProviderConnectionError(err)
|
||||||
|
|
||||||
def get_fields(self):
|
def get_fields(self):
|
||||||
fields = {}
|
if not self._fields:
|
||||||
|
for key, value in self._data.variables.items():
|
||||||
|
if key not in self._data.coords:
|
||||||
|
LOGGER.debug('Adding variable')
|
||||||
|
dtype = value.dtype
|
||||||
|
if dtype.name.startswith('float'):
|
||||||
|
dtype = 'number'
|
||||||
|
elif dtype.name.startswith('int'):
|
||||||
|
dtype = 'integer'
|
||||||
|
|
||||||
for key, value in self._data.variables.items():
|
self._fields[key] = {
|
||||||
if len(value.shape) >= 3:
|
'type': dtype,
|
||||||
LOGGER.debug('Adding variable')
|
'title': value.attrs.get('long_name'),
|
||||||
dtype = value.dtype
|
'x-ogc-unit': value.attrs.get('units')
|
||||||
if dtype.name.startswith('float'):
|
}
|
||||||
dtype = 'number'
|
|
||||||
|
|
||||||
fields[key] = {
|
return self._fields
|
||||||
'type': dtype,
|
|
||||||
'title': value.attrs['long_name'],
|
|
||||||
'x-ogc-unit': value.attrs.get('units')
|
|
||||||
}
|
|
||||||
|
|
||||||
return fields
|
|
||||||
|
|
||||||
def query(self, properties=[], subsets={}, bbox=[], bbox_crs=4326,
|
def query(self, properties=[], subsets={}, bbox=[], bbox_crs=4326,
|
||||||
datetime_=None, format_='json', **kwargs):
|
datetime_=None, format_='json', **kwargs):
|
||||||
@@ -138,9 +150,9 @@ class XarrayProvider(BaseProvider):
|
|||||||
|
|
||||||
data = self._data[[*properties]]
|
data = self._data[[*properties]]
|
||||||
|
|
||||||
if any([self._coverage_properties['x_axis_label'] in subsets,
|
if any([self._coverage_properties.get('x_axis_label') in subsets,
|
||||||
self._coverage_properties['y_axis_label'] in subsets,
|
self._coverage_properties.get('y_axis_label') in subsets,
|
||||||
self._coverage_properties['time_axis_label'] in subsets,
|
self._coverage_properties.get('time_axis_label') in subsets,
|
||||||
datetime_ is not None]):
|
datetime_ is not None]):
|
||||||
|
|
||||||
LOGGER.debug('Creating spatio-temporal subset')
|
LOGGER.debug('Creating spatio-temporal subset')
|
||||||
@@ -159,18 +171,36 @@ class XarrayProvider(BaseProvider):
|
|||||||
self._coverage_properties['y_axis_label'] in subsets,
|
self._coverage_properties['y_axis_label'] in subsets,
|
||||||
len(bbox) > 0]):
|
len(bbox) > 0]):
|
||||||
msg = 'bbox and subsetting by coordinates are exclusive'
|
msg = 'bbox and subsetting by coordinates are exclusive'
|
||||||
LOGGER.warning(msg)
|
LOGGER.error(msg)
|
||||||
raise ProviderQueryError(msg)
|
raise ProviderQueryError(msg)
|
||||||
else:
|
else:
|
||||||
query_params[self._coverage_properties['x_axis_label']] = \
|
x_axis_label = self._coverage_properties['x_axis_label']
|
||||||
slice(bbox[0], bbox[2])
|
x_coords = data.coords[x_axis_label]
|
||||||
query_params[self._coverage_properties['y_axis_label']] = \
|
if x_coords.values[0] > x_coords.values[-1]:
|
||||||
slice(bbox[1], bbox[3])
|
LOGGER.debug(
|
||||||
|
'Reversing slicing of x axis from high to low'
|
||||||
|
)
|
||||||
|
query_params[x_axis_label] = slice(bbox[2], bbox[0])
|
||||||
|
else:
|
||||||
|
query_params[x_axis_label] = slice(bbox[0], bbox[2])
|
||||||
|
y_axis_label = self._coverage_properties['y_axis_label']
|
||||||
|
y_coords = data.coords[y_axis_label]
|
||||||
|
if y_coords.values[0] > y_coords.values[-1]:
|
||||||
|
LOGGER.debug(
|
||||||
|
'Reversing slicing of y axis from high to low'
|
||||||
|
)
|
||||||
|
query_params[y_axis_label] = slice(bbox[3], bbox[1])
|
||||||
|
else:
|
||||||
|
query_params[y_axis_label] = slice(bbox[1], bbox[3])
|
||||||
|
|
||||||
LOGGER.debug('bbox_crs is not currently handled')
|
LOGGER.debug('bbox_crs is not currently handled')
|
||||||
|
|
||||||
if datetime_ is not None:
|
if datetime_ is not None:
|
||||||
if self._coverage_properties['time_axis_label'] in subsets:
|
if self._coverage_properties['time_axis_label'] is None:
|
||||||
|
msg = 'Dataset does not contain a time axis'
|
||||||
|
LOGGER.error(msg)
|
||||||
|
raise ProviderQueryError(msg)
|
||||||
|
elif self._coverage_properties['time_axis_label'] in subsets:
|
||||||
msg = 'datetime and temporal subsetting are exclusive'
|
msg = 'datetime and temporal subsetting are exclusive'
|
||||||
LOGGER.error(msg)
|
LOGGER.error(msg)
|
||||||
raise ProviderQueryError(msg)
|
raise ProviderQueryError(msg)
|
||||||
@@ -192,13 +222,15 @@ class XarrayProvider(BaseProvider):
|
|||||||
LOGGER.warning(err)
|
LOGGER.warning(err)
|
||||||
raise ProviderQueryError(err)
|
raise ProviderQueryError(err)
|
||||||
|
|
||||||
if (any([data.coords[self.x_field].size == 0,
|
if any(size == 0 for size in data.sizes.values()):
|
||||||
data.coords[self.y_field].size == 0,
|
|
||||||
data.coords[self.time_field].size == 0])):
|
|
||||||
msg = 'No data found'
|
msg = 'No data found'
|
||||||
LOGGER.warning(msg)
|
LOGGER.warning(msg)
|
||||||
raise ProviderNoDataError(msg)
|
raise ProviderNoDataError(msg)
|
||||||
|
|
||||||
|
if format_ == 'json':
|
||||||
|
# json does not support float32
|
||||||
|
data = _convert_float32_to_float64(data)
|
||||||
|
|
||||||
out_meta = {
|
out_meta = {
|
||||||
'bbox': [
|
'bbox': [
|
||||||
data.coords[self.x_field].values[0],
|
data.coords[self.x_field].values[0],
|
||||||
@@ -206,18 +238,20 @@ class XarrayProvider(BaseProvider):
|
|||||||
data.coords[self.x_field].values[-1],
|
data.coords[self.x_field].values[-1],
|
||||||
data.coords[self.y_field].values[-1]
|
data.coords[self.y_field].values[-1]
|
||||||
],
|
],
|
||||||
"time": [
|
|
||||||
_to_datetime_string(data.coords[self.time_field].values[0]),
|
|
||||||
_to_datetime_string(data.coords[self.time_field].values[-1])
|
|
||||||
],
|
|
||||||
"driver": "xarray",
|
"driver": "xarray",
|
||||||
"height": data.sizes[self.y_field],
|
"height": data.sizes[self.y_field],
|
||||||
"width": data.sizes[self.x_field],
|
"width": data.sizes[self.x_field],
|
||||||
"time_steps": data.sizes[self.time_field],
|
|
||||||
"variables": {var_name: var.attrs
|
"variables": {var_name: var.attrs
|
||||||
for var_name, var in data.variables.items()}
|
for var_name, var in data.variables.items()}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if self.time_field is not None:
|
||||||
|
out_meta['time'] = [
|
||||||
|
_to_datetime_string(data.coords[self.time_field].values[0]),
|
||||||
|
_to_datetime_string(data.coords[self.time_field].values[-1]),
|
||||||
|
]
|
||||||
|
out_meta["time_steps"] = data.sizes[self.time_field]
|
||||||
|
|
||||||
LOGGER.debug('Serializing data in memory')
|
LOGGER.debug('Serializing data in memory')
|
||||||
if format_ == 'json':
|
if format_ == 'json':
|
||||||
LOGGER.debug('Creating output in CoverageJSON')
|
LOGGER.debug('Creating output in CoverageJSON')
|
||||||
@@ -226,9 +260,11 @@ class XarrayProvider(BaseProvider):
|
|||||||
LOGGER.debug('Returning data in native zarr format')
|
LOGGER.debug('Returning data in native zarr format')
|
||||||
return _get_zarr_data(data)
|
return _get_zarr_data(data)
|
||||||
else: # return data in native format
|
else: # return data in native format
|
||||||
with tempfile.TemporaryFile() as fp:
|
with tempfile.NamedTemporaryFile() as fp:
|
||||||
LOGGER.debug('Returning data in native NetCDF format')
|
LOGGER.debug('Returning data in native NetCDF format')
|
||||||
fp.write(data.to_netcdf())
|
data.to_netcdf(
|
||||||
|
fp.name
|
||||||
|
) # we need to pass a string to be able to use the "netcdf4" engine # noqa
|
||||||
fp.seek(0)
|
fp.seek(0)
|
||||||
return fp.read()
|
return fp.read()
|
||||||
|
|
||||||
@@ -238,14 +274,18 @@ class XarrayProvider(BaseProvider):
|
|||||||
|
|
||||||
:param metadata: coverage metadata
|
:param metadata: coverage metadata
|
||||||
:param data: rasterio DatasetReader object
|
:param data: rasterio DatasetReader object
|
||||||
:param fields: fields dict
|
:param fields: fields
|
||||||
|
|
||||||
:returns: dict of CoverageJSON representation
|
:returns: dict of CoverageJSON representation
|
||||||
"""
|
"""
|
||||||
|
|
||||||
LOGGER.debug('Creating CoverageJSON domain')
|
LOGGER.debug('Creating CoverageJSON domain')
|
||||||
minx, miny, maxx, maxy = metadata['bbox']
|
minx, miny, maxx, maxy = metadata['bbox']
|
||||||
mint, maxt = metadata['time']
|
|
||||||
|
selected_fields = {
|
||||||
|
key: value for key, value in self.fields.items()
|
||||||
|
if key in fields
|
||||||
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
tmp_min = data.coords[self.y_field].values[0]
|
tmp_min = data.coords[self.y_field].values[0]
|
||||||
@@ -276,11 +316,6 @@ class XarrayProvider(BaseProvider):
|
|||||||
'start': maxy,
|
'start': maxy,
|
||||||
'stop': miny,
|
'stop': miny,
|
||||||
'num': metadata['height']
|
'num': metadata['height']
|
||||||
},
|
|
||||||
self.time_field: {
|
|
||||||
'start': mint,
|
|
||||||
'stop': maxt,
|
|
||||||
'num': metadata['time_steps']
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
'referencing': [{
|
'referencing': [{
|
||||||
@@ -295,7 +330,15 @@ class XarrayProvider(BaseProvider):
|
|||||||
'ranges': {}
|
'ranges': {}
|
||||||
}
|
}
|
||||||
|
|
||||||
for key, value in self.fields.items():
|
if self.time_field is not None:
|
||||||
|
mint, maxt = metadata['time']
|
||||||
|
cj['domain']['axes'][self.time_field] = {
|
||||||
|
'start': mint,
|
||||||
|
'stop': maxt,
|
||||||
|
'num': metadata['time_steps'],
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, value in selected_fields.items():
|
||||||
parameter = {
|
parameter = {
|
||||||
'type': 'Parameter',
|
'type': 'Parameter',
|
||||||
'description': value['title'],
|
'description': value['title'],
|
||||||
@@ -313,21 +356,25 @@ class XarrayProvider(BaseProvider):
|
|||||||
cj['parameters'][key] = parameter
|
cj['parameters'][key] = parameter
|
||||||
|
|
||||||
data = data.fillna(None)
|
data = data.fillna(None)
|
||||||
data = _convert_float32_to_float64(data)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
for key, value in self.fields.items():
|
for key, value in selected_fields.items():
|
||||||
cj['ranges'][key] = {
|
cj['ranges'][key] = {
|
||||||
'type': 'NdArray',
|
'type': 'NdArray',
|
||||||
'dataType': value['type'],
|
'dataType': value['type'],
|
||||||
'axisNames': [
|
'axisNames': [
|
||||||
'y', 'x', self._coverage_properties['time_axis_label']
|
'y', 'x'
|
||||||
],
|
],
|
||||||
'shape': [metadata['height'],
|
'shape': [metadata['height'],
|
||||||
metadata['width'],
|
metadata['width']]
|
||||||
metadata['time_steps']]
|
|
||||||
}
|
}
|
||||||
cj['ranges'][key]['values'] = data[key].values.flatten().tolist() # noqa
|
cj['ranges'][key]['values'] = data[key].values.flatten().tolist() # noqa
|
||||||
|
|
||||||
|
if self.time_field is not None:
|
||||||
|
cj['ranges'][key]['axisNames'].append(
|
||||||
|
self._coverage_properties['time_axis_label']
|
||||||
|
)
|
||||||
|
cj['ranges'][key]['shape'].append(metadata['time_steps'])
|
||||||
except IndexError as err:
|
except IndexError as err:
|
||||||
LOGGER.warning(err)
|
LOGGER.warning(err)
|
||||||
raise ProviderQueryError('Invalid query parameter')
|
raise ProviderQueryError('Invalid query parameter')
|
||||||
@@ -337,6 +384,7 @@ class XarrayProvider(BaseProvider):
|
|||||||
def _get_coverage_properties(self):
|
def _get_coverage_properties(self):
|
||||||
"""
|
"""
|
||||||
Helper function to normalize coverage properties
|
Helper function to normalize coverage properties
|
||||||
|
:param provider_def: provider definition
|
||||||
|
|
||||||
:returns: `dict` of coverage properties
|
:returns: `dict` of coverage properties
|
||||||
"""
|
"""
|
||||||
@@ -372,48 +420,61 @@ class XarrayProvider(BaseProvider):
|
|||||||
self._data.coords[self.x_field].values[-1],
|
self._data.coords[self.x_field].values[-1],
|
||||||
self._data.coords[self.y_field].values[-1],
|
self._data.coords[self.y_field].values[-1],
|
||||||
],
|
],
|
||||||
'time_range': [
|
|
||||||
_to_datetime_string(
|
|
||||||
self._data.coords[self.time_field].values[0]
|
|
||||||
),
|
|
||||||
_to_datetime_string(
|
|
||||||
self._data.coords[self.time_field].values[-1]
|
|
||||||
)
|
|
||||||
],
|
|
||||||
'bbox_crs': 'http://www.opengis.net/def/crs/OGC/1.3/CRS84',
|
'bbox_crs': 'http://www.opengis.net/def/crs/OGC/1.3/CRS84',
|
||||||
'crs_type': 'GeographicCRS',
|
'crs_type': 'GeographicCRS',
|
||||||
'x_axis_label': self.x_field,
|
'x_axis_label': self.x_field,
|
||||||
'y_axis_label': self.y_field,
|
'y_axis_label': self.y_field,
|
||||||
'time_axis_label': self.time_field,
|
|
||||||
'width': self._data.sizes[self.x_field],
|
'width': self._data.sizes[self.x_field],
|
||||||
'height': self._data.sizes[self.y_field],
|
'height': self._data.sizes[self.y_field],
|
||||||
'time': self._data.sizes[self.time_field],
|
|
||||||
'time_duration': self.get_time_coverage_duration(),
|
|
||||||
'bbox_units': 'degrees',
|
'bbox_units': 'degrees',
|
||||||
'resx': np.abs(self._data.coords[self.x_field].values[1]
|
'resx': np.abs(
|
||||||
- self._data.coords[self.x_field].values[0]),
|
self._data.coords[self.x_field].values[1]
|
||||||
'resy': np.abs(self._data.coords[self.y_field].values[1]
|
- self._data.coords[self.x_field].values[0]
|
||||||
- self._data.coords[self.y_field].values[0]),
|
),
|
||||||
'restime': self.get_time_resolution()
|
'resy': np.abs(
|
||||||
|
self._data.coords[self.y_field].values[1]
|
||||||
|
- self._data.coords[self.y_field].values[0]
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
if 'crs' in self._data.variables.keys():
|
if self.time_field is not None:
|
||||||
try:
|
properties['time_axis_label'] = self.time_field
|
||||||
properties['bbox_crs'] = f'http://www.opengis.net/def/crs/OGC/1.3/{self._data.crs.epsg_code}' # noqa
|
properties['time_range'] = [
|
||||||
|
_to_datetime_string(
|
||||||
properties['inverse_flattening'] = self._data.crs.\
|
self._data.coords[self.time_field].values[0]
|
||||||
inverse_flattening
|
),
|
||||||
|
_to_datetime_string(
|
||||||
|
self._data.coords[self.time_field].values[-1]
|
||||||
|
),
|
||||||
|
]
|
||||||
|
properties['time'] = self._data.sizes[self.time_field]
|
||||||
|
properties['time_duration'] = self.get_time_coverage_duration()
|
||||||
|
properties['restime'] = self.get_time_resolution()
|
||||||
|
|
||||||
|
# Update properties based on the xarray's CRS
|
||||||
|
epsg_code = self.storage_crs.to_epsg()
|
||||||
|
LOGGER.debug(f'{epsg_code}')
|
||||||
|
if epsg_code == 4326 or self.storage_crs == 'OGC:CRS84':
|
||||||
|
pass
|
||||||
|
LOGGER.debug('Confirmed default of WGS 84')
|
||||||
|
else:
|
||||||
|
properties['bbox_crs'] = \
|
||||||
|
f'https://www.opengis.net/def/crs/EPSG/0/{epsg_code}'
|
||||||
|
properties['inverse_flattening'] = \
|
||||||
|
self.storage_crs.ellipsoid.inverse_flattening
|
||||||
|
if self.storage_crs.is_projected:
|
||||||
properties['crs_type'] = 'ProjectedCRS'
|
properties['crs_type'] = 'ProjectedCRS'
|
||||||
except AttributeError:
|
|
||||||
pass
|
LOGGER.debug(f'properties: {properties}')
|
||||||
|
|
||||||
properties['axes'] = [
|
properties['axes'] = [
|
||||||
properties['x_axis_label'],
|
properties['x_axis_label'],
|
||||||
properties['y_axis_label'],
|
properties['y_axis_label']
|
||||||
properties['time_axis_label']
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if self.time_field is not None:
|
||||||
|
properties['axes'].append(properties['time_axis_label'])
|
||||||
|
|
||||||
return properties
|
return properties
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -440,7 +501,8 @@ class XarrayProvider(BaseProvider):
|
|||||||
:returns: time resolution string
|
:returns: time resolution string
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if self._data[self.time_field].size > 1:
|
if self.time_field is not None \
|
||||||
|
and self._data[self.time_field].size > 1:
|
||||||
time_diff = (self._data[self.time_field][1] -
|
time_diff = (self._data[self.time_field][1] -
|
||||||
self._data[self.time_field][0])
|
self._data[self.time_field][0])
|
||||||
|
|
||||||
@@ -457,6 +519,9 @@ class XarrayProvider(BaseProvider):
|
|||||||
:returns: time coverage duration string
|
:returns: time coverage duration string
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
if self.time_field is None:
|
||||||
|
return None
|
||||||
|
|
||||||
dur = self._data[self.time_field][-1] - self._data[self.time_field][0]
|
dur = self._data[self.time_field][-1] - self._data[self.time_field][0]
|
||||||
ms_difference = dur.values.astype('timedelta64[ms]').astype(np.double)
|
ms_difference = dur.values.astype('timedelta64[ms]').astype(np.double)
|
||||||
|
|
||||||
@@ -472,6 +537,71 @@ class XarrayProvider(BaseProvider):
|
|||||||
|
|
||||||
return ', '.join(times)
|
return ', '.join(times)
|
||||||
|
|
||||||
|
def _parse_grid_mapping(self):
|
||||||
|
"""
|
||||||
|
Identifies grid_mapping.
|
||||||
|
|
||||||
|
:returns: name of xarray data variable that contains CRS information.
|
||||||
|
"""
|
||||||
|
LOGGER.debug('Parsing grid mapping...')
|
||||||
|
spatiotemporal_dims = (self.time_field, self.y_field, self.x_field)
|
||||||
|
LOGGER.debug(spatiotemporal_dims)
|
||||||
|
grid_mapping_name = None
|
||||||
|
for var_name, var in self._data.variables.items():
|
||||||
|
if all(dim in var.dims for dim in spatiotemporal_dims):
|
||||||
|
try:
|
||||||
|
grid_mapping_name = self._data[var_name].attrs['grid_mapping'] # noqa
|
||||||
|
LOGGER.debug(f'Grid mapping: {grid_mapping_name}')
|
||||||
|
except KeyError as err:
|
||||||
|
LOGGER.debug(err)
|
||||||
|
LOGGER.debug('No grid mapping information found.')
|
||||||
|
return grid_mapping_name
|
||||||
|
|
||||||
|
def _parse_storage_crs(
|
||||||
|
self,
|
||||||
|
provider_def: dict
|
||||||
|
) -> pyproj.CRS:
|
||||||
|
"""
|
||||||
|
Parse the storage CRS from an xarray dataset.
|
||||||
|
|
||||||
|
:param provider_def: provider definition
|
||||||
|
|
||||||
|
:returns: `pyproj.CRS` instance parsed from dataset
|
||||||
|
"""
|
||||||
|
storage_crs = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
storage_crs = provider_def['storage_crs']
|
||||||
|
crs_function = pyproj.CRS.from_user_input
|
||||||
|
except KeyError as err:
|
||||||
|
LOGGER.debug(err)
|
||||||
|
LOGGER.debug('No storage_crs found. Attempting to parse the CRS.')
|
||||||
|
|
||||||
|
if storage_crs is None:
|
||||||
|
grid_mapping = self._parse_grid_mapping()
|
||||||
|
if grid_mapping is not None:
|
||||||
|
storage_crs = self._data[grid_mapping].attrs
|
||||||
|
crs_function = pyproj.CRS.from_cf
|
||||||
|
elif 'crs' in self._data.variables.keys():
|
||||||
|
storage_crs = self._data['crs'].attrs
|
||||||
|
crs_function = pyproj.CRS.from_dict
|
||||||
|
else:
|
||||||
|
storage_crs = DEFAULT_STORAGE_CRS
|
||||||
|
crs_function = get_crs_from_uri
|
||||||
|
LOGGER.debug('Failed to parse dataset CRS. Assuming WGS84.')
|
||||||
|
|
||||||
|
LOGGER.debug(f'Parsing CRS {storage_crs} with {crs_function}')
|
||||||
|
try:
|
||||||
|
crs = crs_function(storage_crs)
|
||||||
|
except CRSError as err:
|
||||||
|
LOGGER.debug(f'Unable to parse projection with pyproj: {err}')
|
||||||
|
LOGGER.debug('Assuming default WGS84.')
|
||||||
|
crs = get_crs_from_uri(DEFAULT_STORAGE_CRS)
|
||||||
|
|
||||||
|
LOGGER.debug(crs)
|
||||||
|
|
||||||
|
return crs
|
||||||
|
|
||||||
|
|
||||||
def _to_datetime_string(datetime_obj):
|
def _to_datetime_string(datetime_obj):
|
||||||
"""
|
"""
|
||||||
@@ -554,7 +684,7 @@ def _convert_float32_to_float64(data):
|
|||||||
for var_name in data.variables:
|
for var_name in data.variables:
|
||||||
if data[var_name].dtype == 'float32':
|
if data[var_name].dtype == 'float32':
|
||||||
og_attrs = data[var_name].attrs
|
og_attrs = data[var_name].attrs
|
||||||
data[var_name] = data[var_name].astype('float64')
|
data[var_name] = data[var_name].astype('float64', copy=False)
|
||||||
data[var_name].attrs = og_attrs
|
data[var_name].attrs = og_attrs
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|||||||
@@ -81,14 +81,14 @@ class XarrayEDRProvider(BaseEDRProvider, XarrayProvider):
|
|||||||
wkt = kwargs.get('wkt')
|
wkt = kwargs.get('wkt')
|
||||||
if wkt is not None:
|
if wkt is not None:
|
||||||
LOGGER.debug('Processing WKT')
|
LOGGER.debug('Processing WKT')
|
||||||
LOGGER.debug(f'Geometry type: {wkt.type}')
|
LOGGER.debug(f'Geometry type: {wkt.geom_type}')
|
||||||
if wkt.type == 'Point':
|
if wkt.geom_type == 'Point':
|
||||||
query_params[self._coverage_properties['x_axis_label']] = wkt.x
|
query_params[self._coverage_properties['x_axis_label']] = wkt.x
|
||||||
query_params[self._coverage_properties['y_axis_label']] = wkt.y
|
query_params[self._coverage_properties['y_axis_label']] = wkt.y
|
||||||
elif wkt.type == 'LineString':
|
elif wkt.geom_type == 'LineString':
|
||||||
query_params[self._coverage_properties['x_axis_label']] = wkt.xy[0] # noqa
|
query_params[self._coverage_properties['x_axis_label']] = wkt.xy[0] # noqa
|
||||||
query_params[self._coverage_properties['y_axis_label']] = wkt.xy[1] # noqa
|
query_params[self._coverage_properties['y_axis_label']] = wkt.xy[1] # noqa
|
||||||
elif wkt.type == 'Polygon':
|
elif wkt.geom_type == 'Polygon':
|
||||||
query_params[self._coverage_properties['x_axis_label']] = slice(wkt.bounds[0], wkt.bounds[2]) # noqa
|
query_params[self._coverage_properties['x_axis_label']] = slice(wkt.bounds[0], wkt.bounds[2]) # noqa
|
||||||
query_params[self._coverage_properties['y_axis_label']] = slice(wkt.bounds[1], wkt.bounds[3]) # noqa
|
query_params[self._coverage_properties['y_axis_label']] = slice(wkt.bounds[1], wkt.bounds[3]) # noqa
|
||||||
pass
|
pass
|
||||||
@@ -109,7 +109,7 @@ class XarrayEDRProvider(BaseEDRProvider, XarrayProvider):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
if select_properties:
|
if select_properties:
|
||||||
self.fields = {k: v for k, v in self.fields.items() if k in select_properties} # noqa
|
self._fields = {k: v for k, v in self._fields.items() if k in select_properties} # noqa
|
||||||
data = self._data[[*select_properties]]
|
data = self._data[[*select_properties]]
|
||||||
else:
|
else:
|
||||||
data = self._data
|
data = self._data
|
||||||
@@ -206,7 +206,7 @@ class XarrayEDRProvider(BaseEDRProvider, XarrayProvider):
|
|||||||
LOGGER.debug(f'query parameters: {query_params}')
|
LOGGER.debug(f'query parameters: {query_params}')
|
||||||
try:
|
try:
|
||||||
if select_properties:
|
if select_properties:
|
||||||
self.fields = {k: v for k, v in self.fields.items() if k in select_properties} # noqa
|
self._fields = {k: v for k, v in self._fields.items() if k in select_properties} # noqa
|
||||||
data = self._data[[*select_properties]]
|
data = self._data[[*select_properties]]
|
||||||
else:
|
else:
|
||||||
data = self._data
|
data = self._data
|
||||||
|
|||||||
@@ -334,11 +334,7 @@ async def collection_items(request: Request, collection_id=None, item_id=None):
|
|||||||
if 'item_id' in request.path_params:
|
if 'item_id' in request.path_params:
|
||||||
item_id = request.path_params['item_id']
|
item_id = request.path_params['item_id']
|
||||||
if item_id is None:
|
if item_id is None:
|
||||||
if request.method == 'GET': # list items
|
if request.method == 'POST': # filter or manage items
|
||||||
return await execute_from_starlette(
|
|
||||||
itemtypes_api.get_collection_items, request, collection_id,
|
|
||||||
skip_valid_check=True)
|
|
||||||
elif request.method == 'POST': # filter or manage items
|
|
||||||
content_type = request.headers.get('content-type')
|
content_type = request.headers.get('content-type')
|
||||||
if content_type is not None:
|
if content_type is not None:
|
||||||
if content_type == 'application/geo+json':
|
if content_type == 'application/geo+json':
|
||||||
@@ -357,6 +353,10 @@ async def collection_items(request: Request, collection_id=None, item_id=None):
|
|||||||
itemtypes_api.manage_collection_item, request,
|
itemtypes_api.manage_collection_item, request,
|
||||||
'options', collection_id, skip_valid_check=True,
|
'options', collection_id, skip_valid_check=True,
|
||||||
)
|
)
|
||||||
|
else: # GET: list items
|
||||||
|
return await execute_from_starlette(
|
||||||
|
itemtypes_api.get_collection_items, request, collection_id,
|
||||||
|
skip_valid_check=True)
|
||||||
|
|
||||||
elif request.method == 'DELETE':
|
elif request.method == 'DELETE':
|
||||||
return await execute_from_starlette(
|
return await execute_from_starlette(
|
||||||
@@ -511,12 +511,13 @@ async def get_job_result_resource(request: Request,
|
|||||||
api_.get_job_result_resource, request, job_id, resource)
|
api_.get_job_result_resource, request, job_id, resource)
|
||||||
|
|
||||||
|
|
||||||
async def get_collection_edr_query(request: Request, collection_id=None, instance_id=None): # noqa
|
async def get_collection_edr_query(request: Request, collection_id=None, instance_id=None, location_id=None): # noqa
|
||||||
"""
|
"""
|
||||||
OGC EDR API endpoints
|
OGC EDR API endpoints
|
||||||
|
|
||||||
:param collection_id: collection identifier
|
:param collection_id: collection identifier
|
||||||
:param instance_id: instance identifier
|
:param instance_id: instance identifier
|
||||||
|
:param location_id: location id of a /locations/<location_id> query
|
||||||
|
|
||||||
:returns: HTTP response
|
:returns: HTTP response
|
||||||
"""
|
"""
|
||||||
@@ -527,10 +528,15 @@ async def get_collection_edr_query(request: Request, collection_id=None, instanc
|
|||||||
if 'instance_id' in request.path_params:
|
if 'instance_id' in request.path_params:
|
||||||
instance_id = request.path_params['instance_id']
|
instance_id = request.path_params['instance_id']
|
||||||
|
|
||||||
query_type = request["path"].split('/')[-1] # noqa
|
if 'location_id' in request.path_params:
|
||||||
|
location_id = request.path_params['location_id']
|
||||||
|
query_type = 'locations'
|
||||||
|
else:
|
||||||
|
query_type = request['path'].split('/')[-1]
|
||||||
|
|
||||||
return await execute_from_starlette(
|
return await execute_from_starlette(
|
||||||
edr_api.get_collection_edr_query, request, collection_id,
|
edr_api.get_collection_edr_query, request, collection_id,
|
||||||
instance_id, query_type,
|
instance_id, query_type, location_id,
|
||||||
skip_valid_check=True,
|
skip_valid_check=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -746,6 +752,7 @@ if CONFIG['server'].get('cors', False):
|
|||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=['*'],
|
allow_origins=['*'],
|
||||||
allow_methods=['*'],
|
allow_methods=['*'],
|
||||||
|
expose_headers=['*']
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -27,6 +27,15 @@ main {
|
|||||||
height: 400px;
|
height: 400px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#coverages-map {
|
||||||
|
width: 100%;
|
||||||
|
height: 80vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c3-tooltip-container {
|
||||||
|
z-index: 300;
|
||||||
|
}
|
||||||
|
|
||||||
/* cancel mini-css header>button uppercase */
|
/* cancel mini-css header>button uppercase */
|
||||||
header button, header [type="button"], header .button, header [role="button"] {
|
header button, header [type="button"], header .button, header [role="button"] {
|
||||||
text-transform: none;
|
text-transform: none;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="{{ (locale|lower)[:2] }}" dir="{% trans %}text_direction{% endtrans %}" >
|
||||||
<head>
|
<head>
|
||||||
<meta charset="{{ config['server']['encoding'] }}">
|
<meta charset="{{ config['server']['encoding'] }}">
|
||||||
<title>{% block title %}{% endblock %}{% if not self.title() %}{{ config['metadata']['identification']['title'] }}{% endif %}</title>
|
<title>{% block title %}{% endblock %}{% if not self.title() %}{{ config['metadata']['identification']['title'] }}{% endif %}</title>
|
||||||
@@ -37,9 +37,9 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="bg-light sticky-top border-bottom">
|
<div class="bg-light sticky-top border-bottom">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<header class="d-flex flex-wrap justify-content-center py-3">
|
<header class="d-flex flex-wrap align-items-center py-3 justify-content-between">
|
||||||
<a href="{{ config['server']['url'] }}"
|
<a href="{{ config['server']['url'] }}"
|
||||||
class="d-flex align-items-center mb-3 mb-md-0 me-md-auto text-dark text-decoration-none">
|
class="d-flex align-items-center mb-3 mb-md-0 text-dark text-decoration-none">
|
||||||
<img src="{{ config['server']['url'] }}/static/img/logo.png"
|
<img src="{{ config['server']['url'] }}/static/img/logo.png"
|
||||||
title="{{ config['metadata']['identification']['title'] }}" style="height:40px;vertical-align: middle;" /></a>
|
title="{{ config['metadata']['identification']['title'] }}" style="height:40px;vertical-align: middle;" /></a>
|
||||||
<ul class="nav nav-pills">
|
<ul class="nav nav-pills">
|
||||||
@@ -66,11 +66,11 @@
|
|||||||
{% block crumbs %}
|
{% block crumbs %}
|
||||||
<a href="{{ config['server']['url'] }}">{% trans %}Home{% endtrans %}</a>
|
<a href="{{ config['server']['url'] }}">{% trans %}Home{% endtrans %}</a>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
<span style="float:right">
|
<span style="float: inline-end">
|
||||||
{% set links_found = namespace(json=0, jsonld=0) %}
|
{% set links_found = namespace(json=0, jsonld=0) %}
|
||||||
|
|
||||||
{% for link in data['links'] %}
|
{% for link in data['links'] %}
|
||||||
{% if link['rel'] == 'alternate' and link['type'] and link['type'] in ['application/json', 'application/geo+json'] %}
|
{% if link['rel'] == 'alternate' and link['type'] and link['type'] in ['application/json', 'application/geo+json', 'application/prs.coverage+json'] %}
|
||||||
{% set links_found.json = 1 %}
|
{% set links_found.json = 1 %}
|
||||||
<a href="{{ link['href'] }}">{% trans %}json{% endtrans %}</a>
|
<a href="{{ link['href'] }}">{% trans %}json{% endtrans %}</a>
|
||||||
{% elif link['rel'] == 'alternate' and link['type'] and link['type'] == 'application/ld+json' %}
|
{% elif link['rel'] == 'alternate' and link['type'] and link['type'] == 'application/ld+json' %}
|
||||||
@@ -102,9 +102,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<footer class="sticky-bottom bg-light d-flex flex-wrap py-3 px-3 border-top">{% trans %}Powered by {% endtrans %} <a title="pygeoapi" href="https://pygeoapi.io"><img
|
<footer class="sticky-bottom bg-light d-flex justify-content-center align-items-center py-3 px-3 border-top">
|
||||||
src="{{ config['server']['url'] }}/static/img/pygeoapi.png" class="mx-1" title="pygeoapi logo"
|
<div class="text-center w-100">
|
||||||
style="height:24px;vertical-align: middle;" /></a> {{ version }}</footer>
|
{% trans %}Powered by {% endtrans %}
|
||||||
|
<a title="pygeoapi" href="https://pygeoapi.io"><img src="{{ config['server']['url'] }}/static/img/pygeoapi.png" class="mx-1" title="pygeoapi logo" style="height:24px;vertical-align: middle;" /></a>
|
||||||
|
{{ version }}
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
{% block extrafoot %}
|
{% block extrafoot %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@@ -8,56 +8,281 @@
|
|||||||
{% set col_title = link['title'] %}
|
{% set col_title = link['title'] %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
/ <a href="{{ data['items_path']}}">{% trans %}Items{% endtrans %}</a>
|
/ <a href="{{ data['query_path']}}">{% trans query_type=data.query_type %}{{ query_type }}{% endtrans %}</a>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block extrahead %}
|
{% block extrahead %}
|
||||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.3.1/dist/leaflet.css"/>
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.3.1/dist/leaflet.css"/>
|
||||||
<link rel="stylesheet" type="text/css" href="https://unpkg.com/leaflet-coverage@0.7/leaflet-coverage.css">
|
<link rel="stylesheet" type="text/css" href="https://unpkg.com/leaflet-coverage@0.7/leaflet-coverage.css">
|
||||||
<script src="https://unpkg.com/leaflet@1.3.1/dist/leaflet.js"></script>
|
<script src="https://unpkg.com/leaflet@1.3.1/dist/leaflet.js"></script>
|
||||||
|
{% if data.type == "Coverage" or data.type == "CoverageCollection" %}
|
||||||
|
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/c3@0.7.20/c3.css">
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/d3@5.16.0/dist/d3.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/c3@0.7.20/c3.js"></script>
|
||||||
<script src="https://unpkg.com/covutils@0.6/covutils.min.js"></script>
|
<script src="https://unpkg.com/covutils@0.6/covutils.min.js"></script>
|
||||||
<script src="https://unpkg.com/covjson-reader@0.16/covjson-reader.src.js"></script>
|
<script src="https://unpkg.com/covjson-reader@0.16/covjson-reader.src.js"></script>
|
||||||
<script src="https://unpkg.com/leaflet-coverage@0.7/leaflet-coverage.min.js"></script>
|
<script src="https://unpkg.com/leaflet-coverage@0.7/leaflet-coverage.min.js"></script>
|
||||||
|
{% elif data.type == "Feature" or data.type == "FeatureCollection" %}
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster/dist/MarkerCluster.css"/>
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster/dist/MarkerCluster.Default.css"/>
|
||||||
|
<script src="https://unpkg.com/leaflet.markercluster/dist/leaflet.markercluster-src.js"></script>
|
||||||
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<section id="coverage">
|
<section id="coverage">
|
||||||
<div id="items-map"></div>
|
{% if data.features or data.coverages or data.ranges or data.references %}
|
||||||
|
<div id="coverages-map"></div>
|
||||||
|
{% else %}
|
||||||
|
<div class="row col-sm-12">
|
||||||
|
<p>{% trans %}No items{% endtrans %}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extrafoot %}
|
{% block extrafoot %}
|
||||||
{% if data %}
|
{% if data %}
|
||||||
<script>
|
<script>
|
||||||
var map = L.map('items-map').setView([{{ 45 }}, {{ -75 }}], 5);
|
var map = L.map('coverages-map').setView([40, -85], 3);
|
||||||
map.addLayer(new L.TileLayer(
|
var baseLayers = {
|
||||||
|
'Map': new L.TileLayer(
|
||||||
'{{ config['server']['map']['url'] }}', {
|
'{{ config['server']['map']['url'] }}', {
|
||||||
maxZoom: 18,
|
maxZoom: 18,
|
||||||
attribution: '{{ config['server']['map']['attribution'] | safe }}'
|
attribution: '{{ config['server']['map']['attribution'] | safe }}'
|
||||||
|
}).addTo(map)
|
||||||
|
}
|
||||||
|
|
||||||
|
{% if data.type == "Coverage" or data.type == "CoverageCollection" %}
|
||||||
|
let layerControl = L.control.layers(baseLayers, {}, {collapsed: false}).addTo(map)
|
||||||
|
let layersInControl = new Set()
|
||||||
|
let coverageLayersOnMap = new Set()
|
||||||
|
let paramSync = new C.ParameterSync({
|
||||||
|
syncProperties: {
|
||||||
|
palette: (p1, p2) => p1,
|
||||||
|
paletteExtent: (e1, e2) => e1 && e2 ? [Math.min(e1[0], e2[0]), Math.max(e1[1], e2[1])] : null
|
||||||
|
}
|
||||||
|
}).on('parameterAdd', e => {
|
||||||
|
// The virtual sync layer proxies the synced palette, paletteExtent, and parameter.
|
||||||
|
// The sync layer will fire a 'remove' event if all real layers for that parameter were removed.
|
||||||
|
let layer = e.syncLayer
|
||||||
|
if (layer.palette) {
|
||||||
|
C.legend(layer, {
|
||||||
|
position: 'bottomright'
|
||||||
|
}).addTo(map)
|
||||||
}
|
}
|
||||||
));
|
|
||||||
|
|
||||||
var layers = L.control.layers(null, null, {collapsed: false}).addTo(map)
|
|
||||||
|
|
||||||
CovJSON.read(JSON.parse('{{ data | to_json | safe }}')).then(function (cov) {
|
|
||||||
cov.parameters.forEach((p) => {
|
|
||||||
var layer = C.dataLayer(cov, {parameter: p.key})
|
|
||||||
.on('afterAdd', function () {
|
|
||||||
C.legend(layer).addTo(map)
|
|
||||||
map.fitBounds(layer.getBounds())
|
|
||||||
})
|
|
||||||
.addTo(map)
|
|
||||||
layers.addOverlay(layer, p.observedProperty.label?.en)
|
|
||||||
map.setZoom(5)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
displayCovJSON(JSON.parse('{{ data | to_json | safe }}'), {display: true})
|
||||||
|
|
||||||
|
const truncateString = (str, maxLength) => {
|
||||||
|
str = str.replace(/\+/g, ' ');
|
||||||
|
return str.length > maxLength ? `${str.slice(0, maxLength - 3)}...` : str;
|
||||||
|
};
|
||||||
|
|
||||||
|
function displayCovJSON(obj, options = {}) {
|
||||||
|
map.fire('dataloading');
|
||||||
|
var layer = CovJSON.read(obj)
|
||||||
|
.then(cov => {
|
||||||
|
if (CovUtils.isDomain(cov)) {
|
||||||
|
cov = CovUtils.fromDomain(cov);
|
||||||
|
}
|
||||||
|
|
||||||
|
map.fire('dataload');
|
||||||
|
|
||||||
|
// add each parameter as a layer
|
||||||
|
let firstLayer;
|
||||||
|
|
||||||
|
let layerClazz = C.dataLayerClass(cov);
|
||||||
|
|
||||||
|
if (cov.coverages && !layerClazz) {
|
||||||
|
// generic collection
|
||||||
|
if (!cov.parameters) {
|
||||||
|
throw new Error('only coverage collections with a "parameters" property are supported');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let key of cov.parameters.keys()) {
|
||||||
|
let layers = cov.coverages
|
||||||
|
.filter(coverage => coverage.parameters.has(key))
|
||||||
|
.map(coverage => createLayer(coverage, { keys: [key] }));
|
||||||
|
layers.forEach(layer => map.fire('covlayercreate', { layer }));
|
||||||
|
let layerGroup = L.layerGroup(layers);
|
||||||
|
layersInControl.add(layerGroup);
|
||||||
|
layerControl.addOverlay(layerGroup, truncateString(key, 50));
|
||||||
|
if (!firstLayer) {
|
||||||
|
firstLayer = layerGroup;
|
||||||
|
// the following piece of code should be easier
|
||||||
|
// TODO extend layer group class in leaflet-coverage (like PointCollection) to provide single 'add' event
|
||||||
|
let addCount = 0;
|
||||||
|
for (let l of layers) {
|
||||||
|
l.on('afterAdd', () => {
|
||||||
|
coverageLayersOnMap.add(l);
|
||||||
|
++addCount;
|
||||||
|
if (addCount === layers.length) {
|
||||||
|
zoomToLayers(layers);
|
||||||
|
// FIXME is this the right place?? define event semantics!
|
||||||
|
map.fire('covlayeradd', { layer: l });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (layerClazz) {
|
||||||
|
// single coverage or a coverage collection of a specific domain type
|
||||||
|
for (let key of cov.parameters.keys()) {
|
||||||
|
let opts = { keys: [key] };
|
||||||
|
let layer = createLayer(cov, opts);
|
||||||
|
map.fire('covlayercreate', { layer });
|
||||||
|
layersInControl.add(layer);
|
||||||
|
|
||||||
|
layerControl.addOverlay(layer, truncateString(key, 50));
|
||||||
|
if (!firstLayer) {
|
||||||
|
firstLayer = layer;
|
||||||
|
layer.on('afterAdd', () => {
|
||||||
|
zoomToLayers([layer])
|
||||||
|
if (!cov.coverages) {
|
||||||
|
if (isVerticalProfile(cov) || isTimeSeries(cov)) {
|
||||||
|
layer.openPopup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
layer.on('afterAdd', () => {
|
||||||
|
coverageLayersOnMap.add(layer);
|
||||||
|
map.fire('covlayeradd', { layer });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error('unsupported or missing domain type');
|
||||||
|
}
|
||||||
|
if (options.display && firstLayer) {
|
||||||
|
map.addLayer(firstLayer);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(e => {
|
||||||
|
map.fire('dataload');
|
||||||
|
console.log(e);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLayer(cov, opts) {
|
||||||
|
let layer = C.dataLayer(cov, opts).on('afterAdd', e => {
|
||||||
|
let covLayer = e.target
|
||||||
|
|
||||||
|
// This registers the layer with the sync manager.
|
||||||
|
// By doing that, the palette and extent get unified (if existing)
|
||||||
|
// and an event gets fired if a new parameter was added.
|
||||||
|
// See the code above where ParameterSync gets instantiated.
|
||||||
|
paramSync.addLayer(covLayer)
|
||||||
|
|
||||||
|
if (!cov.coverages) {
|
||||||
|
if (covLayer.time) {
|
||||||
|
new C.TimeAxis(covLayer).addTo(map)
|
||||||
|
}
|
||||||
|
if (covLayer.vertical) {
|
||||||
|
new C.VerticalAxis(covLayer).addTo(map)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).on('dataLoad', () => map.fire('dataload'))
|
||||||
|
.on('dataLoading', () => map.fire('dataloading'))
|
||||||
|
.on('error', e => map.fire('error', { error: e.error }))
|
||||||
|
layer.on('axisChange', () => {
|
||||||
|
layer.paletteExtent = 'subset'
|
||||||
|
})
|
||||||
|
|
||||||
|
if (cov.coverages) {
|
||||||
|
if (isVerticalProfile(cov)) {
|
||||||
|
layer.bindPopupEach(coverage => new C.VerticalProfilePlot(coverage))
|
||||||
|
} else if (isTimeSeries(cov)) {
|
||||||
|
layer.bindPopupEach(coverage => new C.TimeSeriesPlot(coverage))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (isVerticalProfile(cov)) {
|
||||||
|
layer.bindPopup(new C.VerticalProfilePlot(cov))
|
||||||
|
} else if (isTimeSeries(cov)) {
|
||||||
|
layer.bindPopup(new C.TimeSeriesPlot(cov))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return layer
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoomToLayers (layers) {
|
||||||
|
let bnds = layers.map(l => l.getBounds())
|
||||||
|
let bounds = L.latLngBounds(bnds)
|
||||||
|
let opts = {
|
||||||
|
padding: L.point(10, 10)
|
||||||
|
}
|
||||||
|
if (bounds.getWest() === bounds.getEast() && bounds.getSouth() === bounds.getNorth()) {
|
||||||
|
opts.maxZoom = 5
|
||||||
|
}
|
||||||
|
map.fitBounds(bounds, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isVerticalProfile (cov) {
|
||||||
|
return cov.domainType === C.COVJSON_VERTICALPROFILE
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTimeSeries (cov) {
|
||||||
|
return cov.domainType === C.COVJSON_POINTSERIES || cov.domainType === C.COVJSON_POLYGONSERIES
|
||||||
|
}
|
||||||
|
window.api = {
|
||||||
|
map,
|
||||||
|
layers: coverageLayersOnMap
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire up coverage value popup
|
||||||
|
let valuePopup = new C.DraggableValuePopup({
|
||||||
|
className: 'leaflet-popup-draggable',
|
||||||
|
layers: [...coverageLayersOnMap]
|
||||||
})
|
})
|
||||||
|
|
||||||
map.on('click', function (e) {
|
function closeValuePopup () {
|
||||||
new C.DraggableValuePopup({
|
if (map.hasLayer(valuePopup)) {
|
||||||
layers: [layer]
|
map.closePopup(valuePopup)
|
||||||
}).setLatLng(e.latlng).openOn(map)
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// click event needed for Grid layer (can't use bindPopup there)
|
||||||
|
map.on('singleclick', e => {
|
||||||
|
valuePopup.setLatLng(e.latlng).openOn(map)
|
||||||
|
})
|
||||||
|
map.on('covlayercreate', e => {
|
||||||
|
// some layers already have a plot popup bound to it, ignore those
|
||||||
|
if (!e.layer.getPopup()) {
|
||||||
|
e.layer.bindPopup(valuePopup)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
map.on('covlayeradd', e => {
|
||||||
|
valuePopup.addCoverageLayer(e.layer)
|
||||||
|
})
|
||||||
|
map.on('covlayerremove', e => {
|
||||||
|
valuePopup.removeCoverageLayer(e.layer)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
map.on('error', e => {
|
||||||
|
if (e.error?.message) {
|
||||||
|
editor.setError(e.error.message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
{% elif data.type == "Feature" or data.type == "FeatureCollection" %}
|
||||||
|
var geojson_data = {{ data | to_json | safe }};
|
||||||
|
|
||||||
|
var items = new L.GeoJSON(geojson_data, {
|
||||||
|
onEachFeature: function (feature, layer) {
|
||||||
|
var html = '<span>' + {% if data['title_field'] %} feature['properties']['{{ data['title_field'] }}'] {% else %} feature.id {% endif %} + '</span>';
|
||||||
|
layer.bindPopup(html);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
var markers = L.markerClusterGroup({
|
||||||
|
disableClusteringAtZoom: 9,
|
||||||
|
chunkedLoading: true,
|
||||||
|
chunkInterval: 500,
|
||||||
|
});
|
||||||
|
markers.clearLayers().addLayer(items);
|
||||||
|
map.addLayer(markers);
|
||||||
|
map.fitBounds(items.getBounds(), {maxZoom: 15});
|
||||||
|
{% endif %}
|
||||||
</script>
|
</script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -22,7 +22,6 @@
|
|||||||
<section id="items"></section>
|
<section id="items"></section>
|
||||||
<section id="collection">
|
<section id="collection">
|
||||||
<h1>{% for l in data['links'] if l.rel == 'collection' %} {{ l['title'] }} {% endfor %}</h1>
|
<h1>{% for l in data['links'] if l.rel == 'collection' %} {{ l['title'] }} {% endfor %}</h1>
|
||||||
<p>{% trans %}Items in this collection{% endtrans %}.</p>
|
|
||||||
</section>
|
</section>
|
||||||
<section id="items">
|
<section id="items">
|
||||||
{% if data['features'] %}
|
{% if data['features'] %}
|
||||||
@@ -35,7 +34,9 @@
|
|||||||
<div class="col-sm-12">
|
<div class="col-sm-12">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-sm-12">
|
<div class="col-sm-12">
|
||||||
{% trans %}Warning: Higher limits not recommended!{% endtrans %}
|
{% if data['numberMatched'] %}
|
||||||
|
<p>{% trans %}Items in this collection{% endtrans %}: {{ data['numberMatched'] }}</p>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
@@ -47,6 +48,7 @@
|
|||||||
<option value="1000">1,000</option>
|
<option value="1000">1,000</option>
|
||||||
<option value="2000">2,000</option>
|
<option value="2000">2,000</option>
|
||||||
</select>
|
</select>
|
||||||
|
<p>{% trans %}Warning: Higher limits not recommended!{% endtrans %}</p>
|
||||||
<script>
|
<script>
|
||||||
var select = document.getElementById('limits');
|
var select = document.getElementById('limits');
|
||||||
var defaultValue = select.getElementsByTagName('option')[0].value;
|
var defaultValue = select.getElementsByTagName('option')[0].value;
|
||||||
@@ -134,6 +136,12 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% elif data['numberMatched'] %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-sm-12">
|
||||||
|
<p>{% trans %}Items in this collection{% endtrans %}: {{ data['numberMatched'] }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="row col-sm-12">
|
<div class="row col-sm-12">
|
||||||
<p>{% trans %}No items{% endtrans %}</p>
|
<p>{% trans %}No items{% endtrans %}</p>
|
||||||
@@ -161,7 +169,6 @@
|
|||||||
layer.bindPopup(html);
|
layer.bindPopup(html);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
{% if data['features'][0]['geometry']['type'] == 'Point' %}
|
|
||||||
var markers = L.markerClusterGroup({
|
var markers = L.markerClusterGroup({
|
||||||
disableClusteringAtZoom: 9,
|
disableClusteringAtZoom: 9,
|
||||||
chunkedLoading: true,
|
chunkedLoading: true,
|
||||||
@@ -169,9 +176,6 @@
|
|||||||
});
|
});
|
||||||
markers.clearLayers().addLayer(items);
|
markers.clearLayers().addLayer(items);
|
||||||
map.addLayer(markers);
|
map.addLayer(markers);
|
||||||
{% else %}
|
|
||||||
map.addLayer(items);
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
map.fitBounds(items.getBounds());
|
map.fitBounds(items.getBounds());
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends "_base.html" %}
|
{% extends "_base.html" %}
|
||||||
{% set ptitle = data['properties'][data['title_field']] or '_(Item) '.format(data['id']) %}
|
{% set ptitle = data['properties'][data['title_field']] or data['id'] | string %}
|
||||||
{% block desc %}{{ data.get('properties',{}).get('description', {}) | string | truncate(250) }}{% endblock %}
|
{% block desc %}{{ data.get('properties',{}).get('description', {}) | string | truncate(250) }}{% endblock %}
|
||||||
{% block tags %}{{ data['properties'].get('themes', [{}])[0].get('concepts', []) | join(',') }}{% endblock %}
|
{% block tags %}{{ data['properties'].get('themes', [{}])[0].get('concepts', []) | join(',') }}{% endblock %}
|
||||||
{# Optionally renders an img element, otherwise standard value or link rendering #}
|
{# Optionally renders an img element, otherwise standard value or link rendering #}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
{% block title %}{{ super() }} {{ data['title'] }} {% endblock %}
|
{% block title %}{{ super() }} {{ data['title'] }} {% endblock %}
|
||||||
{% block crumbs %}{{ super() }}
|
{% block crumbs %}{{ super() }}
|
||||||
/ <a href="{{ data['collections_path'] }}">{% trans %}Collections{% endtrans %}</a>
|
/ <a href="{{ data['collections_path'] }}">{% trans %}Collections{% endtrans %}</a>
|
||||||
/ <a href="./{{ data['id'] }}">{{ data['title'] | truncate( 25 ) }}</a>
|
/ <a href="{{ data['dataset_path'] }}">{{ data['title'] | truncate( 25 ) }}</a>
|
||||||
/ <a href="./{{ data['id'] }}queryables">{% trans %}Queryables{% endtrans %}</a>
|
/ <a href="{{ data['dataset_path'] }}/queryables">{% trans %}Queryables{% endtrans %}</a>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<section id="collection">
|
<section id="collection">
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
{% block title %}{{ super() }} {{ data['title'] }} {% endblock %}
|
{% block title %}{{ super() }} {{ data['title'] }} {% endblock %}
|
||||||
{% block crumbs %}{{ super() }}
|
{% block crumbs %}{{ super() }}
|
||||||
/ <a href="{{ data['collections_path'] }}">{% trans %}Collections{% endtrans %}</a>
|
/ <a href="{{ data['collections_path'] }}">{% trans %}Collections{% endtrans %}</a>
|
||||||
/ <a href="./{{ data['id'] }}">{{ data['title'] | truncate( 25 ) }}</a>
|
/ <a href="{{ data['dataset_path'] }}">{{ data['title'] | truncate( 25 ) }}</a>
|
||||||
/ <a href="./{{ data['id'] }}schema">{% trans %}Schema{% endtrans %}</a>
|
/ <a href="{{ data['dataset_path'] }}/schema">{% trans %}Schema{% endtrans %}</a>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<section id="collection-schema">
|
<section id="collection-schema">
|
||||||
|
|||||||
@@ -48,5 +48,38 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-sm-12">
|
||||||
|
{% trans %}Limit{% endtrans %}:
|
||||||
|
<select id="limits">
|
||||||
|
<option value="{{ config['server']['limit'] }}">{{ config['server']['limit'] }} ({% trans %}default{% endtrans %})</option>
|
||||||
|
<option value="100">100</option>
|
||||||
|
<option value="1000">1,000</option>
|
||||||
|
<option value="2000">2,000</option>
|
||||||
|
</select>
|
||||||
|
<script>
|
||||||
|
var select = document.getElementById('limits');
|
||||||
|
var defaultValue = select.getElementsByTagName('option')[0].value;
|
||||||
|
let params = (new URL(document.location)).searchParams;
|
||||||
|
select.value = params.get('limit') || defaultValue;
|
||||||
|
select.addEventListener('change', ev => {
|
||||||
|
var limit = ev.target.value;
|
||||||
|
document.location.search = `limit=${limit}`;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-sm-12">
|
||||||
|
{% for link in data['jobs']['links'] %}
|
||||||
|
{% if link['rel'] == 'prev' and data['offset'] > 0 %}
|
||||||
|
<a role="button" href="{{ link['href'] }}">{% trans %}Prev{% endtrans %}</a>
|
||||||
|
{% elif link['rel'] == 'next' and data['jobs']['jobs'] %}
|
||||||
|
<a role="button" href="{{ link['href'] }}">{% trans %}Next{% endtrans %}</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
+6
-1
@@ -168,7 +168,7 @@ def yaml_load(fh: IO) -> dict:
|
|||||||
# # https://stackoverflow.com/a/55301129
|
# # https://stackoverflow.com/a/55301129
|
||||||
|
|
||||||
env_matcher = re.compile(
|
env_matcher = re.compile(
|
||||||
r'.*?\$\{(?P<varname>\w+)(:-(?P<default>[^}]+))?\}')
|
r'.*?\$\{(?P<varname>\w+)(:-(?P<default>[^}]*))?\}')
|
||||||
|
|
||||||
def env_constructor(loader, node):
|
def env_constructor(loader, node):
|
||||||
result = ""
|
result = ""
|
||||||
@@ -597,6 +597,11 @@ class RequestedProcessExecutionMode(Enum):
|
|||||||
respond_async = 'respond-async'
|
respond_async = 'respond-async'
|
||||||
|
|
||||||
|
|
||||||
|
class RequestedResponse(Enum):
|
||||||
|
raw = 'raw'
|
||||||
|
document = 'document'
|
||||||
|
|
||||||
|
|
||||||
class JobStatus(Enum):
|
class JobStatus(Enum):
|
||||||
"""
|
"""
|
||||||
Enum for the job status options specified in the WPS 2.0 specification
|
Enum for the job status options specified in the WPS 2.0 specification
|
||||||
|
|||||||
@@ -6,11 +6,15 @@ elasticsearch-dsl
|
|||||||
fiona
|
fiona
|
||||||
GDAL<=3.8.4
|
GDAL<=3.8.4
|
||||||
geoalchemy2
|
geoalchemy2
|
||||||
|
geopandas
|
||||||
netCDF4
|
netCDF4
|
||||||
numpy
|
numpy==2.0.1
|
||||||
|
opensearch-dsl
|
||||||
|
opensearch-py
|
||||||
oracledb
|
oracledb
|
||||||
pandas
|
pandas
|
||||||
psycopg2
|
psycopg2
|
||||||
|
pyarrow
|
||||||
pygeofilter[backend-sqlalchemy]
|
pygeofilter[backend-sqlalchemy]
|
||||||
pygeoif
|
pygeoif
|
||||||
pygeometa
|
pygeometa
|
||||||
|
|||||||
+1
-2
@@ -14,6 +14,5 @@ PyYAML
|
|||||||
rasterio
|
rasterio
|
||||||
requests
|
requests
|
||||||
shapely
|
shapely
|
||||||
SQLAlchemy<2.0.0
|
SQLAlchemy
|
||||||
tinydb
|
tinydb
|
||||||
unicodecsv
|
|
||||||
|
|||||||
@@ -389,6 +389,9 @@ def test_api(config, api_, openapi):
|
|||||||
assert rsp_headers['Content-Language'] == 'en-US'
|
assert rsp_headers['Content-Language'] == 'en-US'
|
||||||
assert code == HTTPStatus.BAD_REQUEST
|
assert code == HTTPStatus.BAD_REQUEST
|
||||||
|
|
||||||
|
response = json.loads(response)
|
||||||
|
assert response['description'] == 'Invalid format requested'
|
||||||
|
|
||||||
assert api_.get_collections_url() == 'http://localhost:5000/collections'
|
assert api_.get_collections_url() == 'http://localhost:5000/collections'
|
||||||
|
|
||||||
|
|
||||||
@@ -572,7 +575,7 @@ def test_conformance(config, api_):
|
|||||||
|
|
||||||
assert isinstance(root, dict)
|
assert isinstance(root, dict)
|
||||||
assert 'conformsTo' in root
|
assert 'conformsTo' in root
|
||||||
assert len(root['conformsTo']) == 37
|
assert len(root['conformsTo']) == 42
|
||||||
assert 'http://www.opengis.net/spec/ogcapi-features-2/1.0/conf/crs' \
|
assert 'http://www.opengis.net/spec/ogcapi-features-2/1.0/conf/crs' \
|
||||||
in root['conformsTo']
|
in root['conformsTo']
|
||||||
|
|
||||||
@@ -601,7 +604,7 @@ def test_describe_collections(config, api_):
|
|||||||
collections = json.loads(response)
|
collections = json.loads(response)
|
||||||
|
|
||||||
assert len(collections) == 2
|
assert len(collections) == 2
|
||||||
assert len(collections['collections']) == 9
|
assert len(collections['collections']) == 10
|
||||||
assert len(collections['links']) == 3
|
assert len(collections['links']) == 3
|
||||||
|
|
||||||
rsp_headers, code, response = api_.describe_collections(req, 'foo')
|
rsp_headers, code, response = api_.describe_collections(req, 'foo')
|
||||||
|
|||||||
@@ -62,6 +62,11 @@ def test_get_collection_queryables(config, api_):
|
|||||||
api_, req, 'notfound')
|
api_, req, 'notfound')
|
||||||
assert code == HTTPStatus.NOT_FOUND
|
assert code == HTTPStatus.NOT_FOUND
|
||||||
|
|
||||||
|
req = mock_api_request()
|
||||||
|
rsp_headers, code, response = get_collection_queryables(
|
||||||
|
api_, req, 'mapserver_world_map')
|
||||||
|
assert code == HTTPStatus.BAD_REQUEST
|
||||||
|
|
||||||
req = mock_api_request({'f': 'html'})
|
req = mock_api_request({'f': 'html'})
|
||||||
rsp_headers, code, response = get_collection_queryables(api_, req, 'obs')
|
rsp_headers, code, response = get_collection_queryables(api_, req, 'obs')
|
||||||
assert rsp_headers['Content-Type'] == FORMAT_TYPES[F_HTML]
|
assert rsp_headers['Content-Type'] == FORMAT_TYPES[F_HTML]
|
||||||
@@ -74,6 +79,14 @@ def test_get_collection_queryables(config, api_):
|
|||||||
assert 'properties' in queryables
|
assert 'properties' in queryables
|
||||||
assert len(queryables['properties']) == 5
|
assert len(queryables['properties']) == 5
|
||||||
|
|
||||||
|
req = mock_api_request({'f': 'json'})
|
||||||
|
rsp_headers, code, response = get_collection_queryables(api_, req, 'canada-metadata') # noqa
|
||||||
|
assert rsp_headers['Content-Type'] == 'application/schema+json'
|
||||||
|
queryables = json.loads(response)
|
||||||
|
|
||||||
|
assert 'properties' in queryables
|
||||||
|
assert len(queryables['properties']) == 10
|
||||||
|
|
||||||
# test with provider filtered properties
|
# test with provider filtered properties
|
||||||
api_.config['resources']['obs']['providers'][0]['properties'] = ['stn_id']
|
api_.config['resources']['obs']['providers'][0]['properties'] = ['stn_id']
|
||||||
|
|
||||||
@@ -573,6 +586,13 @@ def test_get_collection_item(config, api_):
|
|||||||
assert 'prev' not in feature['links']
|
assert 'prev' not in feature['links']
|
||||||
assert 'next' not in feature['links']
|
assert 'next' not in feature['links']
|
||||||
|
|
||||||
|
req = mock_api_request()
|
||||||
|
rsp_headers, code, response = get_collection_item(api_, req, 'norway_pop',
|
||||||
|
'790')
|
||||||
|
feature = json.loads(response)
|
||||||
|
|
||||||
|
assert feature['properties']['name'] == 'Ålesund'
|
||||||
|
|
||||||
|
|
||||||
def test_get_collection_item_json_ld(config, api_):
|
def test_get_collection_item_json_ld(config, api_):
|
||||||
req = mock_api_request({'f': 'jsonld'})
|
req = mock_api_request({'f': 'jsonld'})
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ from unittest import mock
|
|||||||
|
|
||||||
from pygeoapi.api import FORMAT_TYPES, F_HTML, F_JSON
|
from pygeoapi.api import FORMAT_TYPES, F_HTML, F_JSON
|
||||||
from pygeoapi.api.processes import (
|
from pygeoapi.api.processes import (
|
||||||
describe_processes, execute_process, delete_job, get_job_result,
|
describe_processes, execute_process, delete_job, get_job_result, get_jobs
|
||||||
)
|
)
|
||||||
|
|
||||||
from tests.util import mock_api_request
|
from tests.util import mock_api_request
|
||||||
@@ -198,6 +198,12 @@ def test_execute_process(config, api_):
|
|||||||
'failedUri': 'https://example.com/failed',
|
'failedUri': 'https://example.com/failed',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
req_body_8 = {
|
||||||
|
'inputs': {
|
||||||
|
'name': 'Test document'
|
||||||
|
},
|
||||||
|
'response': 'document'
|
||||||
|
}
|
||||||
|
|
||||||
cleanup_jobs = set()
|
cleanup_jobs = set()
|
||||||
|
|
||||||
@@ -346,6 +352,14 @@ def test_execute_process(config, api_):
|
|||||||
cleanup_jobs.add(tuple(['hello-world',
|
cleanup_jobs.add(tuple(['hello-world',
|
||||||
rsp_headers['Location'].split('/')[-1]]))
|
rsp_headers['Location'].split('/')[-1]]))
|
||||||
|
|
||||||
|
req = mock_api_request(data=req_body_8)
|
||||||
|
rsp_headers, code, response = execute_process(api_, req, 'hello-world')
|
||||||
|
|
||||||
|
response = json.loads(response)
|
||||||
|
assert code == HTTPStatus.OK
|
||||||
|
assert 'outputs' in response
|
||||||
|
assert isinstance(response['outputs'], list)
|
||||||
|
|
||||||
# Cleanup
|
# Cleanup
|
||||||
time.sleep(2) # Allow time for any outstanding async jobs
|
time.sleep(2) # Allow time for any outstanding async jobs
|
||||||
for _, job_id in cleanup_jobs:
|
for _, job_id in cleanup_jobs:
|
||||||
@@ -428,4 +442,51 @@ def test_get_job_result(api_):
|
|||||||
)
|
)
|
||||||
assert code == HTTPStatus.OK
|
assert code == HTTPStatus.OK
|
||||||
assert rsp_headers['Content-Type'] == 'application/json'
|
assert rsp_headers['Content-Type'] == 'application/json'
|
||||||
assert json.loads(response)['value'] == "Hello Sync Test!"
|
assert json.loads(response)['value'] == 'Hello Sync Test!'
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_jobs_single(api_):
|
||||||
|
job_id = _execute_a_job(api_)
|
||||||
|
headers, code, response = get_jobs(api_, mock_api_request(), job_id=job_id)
|
||||||
|
assert code == HTTPStatus.OK
|
||||||
|
|
||||||
|
job = json.loads(response)
|
||||||
|
assert job['jobID'] == job_id
|
||||||
|
assert job['status'] == 'successful'
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_jobs_pagination(api_):
|
||||||
|
# generate test jobs for querying
|
||||||
|
for _ in range(11):
|
||||||
|
_execute_a_job(api_)
|
||||||
|
|
||||||
|
# test default pagination limit
|
||||||
|
headers, code, response = get_jobs(api_, mock_api_request(), job_id=None)
|
||||||
|
job_response = json.loads(response)
|
||||||
|
assert len(job_response['jobs']) == 10
|
||||||
|
assert next(
|
||||||
|
link for link in job_response['links'] if link['rel'] == 'next'
|
||||||
|
)['href'].endswith('/jobs?offset=10')
|
||||||
|
|
||||||
|
headers, code, response = get_jobs(
|
||||||
|
api_,
|
||||||
|
mock_api_request({'limit': 10, 'offset': 9}),
|
||||||
|
job_id=None)
|
||||||
|
job_response_offset = json.loads(response)
|
||||||
|
# check to get 1 same job id with an offset of 9 and limit of 10
|
||||||
|
same_job_ids = {job['jobID'] for job in job_response['jobs']}.intersection(
|
||||||
|
{job['jobID'] for job in job_response_offset['jobs']}
|
||||||
|
)
|
||||||
|
assert len(same_job_ids) == 1
|
||||||
|
assert next(
|
||||||
|
link for link in job_response_offset['links'] if link['rel'] == 'prev'
|
||||||
|
)['href'].endswith('/jobs?offset=0&limit=10')
|
||||||
|
|
||||||
|
# test custom limit
|
||||||
|
headers, code, response = get_jobs(
|
||||||
|
api_,
|
||||||
|
mock_api_request({'limit': 20}),
|
||||||
|
job_id=None)
|
||||||
|
job_response = json.loads(response)
|
||||||
|
# might be more than 11 due to test interaction
|
||||||
|
assert len(job_response['jobs']) > 10
|
||||||
|
|||||||
@@ -13,8 +13,8 @@
|
|||||||
"pretty_print": true,
|
"pretty_print": true,
|
||||||
"limit": 10,
|
"limit": 10,
|
||||||
"map": {
|
"map": {
|
||||||
"url": "https://maps.wikimedia.org/osm-intl/{z}/{x}/{y}.png",
|
"url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||||
"attribution": "<a href=\"https://wikimediafoundation.org/wiki/Maps_Terms_of_Use\">Wikimedia maps</a> | Map data © <a href=\"https://openstreetmap.org/copyright\">OpenStreetMap contributors</a>"
|
"attribution": "© <a href=\"https://openstreetmap.org/copyright\">OpenStreetMap contributors</a>"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"logging": {
|
"logging": {
|
||||||
@@ -69,4 +69,4 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -95,5 +95,5 @@ es.options(request_timeout=90).indices.create(
|
|||||||
with open(sys.argv[1], encoding='utf-8') as fh:
|
with open(sys.argv[1], encoding='utf-8') as fh:
|
||||||
d = json.load(fh)
|
d = json.load(fh)
|
||||||
|
|
||||||
# call generator function to yield features into ES build API
|
# call generator function to yield features into ES bulk API
|
||||||
helpers.bulk(es, gendata(d))
|
helpers.bulk(es, gendata(d))
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# =================================================================
|
||||||
|
#
|
||||||
|
# Authors: Tom Kralidis <tomkralidis@gmail.com>
|
||||||
|
#
|
||||||
|
# Copyright (c) 2024 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
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from opensearchpy import OpenSearch, helpers
|
||||||
|
|
||||||
|
os_ = OpenSearch(['localhost:9209'])
|
||||||
|
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
print(f'Usage: {sys.argv[0]} <path/to/data.geojson> <id-field>')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
index_name = Path(sys.argv[1]).stem.lower()
|
||||||
|
id_field = sys.argv[2]
|
||||||
|
|
||||||
|
if os_.indices.exists(index=index_name):
|
||||||
|
os_.indices.delete(index=index_name)
|
||||||
|
|
||||||
|
# index settings
|
||||||
|
body = {
|
||||||
|
'settings': {
|
||||||
|
'index': {
|
||||||
|
'number_of_shards': 1,
|
||||||
|
'number_of_replicas': 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'mappings': {
|
||||||
|
'properties': {
|
||||||
|
'geometry': {
|
||||||
|
'type': 'geo_shape'
|
||||||
|
},
|
||||||
|
'properties': {
|
||||||
|
'properties': {
|
||||||
|
'nameascii': {
|
||||||
|
'type': 'text',
|
||||||
|
'fields': {
|
||||||
|
'raw': {
|
||||||
|
'type': 'keyword'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def gendata(data):
|
||||||
|
"""
|
||||||
|
Generator function to yield features
|
||||||
|
"""
|
||||||
|
|
||||||
|
for f in data['features']:
|
||||||
|
try:
|
||||||
|
f['properties'][id_field] = int(f['properties'][id_field])
|
||||||
|
except ValueError:
|
||||||
|
f['properties'][id_field] = f['properties'][id_field]
|
||||||
|
yield {
|
||||||
|
"_index": index_name,
|
||||||
|
"_id": f['properties'][id_field],
|
||||||
|
"_source": f
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# create index
|
||||||
|
os_.indices.create(index=index_name, body=body)
|
||||||
|
|
||||||
|
with open(sys.argv[1], encoding='utf-8') as fh:
|
||||||
|
d = json.load(fh)
|
||||||
|
|
||||||
|
# call generator function to yield features into OpenSearch bulk API
|
||||||
|
helpers.bulk(os_, gendata(d))
|
||||||
@@ -41,10 +41,8 @@ server:
|
|||||||
pretty_print: true
|
pretty_print: true
|
||||||
limit: 10
|
limit: 10
|
||||||
map:
|
map:
|
||||||
url: https://maps.wikimedia.org/osm-intl/{z}/{x}/{y}.png
|
url: https://tile.openstreetmap.org/{z}/{x}/{y}.png
|
||||||
attribution: <a href="https://wikimediafoundation.org/wiki/Maps_Terms_of_Use">Wikimedia
|
attribution: '© <a href="https://openstreetmap.org/copyright">OpenStreetMap contributors</a>'
|
||||||
maps</a> | Map data © <a href="https://openstreetmap.org/copyright">OpenStreetMap
|
|
||||||
contributors</a>
|
|
||||||
# manager:
|
# manager:
|
||||||
# name: TinyDB
|
# name: TinyDB
|
||||||
# connection: /tmp/pygeoapi-process-manager.db
|
# connection: /tmp/pygeoapi-process-manager.db
|
||||||
|
|||||||
@@ -46,8 +46,8 @@ server:
|
|||||||
limit: 10
|
limit: 10
|
||||||
# templates: /path/to/templates
|
# templates: /path/to/templates
|
||||||
map:
|
map:
|
||||||
url: https://maps.wikimedia.org/osm-intl/{z}/{x}/{y}.png
|
url: https://tile.openstreetmap.org/{z}/{x}/{y}.png
|
||||||
attribution: '<a href="https://wikimediafoundation.org/wiki/Maps_Terms_of_Use">Wikimedia maps</a> | Map data © <a href="https://openstreetmap.org/copyright">OpenStreetMap contributors</a>'
|
attribution: '© <a href="https://openstreetmap.org/copyright">OpenStreetMap contributors</a>'
|
||||||
manager:
|
manager:
|
||||||
name: TinyDB
|
name: TinyDB
|
||||||
connection: /tmp/pygeoapi-test-process-manager.db
|
connection: /tmp/pygeoapi-test-process-manager.db
|
||||||
|
|||||||
@@ -44,8 +44,8 @@ server:
|
|||||||
limit: 10
|
limit: 10
|
||||||
# templates: /path/to/templates
|
# templates: /path/to/templates
|
||||||
map:
|
map:
|
||||||
url: https://maps.wikimedia.org/osm-intl/{z}/{x}/{y}.png
|
url: https://tile.openstreetmap.org/{z}/{x}/{y}.png
|
||||||
attribution: '<a href="https://wikimediafoundation.org/wiki/Maps_Terms_of_Use">Wikimedia maps</a> | Map data © <a href="https://openstreetmap.org/copyright">OpenStreetMap contributors</a>'
|
attribution: '© <a href="https://openstreetmap.org/copyright">OpenStreetMap contributors</a>'
|
||||||
manager:
|
manager:
|
||||||
name: TinyDB
|
name: TinyDB
|
||||||
connection: /tmp/pygeoapi-test-process-manager.db
|
connection: /tmp/pygeoapi-test-process-manager.db
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ server:
|
|||||||
bind:
|
bind:
|
||||||
host: 0.0.0.0
|
host: 0.0.0.0
|
||||||
port: ${PYGEOAPI_PORT}
|
port: ${PYGEOAPI_PORT}
|
||||||
url: http://localhost:5000/
|
url: ${PYGEOAPI_URL:-http://localhost:5000/}
|
||||||
mimetype: application/json; charset=UTF-8
|
mimetype: application/json; charset=UTF-8
|
||||||
encoding: utf-8
|
encoding: utf-8
|
||||||
language: en-US
|
language: en-US
|
||||||
@@ -41,8 +41,10 @@ server:
|
|||||||
limit: 10
|
limit: 10
|
||||||
# templates: /path/to/templates
|
# templates: /path/to/templates
|
||||||
map:
|
map:
|
||||||
url: https://maps.wikimedia.org/osm-intl/{z}/{x}/{y}.png
|
url: https://tile.openstreetmap.org/{z}/{x}/{y}.png
|
||||||
attribution: '<a href="https://wikimediafoundation.org/wiki/Maps_Terms_of_Use">Wikimedia maps</a> | Map data © <a href="https://openstreetmap.org/copyright">OpenStreetMap contributors</a>'
|
attribution: '© <a href="https://openstreetmap.org/copyright">OpenStreetMap contributors</a>'
|
||||||
|
api_rules: # optional API design rules to which pygeoapi should adhere
|
||||||
|
url_prefix: ${PYGEOAPI_PREFIX:-}
|
||||||
|
|
||||||
logging:
|
logging:
|
||||||
level: DEBUG
|
level: DEBUG
|
||||||
|
|||||||
@@ -44,8 +44,8 @@ server:
|
|||||||
limit: 10
|
limit: 10
|
||||||
# templates: /path/to/templates
|
# templates: /path/to/templates
|
||||||
map:
|
map:
|
||||||
url: https://maps.wikimedia.org/osm-intl/{z}/{x}/{y}.png
|
url: https://tile.openstreetmap.org/{z}/{x}/{y}.png
|
||||||
attribution: '<a href="https://wikimediafoundation.org/wiki/Maps_Terms_of_Use">Wikimedia maps</a> | Map data © <a href="https://openstreetmap.org/copyright">OpenStreetMap contributors</a>'
|
attribution: '© <a href="https://openstreetmap.org/copyright">OpenStreetMap contributors</a>'
|
||||||
manager:
|
manager:
|
||||||
name: TinyDB
|
name: TinyDB
|
||||||
connection: /tmp/pygeoapi-test-process-manager.db
|
connection: /tmp/pygeoapi-test-process-manager.db
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ server:
|
|||||||
limit: 10
|
limit: 10
|
||||||
# templates: /path/to/templates
|
# templates: /path/to/templates
|
||||||
map:
|
map:
|
||||||
url: https://maps.wikimedia.org/osm-intl/{z}/{x}/{y}.png
|
url: https://tile.openstreetmap.org/{z}/{x}/{y}.png
|
||||||
attribution: '<a href="https://wikimediafoundation.org/wiki/Maps_Terms_of_Use">Wikimedia maps</a> | Map data © <a href="https://openstreetmap.org/copyright">OpenStreetMap contributors</a>'
|
attribution: '© <a href="https://openstreetmap.org/copyright">OpenStreetMap contributors</a>'
|
||||||
|
|
||||||
logging:
|
logging:
|
||||||
level: DEBUG
|
level: DEBUG
|
||||||
|
|||||||
@@ -44,8 +44,8 @@ server:
|
|||||||
limit: 10
|
limit: 10
|
||||||
# templates: /path/to/templates
|
# templates: /path/to/templates
|
||||||
map:
|
map:
|
||||||
url: https://maps.wikimedia.org/osm-intl/{z}/{x}/{y}.png
|
url: https://tile.openstreetmap.org/{z}/{x}/{y}.png
|
||||||
attribution: '<a href="https://wikimediafoundation.org/wiki/Maps_Terms_of_Use">Wikimedia maps</a> | Map data © <a href="https://openstreetmap.org/copyright">OpenStreetMap contributors</a>'
|
attribution: '© <a href="https://openstreetmap.org/copyright">OpenStreetMap contributors</a>'
|
||||||
manager:
|
manager:
|
||||||
name: PostgreSQL
|
name: PostgreSQL
|
||||||
connection:
|
connection:
|
||||||
|
|||||||
@@ -44,8 +44,8 @@ server:
|
|||||||
limit: 10
|
limit: 10
|
||||||
# templates: /path/to/templates
|
# templates: /path/to/templates
|
||||||
map:
|
map:
|
||||||
url: https://maps.wikimedia.org/osm-intl/{z}/{x}/{y}.png
|
url: https://tile.openstreetmap.org/{z}/{x}/{y}.png
|
||||||
attribution: '<a href="https://wikimediafoundation.org/wiki/Maps_Terms_of_Use">Wikimedia maps</a> | Map data © <a href="https://openstreetmap.org/copyright">OpenStreetMap contributors</a>'
|
attribution: '© <a href="https://openstreetmap.org/copyright">OpenStreetMap contributors</a>'
|
||||||
manager:
|
manager:
|
||||||
name: TinyDB
|
name: TinyDB
|
||||||
connection: /tmp/pygeoapi-test-process-manager.db
|
connection: /tmp/pygeoapi-test-process-manager.db
|
||||||
@@ -398,6 +398,44 @@ resources:
|
|||||||
name: png
|
name: png
|
||||||
mimetype: image/png
|
mimetype: image/png
|
||||||
|
|
||||||
|
canada-metadata:
|
||||||
|
type: collection
|
||||||
|
title:
|
||||||
|
en: Open Canada sample data
|
||||||
|
fr: Exemple de donn\u00e9es Canada Ouvert
|
||||||
|
description:
|
||||||
|
en: Sample metadata records from open.canada.ca
|
||||||
|
fr: Exemples d'enregistrements de m\u00e9tadonn\u00e9es sur ouvert.canada.ca
|
||||||
|
keywords:
|
||||||
|
en:
|
||||||
|
- canada
|
||||||
|
- open data
|
||||||
|
fr:
|
||||||
|
- canada
|
||||||
|
- donn\u00e9es ouvertes
|
||||||
|
links:
|
||||||
|
- type: text/html
|
||||||
|
rel: canonical
|
||||||
|
title: information
|
||||||
|
href: https://open.canada.ca/en/open-data
|
||||||
|
hreflang: en-CA
|
||||||
|
- type: text/html
|
||||||
|
rel: alternate
|
||||||
|
title: informations
|
||||||
|
href: https://ouvert.canada.ca/fr/donnees-ouvertes
|
||||||
|
hreflang: fr-CA
|
||||||
|
extents:
|
||||||
|
spatial:
|
||||||
|
bbox: [-180,-90,180,90]
|
||||||
|
crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84
|
||||||
|
providers:
|
||||||
|
- type: record
|
||||||
|
name: TinyDBCatalogue
|
||||||
|
data: tests/data/open.canada.ca/sample-records.tinydb
|
||||||
|
id_field: externalId
|
||||||
|
time_field: created
|
||||||
|
title_field: title
|
||||||
|
|
||||||
hello-world:
|
hello-world:
|
||||||
type: process
|
type: process
|
||||||
processor:
|
processor:
|
||||||
|
|||||||
@@ -54,8 +54,19 @@ def test_config_envvars():
|
|||||||
|
|
||||||
assert isinstance(config, dict)
|
assert isinstance(config, dict)
|
||||||
assert config['server']['bind']['port'] == 5001
|
assert config['server']['bind']['port'] == 5001
|
||||||
|
assert config['server']['url'] == 'http://localhost:5000/'
|
||||||
assert config['metadata']['identification']['title'] == \
|
assert config['metadata']['identification']['title'] == \
|
||||||
'pygeoapi default instance my title'
|
'pygeoapi default instance my title'
|
||||||
|
assert config['server']['api_rules']['url_prefix'] == ''
|
||||||
|
|
||||||
|
os.environ['PYGEOAPI_URL'] = 'https://localhost:5000'
|
||||||
|
os.environ['PYGEOAPI_PREFIX'] = 'v1'
|
||||||
|
|
||||||
|
with open(get_test_file_path('pygeoapi-test-config-envvars.yml')) as fh:
|
||||||
|
config = yaml_load(fh)
|
||||||
|
|
||||||
|
assert config['server']['url'] == 'https://localhost:5000'
|
||||||
|
assert config['server']['api_rules']['url_prefix'] == 'v1'
|
||||||
|
|
||||||
os.environ.pop('PYGEOAPI_PORT')
|
os.environ.pop('PYGEOAPI_PORT')
|
||||||
|
|
||||||
|
|||||||
+29
-1
@@ -30,9 +30,11 @@ from typing import Dict
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from pygeoapi.process.base import UnknownProcessError
|
from pygeoapi.process.base import UnknownProcessError, JobNotFoundError
|
||||||
from pygeoapi.process.manager.base import get_manager
|
from pygeoapi.process.manager.base import get_manager
|
||||||
|
|
||||||
|
from .util import get_test_file_path
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def config() -> Dict:
|
def config() -> Dict:
|
||||||
@@ -41,6 +43,7 @@ def config() -> Dict:
|
|||||||
'manager': {
|
'manager': {
|
||||||
'name': 'TinyDB',
|
'name': 'TinyDB',
|
||||||
'output_dir': '/tmp',
|
'output_dir': '/tmp',
|
||||||
|
'connection': '/tmp/pygeoapi-process-manager-test.db'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
'resources': {
|
'resources': {
|
||||||
@@ -71,3 +74,28 @@ def test_get_processor_raises_exception(config):
|
|||||||
manager = get_manager(config)
|
manager = get_manager(config)
|
||||||
with pytest.raises(expected_exception=UnknownProcessError):
|
with pytest.raises(expected_exception=UnknownProcessError):
|
||||||
manager.get_processor('foo')
|
manager.get_processor('foo')
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_job_result_binary(config):
|
||||||
|
manager = get_manager(config)
|
||||||
|
nc_file = get_test_file_path("tests/data/coads_sst.nc")
|
||||||
|
job_id = "15eeae38-608c-11ef-81c8-0242ac130002"
|
||||||
|
job_metadata = {
|
||||||
|
"type": "process",
|
||||||
|
"identifier": job_id,
|
||||||
|
"process_id": "dummy",
|
||||||
|
"job_start_datetime": "2024-08-22T12:00:00.000000Z",
|
||||||
|
"job_end_datetime": "2024-08-22T12:00:01.000000Z",
|
||||||
|
"status": "successful",
|
||||||
|
"location": nc_file,
|
||||||
|
"mimetype": "application/x-netcdf",
|
||||||
|
"message": "Job complete",
|
||||||
|
"progress": 100
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
manager.get_job(job_id)
|
||||||
|
except JobNotFoundError:
|
||||||
|
manager.add_job(job_metadata)
|
||||||
|
mimetype, result = manager.get_job_result(job_id)
|
||||||
|
assert mimetype == "application/x-netcdf"
|
||||||
|
assert isinstance(result, bytes)
|
||||||
|
|||||||
@@ -0,0 +1,318 @@
|
|||||||
|
# =================================================================
|
||||||
|
#
|
||||||
|
# Authors: Tom Kralidis <tomkralidis@gmail.com>
|
||||||
|
#
|
||||||
|
# Copyright (c) 2024 Tom Kralidis
|
||||||
|
# Copyright (c) 2024 Francesco Bartoli
|
||||||
|
#
|
||||||
|
# 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 pytest
|
||||||
|
|
||||||
|
from pygeoapi.provider.base import ProviderItemNotFoundError
|
||||||
|
from pygeoapi.provider.opensearch_ import OpenSearchProvider
|
||||||
|
from pygeoapi.models.cql import CQLModel
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def config():
|
||||||
|
return {
|
||||||
|
'name': 'OpenSearch',
|
||||||
|
'type': 'feature',
|
||||||
|
'data': 'http://localhost:9209/ne_110m_populated_places_simple', # noqa
|
||||||
|
'id_field': 'geonameid'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def config_ordered_properties():
|
||||||
|
return {
|
||||||
|
'name': 'OpenSearch',
|
||||||
|
'type': 'feature',
|
||||||
|
'data': 'http://localhost:9209/ne_110m_populated_places_simple', # noqa
|
||||||
|
'id_field': 'geonameid',
|
||||||
|
'properties': [
|
||||||
|
'adm0name',
|
||||||
|
'adm1name'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def config_cql():
|
||||||
|
return {
|
||||||
|
'name': 'OpenSearch',
|
||||||
|
'type': 'feature',
|
||||||
|
'data': 'http://localhost:9209/nhsl_hazard_threat_all_indicators_s_bc', # noqa
|
||||||
|
'id_field': 'Sauid'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def between():
|
||||||
|
between_ = {
|
||||||
|
"between": {
|
||||||
|
"value": {"property": "properties.pop_max"},
|
||||||
|
"lower": 10000,
|
||||||
|
"upper": 100000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return CQLModel.parse_obj(between_)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def between_upper():
|
||||||
|
between_ = {
|
||||||
|
"between": {
|
||||||
|
"value": {"property": "properties.pop_max"},
|
||||||
|
"upper": 100000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return CQLModel.parse_obj(between_)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def between_lower():
|
||||||
|
between_ = {
|
||||||
|
"between": {
|
||||||
|
"value": {"property": "properties.pop_max"},
|
||||||
|
"lower": 10000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return CQLModel.parse_obj(between_)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def eq():
|
||||||
|
eq_ = {
|
||||||
|
"eq": [
|
||||||
|
{"property": "properties.featurecla"},
|
||||||
|
"Admin-0 capital"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return CQLModel.parse_obj(eq_)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def _and(eq, between):
|
||||||
|
and_ = {
|
||||||
|
"and": [
|
||||||
|
{
|
||||||
|
"between": {
|
||||||
|
"value": {
|
||||||
|
"property": "properties.pop_max"
|
||||||
|
},
|
||||||
|
"lower": 100000,
|
||||||
|
"upper": 1000000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"eq": [
|
||||||
|
{"property": "properties.featurecla"},
|
||||||
|
"Admin-0 capital"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return CQLModel.parse_obj(and_)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def intersects():
|
||||||
|
intersects = {"intersects": [
|
||||||
|
{"property": "geometry"},
|
||||||
|
{
|
||||||
|
"type": "Polygon",
|
||||||
|
"coordinates": [
|
||||||
|
[
|
||||||
|
[10.497565, 41.520355],
|
||||||
|
[10.497565, 43.308645],
|
||||||
|
[15.111823, 43.308645],
|
||||||
|
[15.111823, 41.520355],
|
||||||
|
[10.497565, 41.520355]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
return CQLModel.parse_obj(intersects)
|
||||||
|
|
||||||
|
|
||||||
|
def test_query(config):
|
||||||
|
p = OpenSearchProvider(config)
|
||||||
|
|
||||||
|
fields = p.get_fields()
|
||||||
|
assert len(fields) == 37
|
||||||
|
assert fields['scalerank']['type'] == 'number'
|
||||||
|
assert fields['scalerank']['format'] == 'long'
|
||||||
|
assert fields['changed']['type'] == 'number'
|
||||||
|
assert fields['changed']['format'] == 'float'
|
||||||
|
assert fields['ls_name']['type'] == 'string'
|
||||||
|
|
||||||
|
results = p.query()
|
||||||
|
assert len(results['features']) == 10
|
||||||
|
assert results['numberMatched'] == 242
|
||||||
|
assert results['numberReturned'] == 10
|
||||||
|
assert results['features'][0]['id'] == 6691831
|
||||||
|
assert results['features'][0]['properties']['nameascii'] == 'Vatican City'
|
||||||
|
|
||||||
|
results = p.query(properties=[('nameascii', 'Vatican City')])
|
||||||
|
assert len(results['features']) == 1
|
||||||
|
assert results['numberMatched'] == 1
|
||||||
|
assert results['numberReturned'] == 1
|
||||||
|
|
||||||
|
results = p.query(limit=1)
|
||||||
|
assert len(results['features']) == 1
|
||||||
|
assert results['features'][0]['id'] == 6691831
|
||||||
|
|
||||||
|
results = p.query(offset=2, limit=1)
|
||||||
|
assert len(results['features']) == 1
|
||||||
|
assert results['features'][0]['id'] == 3042030
|
||||||
|
|
||||||
|
results = p.query(sortby=[{'property': 'nameascii', 'order': '+'}])
|
||||||
|
assert results['features'][0]['properties']['nameascii'] == 'Abidjan'
|
||||||
|
|
||||||
|
results = p.query(sortby=[{'property': 'nameascii', 'order': '-'}])
|
||||||
|
assert results['features'][0]['properties']['nameascii'] == 'Zagreb'
|
||||||
|
|
||||||
|
results = p.query(sortby=[{'property': 'scalerank', 'order': '+'}])
|
||||||
|
assert results['features'][0]['properties']['scalerank'] == 0
|
||||||
|
|
||||||
|
results = p.query(sortby=[{'property': 'scalerank', 'order': '-'}])
|
||||||
|
assert results['features'][0]['properties']['scalerank'] == 8
|
||||||
|
|
||||||
|
assert len(results['features'][0]['properties']) == 37
|
||||||
|
|
||||||
|
results = p.query(sortby=[{'property': 'nameascii', 'order': '-'}],
|
||||||
|
limit=10001)
|
||||||
|
assert results['features'][0]['properties']['nameascii'] == 'Zagreb'
|
||||||
|
assert len(results['features']) == 242
|
||||||
|
assert results['numberMatched'] == 242
|
||||||
|
assert results['numberReturned'] == 242
|
||||||
|
|
||||||
|
results = p.query(select_properties=['nameascii'])
|
||||||
|
assert len(results['features'][0]['properties']) == 1
|
||||||
|
|
||||||
|
results = p.query(select_properties=['nameascii', 'scalerank'])
|
||||||
|
assert len(results['features'][0]['properties']) == 2
|
||||||
|
|
||||||
|
results = p.query(skip_geometry=True)
|
||||||
|
assert results['features'][0]['geometry'] is None
|
||||||
|
|
||||||
|
config['properties'] = ['nameascii']
|
||||||
|
p = OpenSearchProvider(config)
|
||||||
|
results = p.query()
|
||||||
|
assert len(results['features'][0]['properties']) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_query_ordered_properties(config_ordered_properties):
|
||||||
|
p = OpenSearchProvider(config_ordered_properties)
|
||||||
|
|
||||||
|
result = p.query()
|
||||||
|
feature_properties = list(result['features'][0]['properties'].keys())
|
||||||
|
|
||||||
|
assert feature_properties == ['adm0name', 'adm1name']
|
||||||
|
|
||||||
|
|
||||||
|
def test_get(config):
|
||||||
|
p = OpenSearchProvider(config)
|
||||||
|
|
||||||
|
result = p.get('3413829')
|
||||||
|
assert result['id'] == 3413829
|
||||||
|
assert result['properties']['ls_name'] == 'Reykjavik'
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_not_existing_item_raise_exception(config):
|
||||||
|
"""Testing query for a not existing object"""
|
||||||
|
p = OpenSearchProvider(config)
|
||||||
|
with pytest.raises(ProviderItemNotFoundError):
|
||||||
|
p.get('404')
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_cql_json_between_query(config, between):
|
||||||
|
"""Testing cql json query for a between object"""
|
||||||
|
p = OpenSearchProvider(config)
|
||||||
|
|
||||||
|
results = p.query(limit=100, filterq=between)
|
||||||
|
|
||||||
|
assert len(results['features']) == 23
|
||||||
|
assert results['numberMatched'] == 23
|
||||||
|
assert results['numberReturned'] == 23
|
||||||
|
|
||||||
|
for item in results['features']:
|
||||||
|
assert 10000 <= item["properties"]["pop_max"] <= 100000
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_cql_json_between_lte_query(config, between_upper):
|
||||||
|
"""Testing cql json query for a between object"""
|
||||||
|
p = OpenSearchProvider(config)
|
||||||
|
|
||||||
|
results = p.query(limit=100, filterq=between_upper)
|
||||||
|
|
||||||
|
assert len(results['features']) == 28
|
||||||
|
assert results['numberMatched'] == 28
|
||||||
|
assert results['numberReturned'] == 28
|
||||||
|
|
||||||
|
for item in results['features']:
|
||||||
|
assert item["properties"]["pop_max"] <= 100000
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_cql_json_between_gte_query(config, between_lower):
|
||||||
|
"""Testing cql json query for a between object"""
|
||||||
|
p = OpenSearchProvider(config)
|
||||||
|
|
||||||
|
results = p.query(limit=500, filterq=between_lower)
|
||||||
|
|
||||||
|
assert len(results['features']) == 237
|
||||||
|
assert results['numberMatched'] == 237
|
||||||
|
assert results['numberReturned'] == 237
|
||||||
|
|
||||||
|
for item in results['features']:
|
||||||
|
assert 10000 <= item["properties"]["pop_max"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_cql_json_eq_query(config, eq):
|
||||||
|
"""Testing cql json query for an eq object"""
|
||||||
|
p = OpenSearchProvider(config)
|
||||||
|
|
||||||
|
results = p.query(limit=500, filterq=eq)
|
||||||
|
|
||||||
|
assert len(results['features']) == 235
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_cql_json_and_query(config, _and):
|
||||||
|
"""Testing cql json query for an and object"""
|
||||||
|
p = OpenSearchProvider(config)
|
||||||
|
|
||||||
|
results = p.query(limit=1000, filterq=_and)
|
||||||
|
|
||||||
|
assert len(results['features']) == 77
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_cql_json_intersects_query(config, intersects):
|
||||||
|
"""Testing cql json query for an intersects object"""
|
||||||
|
p = OpenSearchProvider(config)
|
||||||
|
|
||||||
|
results = p.query(limit=100, filterq=intersects)
|
||||||
|
|
||||||
|
assert len(results['features']) == 2
|
||||||
@@ -62,8 +62,11 @@ class SqlManipulator:
|
|||||||
q,
|
q,
|
||||||
language,
|
language,
|
||||||
filterq,
|
filterq,
|
||||||
|
extra_params
|
||||||
):
|
):
|
||||||
sql = "ID = 10 AND :foo != :bar"
|
sql = "ID = 10 AND :foo != :bar"
|
||||||
|
if extra_params.get("custom-auth") == "forbidden":
|
||||||
|
sql = f"{sql} AND 'auth' = 'you are not allowed'"
|
||||||
|
|
||||||
if sql_query.find(" WHERE ") == -1:
|
if sql_query.find(" WHERE ") == -1:
|
||||||
sql_query = sql_query.replace("#WHERE#", f" WHERE {sql}")
|
sql_query = sql_query.replace("#WHERE#", f" WHERE {sql}")
|
||||||
@@ -632,6 +635,15 @@ def test_query_mandatory_properties_must_be_specified(config):
|
|||||||
p.query(properties=[("id", "123")])
|
p.query(properties=[("id", "123")])
|
||||||
|
|
||||||
|
|
||||||
|
def test_extra_params_are_passed_to_sql_manipulator(config_manipulator):
|
||||||
|
extra_params = [("custom-auth", "forbidden")]
|
||||||
|
|
||||||
|
p = OracleProvider(config_manipulator)
|
||||||
|
response = p.query(properties=extra_params)
|
||||||
|
|
||||||
|
assert not response['features']
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def database_connection_pool(config_db_conn):
|
def database_connection_pool(config_db_conn):
|
||||||
os.environ["ORACLE_POOL_MIN"] = "2" # noqa: F841
|
os.environ["ORACLE_POOL_MIN"] = "2" # noqa: F841
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
# =================================================================
|
||||||
|
#
|
||||||
|
# Authors: Leo Ghignone <leo.ghignone@gmail.com>
|
||||||
|
#
|
||||||
|
# Copyright (c) 2024 Leo Ghignone
|
||||||
|
#
|
||||||
|
# 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 pytest
|
||||||
|
|
||||||
|
from pygeoapi.provider.base import ProviderItemNotFoundError
|
||||||
|
from pygeoapi.provider.parquet import ParquetProvider
|
||||||
|
|
||||||
|
from .util import get_test_file_path
|
||||||
|
|
||||||
|
path = get_test_file_path(
|
||||||
|
'data/random.parquet')
|
||||||
|
|
||||||
|
path_nogeom = get_test_file_path(
|
||||||
|
'data/random_nogeom.parquet')
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def config_parquet():
|
||||||
|
return {
|
||||||
|
'name': 'Parquet',
|
||||||
|
'type': 'feature',
|
||||||
|
'data': {
|
||||||
|
'source_type': 'Parquet',
|
||||||
|
'source': path,
|
||||||
|
},
|
||||||
|
'id_field': 'id',
|
||||||
|
'time_field': 'time',
|
||||||
|
'x_field': 'lon',
|
||||||
|
'y_field': 'lat',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def config_parquet_nogeom_notime():
|
||||||
|
return {
|
||||||
|
'name': 'ParquetNoGeomNoTime',
|
||||||
|
'type': 'feature',
|
||||||
|
'data': {
|
||||||
|
'source_type': 'Parquet',
|
||||||
|
'source': path_nogeom,
|
||||||
|
},
|
||||||
|
'id_field': 'id'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_fields(config_parquet):
|
||||||
|
"""Testing field types"""
|
||||||
|
|
||||||
|
p = ParquetProvider(config_parquet)
|
||||||
|
results = p.get_fields()
|
||||||
|
assert results['lat']['type'] == 'number'
|
||||||
|
assert results['lon']['format'] == 'double'
|
||||||
|
assert results['time']['format'] == 'date-time'
|
||||||
|
|
||||||
|
|
||||||
|
def test_get(config_parquet):
|
||||||
|
"""Testing query for a specific object"""
|
||||||
|
|
||||||
|
p = ParquetProvider(config_parquet)
|
||||||
|
result = p.get('42')
|
||||||
|
assert result['id'] == '42'
|
||||||
|
assert result['properties']['lon'] == 4.947447
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_not_existing_feature_raise_exception(
|
||||||
|
config_parquet
|
||||||
|
):
|
||||||
|
"""Testing query for a not existing object"""
|
||||||
|
p = ParquetProvider(config_parquet)
|
||||||
|
with pytest.raises(ProviderItemNotFoundError):
|
||||||
|
p.get(-1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_query_hits(config_parquet):
|
||||||
|
"""Testing query on entire collection for hits"""
|
||||||
|
|
||||||
|
p = ParquetProvider(config_parquet)
|
||||||
|
feature_collection = p.query(resulttype='hits')
|
||||||
|
assert feature_collection.get('type') == 'FeatureCollection'
|
||||||
|
features = feature_collection.get('features')
|
||||||
|
assert len(features) == 0
|
||||||
|
hits = feature_collection.get('numberMatched')
|
||||||
|
assert hits is not None
|
||||||
|
assert hits == 100
|
||||||
|
|
||||||
|
|
||||||
|
def test_query_bbox_hits(config_parquet):
|
||||||
|
"""Testing query for a valid JSON object with geometry"""
|
||||||
|
|
||||||
|
p = ParquetProvider(config_parquet)
|
||||||
|
feature_collection = p.query(
|
||||||
|
bbox=[100, -50, 150, 0],
|
||||||
|
resulttype='hits')
|
||||||
|
assert feature_collection.get('type') == 'FeatureCollection'
|
||||||
|
features = feature_collection.get('features')
|
||||||
|
assert len(features) == 0
|
||||||
|
hits = feature_collection.get('numberMatched')
|
||||||
|
assert hits is not None
|
||||||
|
assert hits == 6
|
||||||
|
|
||||||
|
|
||||||
|
def test_query_with_limit(config_parquet):
|
||||||
|
"""Testing query for a valid JSON object with geometry"""
|
||||||
|
|
||||||
|
p = ParquetProvider(config_parquet)
|
||||||
|
feature_collection = p.query(limit=2, resulttype='results')
|
||||||
|
assert feature_collection.get('type') == 'FeatureCollection'
|
||||||
|
features = feature_collection.get('features')
|
||||||
|
assert len(features) == 2
|
||||||
|
hits = feature_collection.get('numberMatched')
|
||||||
|
assert hits > 2
|
||||||
|
feature = features[0]
|
||||||
|
properties = feature.get('properties')
|
||||||
|
assert properties is not None
|
||||||
|
geometry = feature.get('geometry')
|
||||||
|
assert geometry is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_query_with_offset(config_parquet):
|
||||||
|
"""Testing query for a valid JSON object with geometry"""
|
||||||
|
|
||||||
|
p = ParquetProvider(config_parquet)
|
||||||
|
feature_collection = p.query(offset=20, limit=10, resulttype='results')
|
||||||
|
assert feature_collection.get('type') == 'FeatureCollection'
|
||||||
|
features = feature_collection.get('features')
|
||||||
|
assert len(features) == 10
|
||||||
|
hits = feature_collection.get('numberMatched')
|
||||||
|
assert hits > 30
|
||||||
|
feature = features[0]
|
||||||
|
properties = feature.get('properties')
|
||||||
|
assert properties is not None
|
||||||
|
assert feature['id'] == '21'
|
||||||
|
assert properties['lat'] == 66.264988
|
||||||
|
geometry = feature.get('geometry')
|
||||||
|
assert geometry is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_query_with_property(config_parquet):
|
||||||
|
"""Testing query for a valid JSON object with property filter"""
|
||||||
|
|
||||||
|
p = ParquetProvider(config_parquet)
|
||||||
|
feature_collection = p.query(
|
||||||
|
resulttype='results',
|
||||||
|
properties=[('lon', -12.855022)])
|
||||||
|
assert feature_collection.get('type') == 'FeatureCollection'
|
||||||
|
features = feature_collection.get('features')
|
||||||
|
assert len(features) == 1
|
||||||
|
for feature in features:
|
||||||
|
assert feature['properties']['lon'] == -12.855022
|
||||||
|
|
||||||
|
|
||||||
|
def test_query_with_skip_geometry(config_parquet):
|
||||||
|
"""Testing query for a valid JSON object with property filter"""
|
||||||
|
|
||||||
|
p = ParquetProvider(config_parquet)
|
||||||
|
feature_collection = p.query(skip_geometry=True)
|
||||||
|
for feature in feature_collection['features']:
|
||||||
|
assert feature.get('geometry') is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_query_with_datetime(config_parquet):
|
||||||
|
"""Testing query for a valid JSON object with time"""
|
||||||
|
|
||||||
|
p = ParquetProvider(config_parquet)
|
||||||
|
feature_collection = p.query(
|
||||||
|
datetime_='2022-05-01T00:00:00Z/2022-05-31T23:59:59Z')
|
||||||
|
assert feature_collection.get('type') == 'FeatureCollection'
|
||||||
|
features = feature_collection.get('features')
|
||||||
|
assert len(features) == 7
|
||||||
|
for feature in feature_collection['features']:
|
||||||
|
time = feature['properties'][config_parquet['time_field']]
|
||||||
|
assert time.year == 2022
|
||||||
|
assert time.month == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_query_nogeom(config_parquet_nogeom_notime):
|
||||||
|
"""Testing query for a valid JSON object without geometry"""
|
||||||
|
|
||||||
|
p = ParquetProvider(config_parquet_nogeom_notime)
|
||||||
|
feature_collection = p.query(resulttype='results')
|
||||||
|
assert feature_collection.get('type') == 'FeatureCollection'
|
||||||
|
assert len(feature_collection.get('features')) > 0
|
||||||
|
for feature in feature_collection['features']:
|
||||||
|
assert feature.get('geometry') is None
|
||||||
@@ -103,6 +103,15 @@ def _create_delete_request(job_id, locales):
|
|||||||
return APIRequest.with_data(req, locales)
|
return APIRequest.with_data(req, locales)
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_connection_rfc3986(config, openapi):
|
||||||
|
connection = config['server']['manager']['connection']
|
||||||
|
connection_string = (
|
||||||
|
f"postgresql://{connection['user']}:{connection['password']}"
|
||||||
|
f"@{connection['host']}:{connection['port']}/{connection['database']}")
|
||||||
|
config['server']['manager']['connection'] = connection_string
|
||||||
|
API(config, openapi)
|
||||||
|
|
||||||
|
|
||||||
def test_job_sync_hello_world(api_, config):
|
def test_job_sync_hello_world(api_, config):
|
||||||
"""
|
"""
|
||||||
Create a new job for hello-world,
|
Create a new job for hello-world,
|
||||||
|
|||||||
@@ -556,7 +556,7 @@ def test_get_collection_items_postgresql_cql_bad_cql(pg_api_, bad_cql):
|
|||||||
assert code == HTTPStatus.BAD_REQUEST
|
assert code == HTTPStatus.BAD_REQUEST
|
||||||
error_response = json.loads(response)
|
error_response = json.loads(response)
|
||||||
assert error_response['code'] == 'InvalidParameterValue'
|
assert error_response['code'] == 'InvalidParameterValue'
|
||||||
assert error_response['description'] == f'Bad CQL string : {bad_cql}'
|
assert error_response['description'] == 'Bad CQL text'
|
||||||
|
|
||||||
|
|
||||||
def test_post_collection_items_postgresql_cql(pg_api_):
|
def test_post_collection_items_postgresql_cql(pg_api_):
|
||||||
@@ -642,7 +642,7 @@ def test_post_collection_items_postgresql_cql_bad_cql(pg_api_, bad_cql):
|
|||||||
assert code == HTTPStatus.BAD_REQUEST
|
assert code == HTTPStatus.BAD_REQUEST
|
||||||
error_response = json.loads(response)
|
error_response = json.loads(response)
|
||||||
assert error_response['code'] == 'InvalidParameterValue'
|
assert error_response['code'] == 'InvalidParameterValue'
|
||||||
assert error_response['description'].startswith('Bad CQL string')
|
assert error_response['description'] == 'Bad CQL text'
|
||||||
|
|
||||||
|
|
||||||
def test_get_collection_items_postgresql_crs(pg_api_):
|
def test_get_collection_items_postgresql_crs(pg_api_):
|
||||||
|
|||||||
+1
-1
@@ -172,7 +172,7 @@ def test_path_basename():
|
|||||||
def test_filter_dict_by_key_value(config):
|
def test_filter_dict_by_key_value(config):
|
||||||
collections = util.filter_dict_by_key_value(config['resources'],
|
collections = util.filter_dict_by_key_value(config['resources'],
|
||||||
'type', 'collection')
|
'type', 'collection')
|
||||||
assert len(collections) == 9
|
assert len(collections) == 10
|
||||||
|
|
||||||
notfound = util.filter_dict_by_key_value(config['resources'],
|
notfound = util.filter_dict_by_key_value(config['resources'],
|
||||||
'type', 'foo')
|
'type', 'foo')
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
from numpy import float64, int64
|
from numpy import float64, int64
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
import xarray as xr
|
||||||
|
|
||||||
from pygeoapi.provider.xarray_ import XarrayProvider
|
from pygeoapi.provider.xarray_ import XarrayProvider
|
||||||
from pygeoapi.util import json_serial
|
from pygeoapi.util import json_serial
|
||||||
@@ -53,6 +54,20 @@ def config():
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def config_no_time(tmp_path):
|
||||||
|
ds = xr.open_zarr(path)
|
||||||
|
ds = ds.sel(time=ds.time[0])
|
||||||
|
ds = ds.drop_vars('time')
|
||||||
|
ds.to_zarr(tmp_path / 'no_time.zarr')
|
||||||
|
return {
|
||||||
|
'name': 'zarr',
|
||||||
|
'type': 'coverage',
|
||||||
|
'data': str(tmp_path / 'no_time.zarr'),
|
||||||
|
'format': {'name': 'zarr', 'mimetype': 'application/zip'},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_provider(config):
|
def test_provider(config):
|
||||||
p = XarrayProvider(config)
|
p = XarrayProvider(config)
|
||||||
|
|
||||||
@@ -85,3 +100,14 @@ def test_numpy_json_serial():
|
|||||||
|
|
||||||
d = float64(500.00000005)
|
d = float64(500.00000005)
|
||||||
assert json_serial(d) == 500.00000005
|
assert json_serial(d) == 500.00000005
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_time(config_no_time):
|
||||||
|
p = XarrayProvider(config_no_time)
|
||||||
|
|
||||||
|
assert len(p.fields) == 4
|
||||||
|
assert p.axes == ['lon', 'lat']
|
||||||
|
|
||||||
|
coverage = p.query(format='json')
|
||||||
|
|
||||||
|
assert sorted(coverage['domain']['axes'].keys()) == ['x', 'y']
|
||||||
|
|||||||
Reference in New Issue
Block a user