Compare commits
66 Commits
polishing_more
...
master
| 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
|
||||
|
||||
#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
|
||||
node port: 9300
|
||||
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
|
||||
uses: supercharge/mongodb-github-action@1.5.0
|
||||
with:
|
||||
@@ -94,13 +100,12 @@ jobs:
|
||||
pip3 install -r requirements-manager.txt
|
||||
pip3 install -r requirements-django.txt
|
||||
python3 setup.py install
|
||||
pip3 install --upgrade numpy elasticsearch
|
||||
pip3 install --upgrade numpy "sqlalchemy<2"
|
||||
pip3 install --global-option=build_ext --global-option="-I/usr/include/gdal" GDAL==`gdal-config --version`
|
||||
#pip3 install --upgrade rasterio==1.1.8
|
||||
- name: setup test data ⚙️
|
||||
run: |
|
||||
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
|
||||
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
|
||||
@@ -119,6 +124,7 @@ jobs:
|
||||
pytest tests/test_csv__provider.py
|
||||
pytest tests/test_django.py
|
||||
pytest tests/test_elasticsearch__provider.py
|
||||
pytest tests/test_opensearch__provider.py
|
||||
pytest tests/test_esri_provider.py
|
||||
pytest tests/test_filesystem_provider.py
|
||||
pytest tests/test_geojson_provider.py
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
working-directory: .
|
||||
steps:
|
||||
- name: Checkout pygeoapi
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@master
|
||||
- name: Scan vulnerabilities with trivy
|
||||
uses: aquasecurity/trivy-action@master
|
||||
with:
|
||||
@@ -37,6 +37,9 @@ jobs:
|
||||
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
|
||||
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:
|
||||
scan-type: image
|
||||
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>"
|
||||
|
||||
@@ -98,7 +98,6 @@ ENV TZ=${TZ} \
|
||||
python3-greenlet \
|
||||
python3-pip \
|
||||
python3-tz \
|
||||
python3-unicodecsv \
|
||||
python3-yaml \
|
||||
${ADD_DEB_PACKAGES}"
|
||||
|
||||
|
||||
@@ -48,8 +48,8 @@ server:
|
||||
limit: 10
|
||||
# templates: /path/to/templates
|
||||
map:
|
||||
url: https://maps.wikimedia.org/osm-intl/{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>'
|
||||
url: https://tile.openstreetmap.org/{z}/{x}/{y}.png
|
||||
attribution: '© <a href="https://openstreetmap.org/copyright">OpenStreetMap contributors</a>'
|
||||
ogc_schemas_location: /schemas.opengis.net
|
||||
|
||||
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">
|
||||
<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 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">
|
||||
<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 title="FOSS4G Conference" href="https://2023.foss4g.org">
|
||||
<img style="background: white;" alt="FOSS4G Conference" width="145" height="45" src="https://2023.foss4g.org/img/logo.png"/>
|
||||
<a title="FOSS4G Conference" href="https://2024.foss4g.org">
|
||||
<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>
|
||||
</p>
|
||||
|
||||
|
||||
@@ -14,6 +14,15 @@ The API is enabled with the following server configuration:
|
||||
server:
|
||||
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
|
||||
--------------
|
||||
|
||||
|
||||
+1
-1
@@ -112,7 +112,7 @@ today_fmt = '%Y-%m-%d'
|
||||
# built documents.
|
||||
#
|
||||
# The short X.Y version.
|
||||
version = '0.18.dev0'
|
||||
version = '0.19.dev0'
|
||||
# The full version, including alpha/beta/rc tags.
|
||||
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
|
||||
|
||||
map: # leaflet map setup for HTML pages
|
||||
url: https://maps.wikimedia.org/osm-intl/{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>'
|
||||
url: https://tile.openstreetmap.org/{z}/{x}/{y}.png
|
||||
attribution: '© <a href="https://openstreetmap.org/copyright">OpenStreetMap contributors</a>'
|
||||
ogc_schemas_location: /opt/schemas.opengis.net # local copy of https://schemas.opengis.net
|
||||
|
||||
manager: # optional OGC API - Processes asynchronous job management
|
||||
@@ -241,7 +241,7 @@ default.
|
||||
option_name: option_value
|
||||
|
||||
hello-world: # name of process
|
||||
type: collection # REQUIRED (collection, process, or stac-collection)
|
||||
type: process # REQUIRED (collection, process, or stac-collection)
|
||||
processor:
|
||||
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
|
||||
# optionally specify x/y/time fields, else provider will attempt
|
||||
# to derive automagically
|
||||
x_field: lat
|
||||
x_field: lon
|
||||
y_field: lat
|
||||
time_field: time
|
||||
# optionally specify the coordinate reference system of your dataset
|
||||
# else pygeoapi assumes it is WGS84 (EPSG:4326).
|
||||
storage_crs: 4326
|
||||
format:
|
||||
name: 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
|
||||
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
|
||||
--------------------
|
||||
|
||||
@@ -146,3 +154,4 @@ Data access examples
|
||||
.. _`NetCDF`: https://en.wikipedia.org/wiki/NetCDF
|
||||
.. _`Zarr`: https://zarr.readthedocs.io/en/stable
|
||||
.. _`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
|
||||
# optionally specify x/y/time fields, else provider will attempt
|
||||
# to derive automagically
|
||||
x_field: lat
|
||||
x_field: lon
|
||||
y_field: lat
|
||||
time_field: time
|
||||
# optionally specify the coordinate reference system of your dataset
|
||||
# else pygeoapi assumes it is WGS84 (EPSG:4326).
|
||||
storage_crs: 4326
|
||||
format:
|
||||
name: 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
|
||||
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
|
||||
--------------------
|
||||
@@ -105,6 +113,7 @@ Data access examples
|
||||
.. _`xarray`: https://docs.xarray.dev/en/stable/
|
||||
.. _`NetCDF`: https://en.wikipedia.org/wiki/NetCDF
|
||||
.. _`Zarr`: https://zarr.readthedocs.io/en/stable
|
||||
.. _`pyproj.CRS.from_user_input`: https://pyproj4.github.io/pyproj/stable/api/crs/coordinate_system.html#pyproj.crs.CoordinateSystem.from_user_input
|
||||
|
||||
|
||||
.. _`OGC Environmental Data Retrieval (EDR) (API)`: https://github.com/opengeospatial/ogcapi-coverages
|
||||
|
||||
@@ -26,7 +26,9 @@ parameters.
|
||||
`GeoJSON`_,✅/✅,results/hits,❌,❌,❌,✅,❌,❌,✅
|
||||
`MongoDB`_,✅/❌,results,✅,✅,✅,✅,❌,❌,✅
|
||||
`OGR`_,✅/❌,results/hits,✅,❌,❌,✅,❌,❌,✅
|
||||
`OpenSearch`_,✅/✅,results/hits,✅,✅,✅,✅,✅,✅,✅
|
||||
`Oracle`_,✅/✅,results/hits,✅,❌,✅,✅,❌,❌,✅
|
||||
`Parquet`_,✅/✅,results/hits,✅,✅,❌,✅,❌,❌,✅
|
||||
`PostgreSQL`_,✅/✅,results/hits,✅,✅,✅,✅,✅,❌,✅
|
||||
`SQLiteGPKG`_,✅/❌,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``.
|
||||
* 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
|
||||
|
||||
@@ -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)
|
||||
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
|
||||
|
||||
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
|
||||
^^^^^^^
|
||||
@@ -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:
|
||||
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
|
||||
@@ -420,7 +482,7 @@ Configured using environment variables.
|
||||
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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
.. _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
|
||||
|
||||
@@ -51,7 +51,7 @@ Currently supported style files (`options.style`):
|
||||
.. code-block:: yaml
|
||||
|
||||
providers:
|
||||
- type: map
|
||||
- type: map
|
||||
name: MapScript
|
||||
data: /path/to/data.shp
|
||||
options:
|
||||
@@ -59,7 +59,7 @@ Currently supported style files (`options.style`):
|
||||
layer: foo_name
|
||||
style: ./foo.sld
|
||||
format:
|
||||
name: png
|
||||
name: png
|
||||
mimetype: image/png
|
||||
|
||||
WMSFacade
|
||||
@@ -71,14 +71,15 @@ required. An optional style name can be defined via `options.style`.
|
||||
.. code-block:: yaml
|
||||
|
||||
providers:
|
||||
- type: map
|
||||
- type: map
|
||||
name: WMSFacade
|
||||
data: https://demo.mapserver.org/cgi-bin/msautotest
|
||||
options:
|
||||
layer: world_latlong
|
||||
style: default
|
||||
version: 1.3.0
|
||||
format:
|
||||
name: png
|
||||
name: png
|
||||
mimetype: image/png
|
||||
|
||||
|
||||
|
||||
@@ -14,15 +14,47 @@ The pygeoapi offers two processes: a default ``hello-world`` process which allow
|
||||
Configuration
|
||||
-------------
|
||||
|
||||
The below configuration is an example of a process defined within the pygeoapi internal plugin registry:
|
||||
|
||||
.. code-block:: yaml
|
||||
|
||||
processes:
|
||||
|
||||
# enabled by default
|
||||
# enabled by default
|
||||
hello-world:
|
||||
processor:
|
||||
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
|
||||
--------------------
|
||||
|
||||
@@ -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
|
||||
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
|
||||
:ref:`plugins` architecture. pygeoapi provides a default manager implementation
|
||||
based on `TinyDB`_ for simplicity. Custom manager plugins can be developed for more
|
||||
advanced job management capabilities (e.g. Kubernetes, databases, etc.).
|
||||
|
||||
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 managers
|
||||
------------
|
||||
|
||||
TinyDB
|
||||
^^^^^^
|
||||
|
||||
TinyDB is the default job manager for pygeoapi when enabled.
|
||||
|
||||
.. code-block:: yaml
|
||||
|
||||
@@ -52,11 +96,12 @@ can be requested by including the ``Prefer: respond-async`` HTTP header in the r
|
||||
output_dir: /tmp/
|
||||
|
||||
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.
|
||||
`MongoDB`_ uses the localhost and port 27017 by default. Jobs are stored in a collection named
|
||||
job_manager_pygeoapi.
|
||||
^^^^^^^
|
||||
|
||||
As an alternative to the default, a manager employing `MongoDB`_ can be used.
|
||||
The connection to a `MongoDB`_ instance must be provided in the configuration.
|
||||
`MongoDB`_ uses ``localhost`` and port ``27017`` by default. Jobs are stored in a collection named
|
||||
``job_manager_pygeoapi``.
|
||||
|
||||
.. code-block:: yaml
|
||||
|
||||
@@ -66,11 +111,34 @@ job_manager_pygeoapi.
|
||||
connection: mongodb://host:port
|
||||
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
|
||||
-----------------------
|
||||
|
||||
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
|
||||
* 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:
|
||||
min: 0
|
||||
max: 15
|
||||
schemes:
|
||||
- WebMercatorQuad # this option is needed in the MVT-proxy provider
|
||||
schemes:
|
||||
- WebMercatorQuad # this option is needed in the MVT-proxy provider
|
||||
format:
|
||||
name: pbf
|
||||
mimetype: application/vnd.mapbox-vector-tile
|
||||
@@ -124,8 +124,8 @@ Following code block shows how to configure pygeoapi to read Mapbox vector tiles
|
||||
zoom:
|
||||
min: 0
|
||||
max: 15
|
||||
schemes:
|
||||
- WebMercatorQuad
|
||||
schemes:
|
||||
- WebMercatorQuad
|
||||
format:
|
||||
name: pbf
|
||||
mimetype: application/vnd.mapbox-vector-tile
|
||||
|
||||
@@ -11,6 +11,13 @@ Requirements and dependencies
|
||||
|
||||
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
|
||||
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
|
||||
export PYGEOAPI_CONFIG=example-config.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
|
||||
curl http://localhost:5000
|
||||
|
||||
@@ -142,3 +149,4 @@ onto your system.
|
||||
|
||||
|
||||
.. _`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 - Environmental Data Retrieval
|
||||
* OGC API - Tiles
|
||||
* OGC API - Processes
|
||||
|
||||
* additionally implements
|
||||
|
||||
* OGC API - Coverages
|
||||
* OGC API - Maps
|
||||
* OGC API - Processes
|
||||
* OGC API - Records
|
||||
* 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 - Maps`_,Implementing
|
||||
`OGC API - Tiles`_,Reference Implementation
|
||||
`OGC API - Processes`_,Implementing
|
||||
`OGC API - Processes`_,Compliant
|
||||
`OGC API - Records`_,Implementing
|
||||
`OGC API - Environmental Data Retrieval`_,Reference Implementation
|
||||
`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)
|
||||
self.num_bands = 4
|
||||
self.axes = ['Lat', 'Long']
|
||||
self.fields = self.get_fields()
|
||||
self.get_fields()
|
||||
|
||||
def get_fields(self):
|
||||
# generate a JSON Schema of coverage band metadata
|
||||
return {
|
||||
self._fields = {
|
||||
'b1': {
|
||||
'type': 'number'
|
||||
}
|
||||
}
|
||||
return self._fields
|
||||
|
||||
def query(self, bands=[], subsets={}, format_='json', **kwargs):
|
||||
# process bands and subsets parameters
|
||||
@@ -272,6 +273,8 @@ implementation.
|
||||
|
||||
Each base class documents the functions, arguments and return types required for implementation.
|
||||
|
||||
.. _example-custom-pygeoapi-processing-plugin:
|
||||
|
||||
Example: custom pygeoapi processing plugin
|
||||
------------------------------------------
|
||||
|
||||
|
||||
@@ -14,4 +14,5 @@ as required.
|
||||
The following projects provide security frameworks atop pygeoapi:
|
||||
|
||||
* `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"
|
||||
"Generated-By: Babel 2.14.0\n"
|
||||
|
||||
#: pygeoapi/templates/_base.html:2
|
||||
msgid "text_direction"
|
||||
msgstr "ltr"
|
||||
|
||||
#: pygeoapi/templates/_base.html:51
|
||||
msgid "Admin"
|
||||
msgstr "Admin"
|
||||
@@ -656,3 +660,27 @@ msgstr ""
|
||||
|
||||
msgid "not specified"
|
||||
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"
|
||||
"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/landing_page.html:2
|
||||
#: pygeoapi/templates/_base.html:40 pygeoapi/templates/landing_page.html:2
|
||||
@@ -706,3 +710,27 @@ msgstr ""
|
||||
|
||||
msgid "not specified"
|
||||
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"
|
||||
"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/landing_page.html:2
|
||||
#: pygeoapi/templates/_base.html:40 pygeoapi/templates/landing_page.html:2
|
||||
@@ -708,3 +712,27 @@ msgstr ""
|
||||
|
||||
msgid "not specified"
|
||||
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"
|
||||
"Generated-By: Babel 2.11.0\n"
|
||||
|
||||
#: pygeoapi/templates/_base.html:2
|
||||
msgid "text_direction"
|
||||
msgstr "ltr"
|
||||
|
||||
#: pygeoapi/templates/_base.html:51
|
||||
msgid "Admin"
|
||||
msgstr "Admin"
|
||||
@@ -521,3 +525,27 @@ msgstr ""
|
||||
|
||||
msgid "not specified"
|
||||
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"
|
||||
"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/landing_page.html:2
|
||||
#: pygeoapi/templates/_base.html:40 pygeoapi/templates/landing_page.html:2
|
||||
@@ -715,3 +719,27 @@ msgstr ""
|
||||
|
||||
msgid "not specified"
|
||||
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"
|
||||
"Generated-By: Babel 2.14.0\n"
|
||||
|
||||
#: pygeoapi/templates/_base.html:2
|
||||
msgid "text_direction"
|
||||
msgstr "ltr"
|
||||
|
||||
#: pygeoapi/templates/_base.html:51
|
||||
msgid "Admin"
|
||||
msgstr "Admin"
|
||||
@@ -656,3 +660,27 @@ msgstr ""
|
||||
|
||||
msgid "not specified"
|
||||
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
|
||||
try:
|
||||
|
||||
@@ -80,6 +80,7 @@ HEADERS = {
|
||||
|
||||
CHARSET = ['utf-8']
|
||||
F_JSON = 'json'
|
||||
F_COVERAGEJSON = 'json'
|
||||
F_HTML = 'html'
|
||||
F_JSONLD = 'jsonld'
|
||||
F_GZIP = 'gzip'
|
||||
@@ -1209,6 +1210,7 @@ class API:
|
||||
if edr:
|
||||
# TODO: translate
|
||||
LOGGER.debug('Adding EDR links')
|
||||
collection['data_queries'] = {}
|
||||
parameters = p.get_fields()
|
||||
if parameters:
|
||||
collection['parameter_names'] = {}
|
||||
@@ -1229,6 +1231,14 @@ class API:
|
||||
}
|
||||
|
||||
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 = f'{qt} {title1}'
|
||||
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)
|
||||
|
||||
schema['collections_path'] = self.get_collections_url()
|
||||
schema['dataset_path'] = f'{self.get_collections_url()}/{dataset}'
|
||||
|
||||
content = render_j2_template(self.tpl_config,
|
||||
'collections/schema.html',
|
||||
@@ -1430,7 +1441,8 @@ class API:
|
||||
# Content-Language is in the system locale (ignore language settings)
|
||||
headers = request.get_response_headers(SYSTEM_LOCALE,
|
||||
**self.api_headers)
|
||||
msg = f'Invalid format: {request.format}'
|
||||
msg = 'Invalid format requested'
|
||||
LOGGER.error(f'{msg}: {request.format}')
|
||||
return self.get_exception(
|
||||
HTTPStatus.BAD_REQUEST, headers,
|
||||
request.format, 'InvalidParameterValue', msg)
|
||||
|
||||
@@ -41,10 +41,12 @@
|
||||
from http import HTTPStatus
|
||||
import logging
|
||||
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 pygeoapi import l10n
|
||||
from pygeoapi.plugin import load_plugin, PLUGINS
|
||||
from pygeoapi.provider.base import ProviderGenericError
|
||||
from pygeoapi.util import (
|
||||
@@ -52,7 +54,8 @@ from pygeoapi.util import (
|
||||
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__)
|
||||
|
||||
@@ -88,6 +91,27 @@ def get_collection_edr_query(api: API, request: APIRequest,
|
||||
return api.get_exception(
|
||||
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 datetime parameter')
|
||||
@@ -124,7 +148,7 @@ def get_collection_edr_query(api: API, request: APIRequest,
|
||||
if wkt:
|
||||
try:
|
||||
wkt = shapely_loads(wkt)
|
||||
except WKTReadingError:
|
||||
except ShapelyError:
|
||||
msg = 'invalid coords parameter'
|
||||
return api.get_exception(
|
||||
HTTPStatus.BAD_REQUEST, headers, request.format,
|
||||
@@ -144,27 +168,6 @@ def get_collection_edr_query(api: API, request: APIRequest,
|
||||
LOGGER.debug('Processing z parameter')
|
||||
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)
|
||||
for fld in p.get_fields().keys()):
|
||||
msg = 'Invalid parameter-name'
|
||||
@@ -195,6 +198,36 @@ def get_collection_edr_query(api: API, request: APIRequest,
|
||||
err.ogc_exception_code, err.message)
|
||||
|
||||
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,
|
||||
'collections/edr/query.html', data,
|
||||
api.default_locale)
|
||||
@@ -305,11 +338,9 @@ def get_oas_30(cfg: dict, locale: str) -> tuple[list[dict[str, str]], dict[str,
|
||||
'tags': [k],
|
||||
'operationId': f'queryLOCATIONSBYID{k.capitalize()}',
|
||||
'parameters': [
|
||||
{'$ref': f"{OPENAPI_YAML['oaedr']}/parameters/{spatial_parameter}.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['oaedr']}/parameters/parameter-name.yaml"}, # noqa
|
||||
{'$ref': f"{OPENAPI_YAML['oaedr']}/parameters/z.yaml"}, # noqa
|
||||
{'$ref': '#/components/parameters/f'}
|
||||
],
|
||||
'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)
|
||||
|
||||
LOGGER.debug('Creating collection queryables')
|
||||
try:
|
||||
LOGGER.debug('Loading feature provider')
|
||||
p = load_plugin('provider', get_provider_by_type(
|
||||
api.config['resources'][dataset]['providers'], 'feature'))
|
||||
except ProviderTypeError:
|
||||
|
||||
p = None
|
||||
for pt in ['feature', 'coverage', 'record']:
|
||||
try:
|
||||
LOGGER.debug('Loading coverage provider')
|
||||
LOGGER.debug(f'Loading {pt} provider')
|
||||
p = load_plugin('provider', get_provider_by_type(
|
||||
api.config['resources'][dataset]['providers'], 'coverage')) # noqa
|
||||
api.config['resources'][dataset]['providers'], pt))
|
||||
break
|
||||
except ProviderTypeError:
|
||||
LOGGER.debug('Loading record provider')
|
||||
p = load_plugin('provider', get_provider_by_type(
|
||||
api.config['resources'][dataset]['providers'], 'record'))
|
||||
except ProviderGenericError as err:
|
||||
LOGGER.debug(f'Providing type {pt} not found')
|
||||
|
||||
if p is None:
|
||||
msg = 'queryables not available for this collection'
|
||||
return api.get_exception(
|
||||
err.http_status_code, headers, request.format,
|
||||
err.ogc_exception_code, err.message)
|
||||
HTTPStatus.BAD_REQUEST, headers, request.format,
|
||||
'NoApplicableError', msg)
|
||||
|
||||
queryables = {
|
||||
'type': 'object',
|
||||
@@ -182,6 +181,7 @@ def get_collection_queryables(api: API, request: Union[APIRequest, Any],
|
||||
api.config['resources'][dataset]['title'], request.locale)
|
||||
|
||||
queryables['collections_path'] = api.get_collections_url()
|
||||
queryables['dataset_path'] = f'{api.get_collections_url()}/{dataset}'
|
||||
|
||||
content = render_j2_template(api.tpl_config,
|
||||
'collections/queryables.html',
|
||||
@@ -380,8 +380,12 @@ def get_collection_items(
|
||||
|
||||
LOGGER.debug('processing property parameters')
|
||||
for k, v in request.params.items():
|
||||
if k not in reserved_fieldnames and k in list(p.fields.keys()):
|
||||
LOGGER.debug(f'Adding property filter {k}={v}')
|
||||
if k not in reserved_fieldnames:
|
||||
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))
|
||||
|
||||
LOGGER.debug('processing sort parameter')
|
||||
@@ -444,7 +448,8 @@ def get_collection_items(
|
||||
geometry_column_name=provider_def.get('geom_field'),
|
||||
)
|
||||
except Exception:
|
||||
msg = f'Bad CQL string : {cql_text}'
|
||||
msg = 'Bad CQL text'
|
||||
LOGGER.error(f'{msg}: {cql_text}')
|
||||
return api.get_exception(
|
||||
HTTPStatus.BAD_REQUEST, headers, request.format,
|
||||
'InvalidParameterValue', msg)
|
||||
@@ -531,17 +536,23 @@ def get_collection_items(
|
||||
'href': f'{uri}?offset={prev}{serialized_query_params}'
|
||||
})
|
||||
|
||||
if 'numberMatched' in content:
|
||||
if content['numberMatched'] > (limit + offset):
|
||||
next_ = offset + limit
|
||||
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
|
||||
})
|
||||
next_link = False
|
||||
|
||||
if content.get('numberMatched', -1) > (limit + offset):
|
||||
next_link = True
|
||||
elif len(content['features']) == limit:
|
||||
next_link = True
|
||||
|
||||
if next_link:
|
||||
next_ = offset + limit
|
||||
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(
|
||||
{
|
||||
@@ -836,7 +847,7 @@ def post_collection_items(
|
||||
if (request_headers.get(
|
||||
'Content-Type') or request_headers.get(
|
||||
'content-type')) != 'application/query-cql-json':
|
||||
msg = ('Invalid body content-type')
|
||||
msg = 'Invalid body content-type'
|
||||
return api.get_exception(
|
||||
HTTPStatus.BAD_REQUEST, headers, request.format,
|
||||
'InvalidHeaderValue', msg)
|
||||
@@ -872,16 +883,18 @@ def post_collection_items(
|
||||
geometry_column_name=provider_def.get('geom_field')
|
||||
)
|
||||
except Exception:
|
||||
msg = f'Bad CQL string : {data}'
|
||||
msg = 'Bad CQL text'
|
||||
LOGGER.error(f'{msg}: {data}')
|
||||
return api.get_exception(
|
||||
HTTPStatus.BAD_REQUEST, headers, request.format,
|
||||
'InvalidParameterValue', msg)
|
||||
else:
|
||||
LOGGER.debug('processing Elasticsearch CQL_JSON data')
|
||||
LOGGER.debug('processing CQL_JSON data')
|
||||
try:
|
||||
filter_ = CQLModel.parse_raw(data)
|
||||
except Exception:
|
||||
msg = f'Bad CQL string : {data}'
|
||||
msg = 'Bad CQL text'
|
||||
LOGGER.error(f'{msg}: {data}')
|
||||
return api.get_exception(
|
||||
HTTPStatus.BAD_REQUEST, headers, request.format,
|
||||
'InvalidParameterValue', msg)
|
||||
|
||||
@@ -46,6 +46,7 @@ from http import HTTPStatus
|
||||
import json
|
||||
import logging
|
||||
from typing import Tuple
|
||||
import urllib.parse
|
||||
|
||||
from pygeoapi import l10n
|
||||
from pygeoapi.util import (
|
||||
@@ -240,10 +241,51 @@ def get_jobs(api: API, request: APIRequest,
|
||||
|
||||
headers = request.get_response_headers(SYSTEM_LOCALE,
|
||||
**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:
|
||||
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'],
|
||||
reverse=True)
|
||||
numberMatched = jobs_data['numberMatched']
|
||||
|
||||
else:
|
||||
try:
|
||||
jobs = [api.manager.get_job(job_id)]
|
||||
@@ -251,6 +293,7 @@ def get_jobs(api: API, request: APIRequest,
|
||||
return api.get_exception(
|
||||
HTTPStatus.NOT_FOUND, headers, request.format,
|
||||
'InvalidParameterValue', job_id)
|
||||
numberMatched = 1
|
||||
|
||||
serialized_jobs = {
|
||||
'jobs': [],
|
||||
@@ -309,6 +352,44 @@ def get_jobs(api: API, request: APIRequest,
|
||||
|
||||
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:
|
||||
j2_template = 'jobs/index.html'
|
||||
else:
|
||||
@@ -318,6 +399,7 @@ def get_jobs(api: API, request: APIRequest,
|
||||
if request.format == F_HTML:
|
||||
data = {
|
||||
'jobs': serialized_jobs,
|
||||
'offset': offset,
|
||||
'now': datetime.now(timezone.utc).strftime(DATETIME_FORMAT)
|
||||
}
|
||||
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')
|
||||
LOGGER.debug(f'outputs: {requested_outputs}')
|
||||
|
||||
requested_response = data.get('response', 'raw')
|
||||
|
||||
subscriber = None
|
||||
subscriber_dict = data.get('subscriber')
|
||||
if subscriber_dict:
|
||||
@@ -407,10 +491,14 @@ def execute_process(api: API, request: APIRequest,
|
||||
result = api.manager.execute_process(
|
||||
process_id, data_dict, execution_mode=execution_mode,
|
||||
requested_outputs=requested_outputs,
|
||||
subscriber=subscriber)
|
||||
subscriber=subscriber,
|
||||
requested_response=requested_response)
|
||||
job_id, mime_type, outputs, status, additional_headers = result
|
||||
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:
|
||||
return api.get_exception(
|
||||
err.http_status_code, headers,
|
||||
@@ -420,11 +508,11 @@ def execute_process(api: API, request: APIRequest,
|
||||
if status == JobStatus.failed:
|
||||
response = outputs
|
||||
|
||||
if data.get('response', 'raw') == 'raw':
|
||||
if requested_response == 'raw':
|
||||
headers['Content-Type'] = mime_type
|
||||
response = outputs
|
||||
elif status not in (JobStatus.failed, JobStatus.accepted):
|
||||
response['outputs'] = [outputs]
|
||||
response = outputs
|
||||
|
||||
if status == JobStatus.accepted:
|
||||
http_status = HTTPStatus.CREATED
|
||||
@@ -433,7 +521,7 @@ def execute_process(api: API, request: APIRequest,
|
||||
else:
|
||||
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)
|
||||
else:
|
||||
response2 = response
|
||||
|
||||
@@ -77,7 +77,7 @@ ADMIN_BLUEPRINT = Blueprint('admin', __name__, static_folder=STATIC_FOLDER)
|
||||
if CONFIG['server'].get('cors', False):
|
||||
try:
|
||||
from flask_cors import CORS
|
||||
CORS(APP)
|
||||
CORS(APP, CORS_EXPOSE_HEADERS=['*'])
|
||||
except ModuleNotFoundError:
|
||||
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 request.method == 'GET': # list 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.method == 'POST': # filter or manage items
|
||||
if request.content_type is not None:
|
||||
if request.content_type == 'application/geo+json':
|
||||
return execute_from_flask(
|
||||
@@ -298,6 +294,10 @@ def collection_items(collection_id, item_id=None):
|
||||
return execute_from_flask(
|
||||
itemtypes_api.manage_collection_item, request, 'options',
|
||||
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':
|
||||
return execute_from_flask(itemtypes_api.manage_collection_item,
|
||||
|
||||
@@ -27,11 +27,10 @@
|
||||
#
|
||||
# =================================================================
|
||||
|
||||
import csv
|
||||
import io
|
||||
import logging
|
||||
|
||||
import unicodecsv as csv
|
||||
|
||||
from pygeoapi.formatter.base import BaseFormatter, FormatterSerializationError
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
@@ -83,10 +82,11 @@ class CSVFormatter(BaseFormatter):
|
||||
# TODO: implement wkt geometry serialization
|
||||
LOGGER.debug('not a point geometry, skipping')
|
||||
|
||||
print("JJJ", fields)
|
||||
LOGGER.debug(f'CSV fields: {fields}')
|
||||
|
||||
try:
|
||||
output = io.BytesIO()
|
||||
output = io.StringIO()
|
||||
writer = csv.DictWriter(output, fields)
|
||||
writer.writeheader()
|
||||
|
||||
@@ -101,7 +101,7 @@ class CSVFormatter(BaseFormatter):
|
||||
LOGGER.error(err)
|
||||
raise FormatterSerializationError('Error writing CSV output')
|
||||
|
||||
return output.getvalue()
|
||||
return output.getvalue().encode('utf-8')
|
||||
|
||||
def __repr__(self):
|
||||
return f'<CSVFormatter> {self.name}'
|
||||
|
||||
+58
-5
@@ -134,6 +134,52 @@ def gen_response_object(description: str, media_type: str,
|
||||
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:
|
||||
"""
|
||||
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
|
||||
'termsOfService':
|
||||
cfg['metadata']['identification']['terms_of_service'],
|
||||
'contact': {
|
||||
'name': cfg['metadata']['provider']['name'],
|
||||
'url': cfg['metadata']['provider']['url'],
|
||||
'email': cfg['metadata']['contact']['email']
|
||||
},
|
||||
'contact': gen_contact(cfg),
|
||||
'license': {
|
||||
'name': cfg['metadata']['license']['name'],
|
||||
'url': cfg['metadata']['license']['url']
|
||||
@@ -903,6 +945,17 @@ def load_openapi_document() -> dict:
|
||||
|
||||
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:
|
||||
if pygeoapi_openapi.endswith(('.yaml', '.yml')):
|
||||
openapi_ = yaml_load(ff)
|
||||
|
||||
+4
-2
@@ -51,10 +51,12 @@ PLUGINS = {
|
||||
'MapScript': 'pygeoapi.provider.mapscript_.MapScriptProvider',
|
||||
'MongoDB': 'pygeoapi.provider.mongo.MongoProvider',
|
||||
'MVT-tippecanoe': 'pygeoapi.provider.mvt_tippecanoe.MVTTippecanoeProvider', # noqa: E501
|
||||
'MVT-elastic': 'pygeoapi.provider.mvt_elastic.MVTElasticProvider', # noqa: E501
|
||||
'MVT-proxy': 'pygeoapi.provider.mvt_proxy.MVTProxyProvider', # noqa: E501
|
||||
'MVT-elastic': 'pygeoapi.provider.mvt_elastic.MVTElasticProvider',
|
||||
'MVT-proxy': 'pygeoapi.provider.mvt_proxy.MVTProxyProvider',
|
||||
'OracleDB': 'pygeoapi.provider.oracle.OracleProvider',
|
||||
'OGR': 'pygeoapi.provider.ogr.OGRProvider',
|
||||
'OpenSearch': 'pygeoapi.provider.opensearch_.OpenSearchProvider',
|
||||
'Parquet': 'pygeoapi.provider.parquet.ParquetProvider',
|
||||
'PostgreSQL': 'pygeoapi.provider.postgresql.PostgreSQLProvider',
|
||||
'rasterio': 'pygeoapi.provider.rasterio_.RasterioProvider',
|
||||
'SensorThings': 'pygeoapi.provider.sensorthings.SensorThingsProvider',
|
||||
|
||||
@@ -54,6 +54,7 @@ from pygeoapi.util import (
|
||||
JobStatus,
|
||||
ProcessExecutionMode,
|
||||
RequestedProcessExecutionMode,
|
||||
RequestedResponse,
|
||||
Subscriber
|
||||
)
|
||||
|
||||
@@ -107,14 +108,21 @@ class BaseManager:
|
||||
else:
|
||||
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
|
||||
|
||||
:param status: job status (accepted, running, successful,
|
||||
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()
|
||||
@@ -187,6 +195,7 @@ class BaseManager:
|
||||
data_dict: dict,
|
||||
requested_outputs: Optional[dict] = None,
|
||||
subscriber: Optional[Subscriber] = None,
|
||||
requested_response: Optional[RequestedResponse] = RequestedResponse.raw.value # noqa
|
||||
) -> Tuple[str, None, JobStatus]:
|
||||
"""
|
||||
This private execution handler executes a process in a background
|
||||
@@ -197,27 +206,34 @@ class BaseManager:
|
||||
:param p: `pygeoapi.process` object
|
||||
:param job_id: job identifier
|
||||
:param data_dict: `dict` of data parameters
|
||||
:param requested_outputs: `dict` 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 requested_outputs: `dict` optionally specifying 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: 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)
|
||||
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()
|
||||
|
||||
return 'application/json', None, JobStatus.accepted
|
||||
|
||||
def _execute_handler_sync(self, p: BaseProcessor, job_id: str,
|
||||
data_dict: dict,
|
||||
requested_outputs: Optional[dict] = None,
|
||||
subscriber: Optional[Subscriber] = None,
|
||||
requested_response: Optional[RequestedResponse] = RequestedResponse.raw.value # noqa
|
||||
) -> Tuple[str, Any, JobStatus]:
|
||||
"""
|
||||
Synchronous execution handler
|
||||
@@ -229,15 +245,27 @@ class BaseManager:
|
||||
:param p: `pygeoapi.process` object
|
||||
:param job_id: job identifier
|
||||
:param data_dict: `dict` of data parameters
|
||||
:param requested_outputs: `dict` 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 requested_outputs: `dict` optionally specifying 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: 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
|
||||
"""
|
||||
|
||||
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)
|
||||
|
||||
try:
|
||||
@@ -248,13 +276,12 @@ class BaseManager:
|
||||
job_filename = None
|
||||
|
||||
current_status = JobStatus.running
|
||||
jfmt, outputs = p.execute(
|
||||
data_dict,
|
||||
# only pass requested_outputs if supported,
|
||||
# otherwise this breaks existing processes
|
||||
**({'outputs': requested_outputs}
|
||||
if p.supports_outputs else {})
|
||||
)
|
||||
jfmt, outputs = p.execute(data_dict, **extra_execute_parameters)
|
||||
|
||||
if requested_response == RequestedResponse.document.value:
|
||||
outputs = {
|
||||
'outputs': [outputs]
|
||||
}
|
||||
|
||||
self.update_job(job_id, {
|
||||
'status': current_status.value,
|
||||
@@ -330,7 +357,8 @@ class BaseManager:
|
||||
data_dict: dict,
|
||||
execution_mode: Optional[RequestedProcessExecutionMode] = 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]]]:
|
||||
"""
|
||||
Default process execution handler
|
||||
@@ -339,12 +367,17 @@ class BaseManager:
|
||||
:param data_dict: `dict` of data parameters
|
||||
:param execution_mode: `str` optionally specifying sync or async
|
||||
processing.
|
||||
: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 requested_outputs: `dict` optionally specifying the subset of
|
||||
required outputs - defaults to all outputs.
|
||||
The value of any key may be an object and
|
||||
include the property `transmissionMode`
|
||||
(default is `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
|
||||
@@ -356,6 +389,9 @@ class BaseManager:
|
||||
job_id = str(uuid.uuid1())
|
||||
processor = self.get_processor(process_id)
|
||||
processor.set_job_id(job_id)
|
||||
extra_execute_handler_parameters = {
|
||||
'requested_response': requested_response
|
||||
}
|
||||
|
||||
if execution_mode == RequestedProcessExecutionMode.respond_async:
|
||||
job_control_options = processor.metadata.get(
|
||||
@@ -406,6 +442,11 @@ class BaseManager:
|
||||
}
|
||||
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
|
||||
# headers
|
||||
mime_type, outputs, status = handler(
|
||||
@@ -413,10 +454,7 @@ class BaseManager:
|
||||
job_id,
|
||||
data_dict,
|
||||
requested_outputs,
|
||||
# only pass subscriber if supported, otherwise this breaks existing
|
||||
# managers
|
||||
**({'subscriber': subscriber} if self.supports_subscribing else {})
|
||||
)
|
||||
**extra_execute_handler_parameters)
|
||||
|
||||
return job_id, mime_type, outputs, status, response_headers
|
||||
|
||||
|
||||
@@ -33,8 +33,9 @@ import uuid
|
||||
|
||||
from pygeoapi.process.manager.base import BaseManager
|
||||
from pygeoapi.util import (
|
||||
RequestedProcessExecutionMode,
|
||||
JobStatus,
|
||||
RequestedProcessExecutionMode,
|
||||
RequestedResponse,
|
||||
Subscriber
|
||||
)
|
||||
|
||||
@@ -55,17 +56,21 @@ class DummyManager(BaseManager):
|
||||
|
||||
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
|
||||
|
||||
:param status: job status (accepted, running, successful,
|
||||
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(
|
||||
self,
|
||||
@@ -73,7 +78,8 @@ class DummyManager(BaseManager):
|
||||
data_dict: dict,
|
||||
execution_mode: Optional[RequestedProcessExecutionMode] = 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]]]:
|
||||
"""
|
||||
Default process execution handler
|
||||
@@ -81,9 +87,19 @@ class DummyManager(BaseManager):
|
||||
:param process_id: process identifier
|
||||
:param data_dict: `dict` of data parameters
|
||||
: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
|
||||
optionally additional HTTP headers to include in the
|
||||
optionally additional HTTP headers to include in the final
|
||||
response
|
||||
"""
|
||||
|
||||
@@ -100,7 +116,8 @@ class DummyManager(BaseManager):
|
||||
self._send_in_progress_notification(subscriber)
|
||||
processor = self.get_processor(process_id)
|
||||
try:
|
||||
jfmt, outputs = processor.execute(data_dict)
|
||||
jfmt, outputs = processor.execute(
|
||||
data_dict, outputs=requested_outputs)
|
||||
current_status = JobStatus.successful
|
||||
self._send_success_notification(subscriber, outputs)
|
||||
except Exception as err:
|
||||
@@ -111,6 +128,12 @@ class DummyManager(BaseManager):
|
||||
current_status = JobStatus.failed
|
||||
LOGGER.exception(err)
|
||||
self._send_failed_notification(subscriber)
|
||||
|
||||
if requested_response == RequestedResponse.document.value:
|
||||
outputs = {
|
||||
'outputs': [outputs]
|
||||
}
|
||||
|
||||
job_id = str(uuid.uuid1())
|
||||
return job_id, jfmt, outputs, current_status, response_headers
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ import traceback
|
||||
|
||||
from pymongo import MongoClient
|
||||
|
||||
from pygeoapi.api import FORMAT_TYPES, F_JSON, F_JSONLD
|
||||
from pygeoapi.process.base import (
|
||||
JobNotFoundError,
|
||||
JobResultNotFoundError,
|
||||
@@ -70,7 +71,7 @@ class MongoDBManager(BaseManager):
|
||||
exc_info=(traceback))
|
||||
return False
|
||||
|
||||
def get_jobs(self, status=None):
|
||||
def get_jobs(self, status=None, limit=None, offset=None):
|
||||
try:
|
||||
self._connect()
|
||||
database = self.db.job_manager_pygeoapi
|
||||
@@ -80,7 +81,10 @@ class MongoDBManager(BaseManager):
|
||||
else:
|
||||
jobs = list(collection.find({}))
|
||||
LOGGER.info("JOBMANAGER - MongoDB jobs queried")
|
||||
return jobs
|
||||
return {
|
||||
'jobs': jobs,
|
||||
'numberMatched': len(jobs)
|
||||
}
|
||||
except Exception:
|
||||
LOGGER.error("JOBMANAGER - get_jobs error",
|
||||
exc_info=(traceback))
|
||||
@@ -148,8 +152,16 @@ class MongoDBManager(BaseManager):
|
||||
if entry["status"] != "successful":
|
||||
LOGGER.info("JOBMANAGER - job not finished or failed")
|
||||
return (None,)
|
||||
with open(entry["location"], "r") as file:
|
||||
data = json.load(file)
|
||||
if not entry["location"]:
|
||||
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")
|
||||
return entry["mimetype"], data
|
||||
except Exception as err:
|
||||
|
||||
@@ -46,8 +46,10 @@ from pathlib import Path
|
||||
from typing import Any, Tuple
|
||||
|
||||
from sqlalchemy import insert, update, delete
|
||||
from sqlalchemy.engine import make_url
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from pygeoapi.api import FORMAT_TYPES, F_JSON, F_JSONLD
|
||||
from pygeoapi.process.base import (
|
||||
JobNotFoundError,
|
||||
JobResultNotFoundError,
|
||||
@@ -83,12 +85,18 @@ class PostgreSQLManager(BaseManager):
|
||||
self.db_search_path = tuple(self.connection.get('search_path',
|
||||
['public']))
|
||||
except Exception:
|
||||
self.db_search_path = 'public'
|
||||
self.db_search_path = ('public',)
|
||||
|
||||
try:
|
||||
LOGGER.debug('Connecting to database')
|
||||
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:
|
||||
self._engine = get_engine(**self.connection)
|
||||
except Exception as err:
|
||||
@@ -109,16 +117,18 @@ class PostgreSQLManager(BaseManager):
|
||||
LOGGER.error(f'{msg}: {err}')
|
||||
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
|
||||
|
||||
:param status: job status (accepted, running, successful,
|
||||
failed, results) (default is all)
|
||||
:param limit: number of jobs to return
|
||||
:param offset: pagination offset
|
||||
|
||||
:returns: 'list` of jobs (type (default='process'), identifier,
|
||||
status, process_id, job_start_datetime, job_end_datetime, location,
|
||||
mimetype, message, progress)
|
||||
:returns: dict of list of jobs (identifier, status, process identifier)
|
||||
and numberMatched
|
||||
"""
|
||||
|
||||
LOGGER.debug('Querying for jobs')
|
||||
@@ -128,7 +138,11 @@ class PostgreSQLManager(BaseManager):
|
||||
column = getattr(self.table_model, 'status')
|
||||
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:
|
||||
"""
|
||||
@@ -279,8 +293,13 @@ class PostgreSQLManager(BaseManager):
|
||||
else:
|
||||
try:
|
||||
location = Path(location)
|
||||
with location.open(encoding='utf-8') as fh:
|
||||
result = json.load(fh)
|
||||
if mimetype in (None, FORMAT_TYPES[F_JSON],
|
||||
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):
|
||||
raise JobResultNotFoundError()
|
||||
else:
|
||||
|
||||
@@ -37,6 +37,7 @@ from typing import Any, Tuple
|
||||
import tinydb
|
||||
from filelock import FileLock
|
||||
|
||||
from pygeoapi.api import FORMAT_TYPES, F_JSON, F_JSONLD
|
||||
from pygeoapi.process.base import (
|
||||
JobNotFoundError,
|
||||
JobResultNotFoundError,
|
||||
@@ -82,20 +83,35 @@ class TinyDBManager(BaseManager):
|
||||
|
||||
return True
|
||||
|
||||
def get_jobs(self, status: JobStatus = None) -> list:
|
||||
def get_jobs(self, status: JobStatus = None, limit=None, offset=None
|
||||
) -> dict:
|
||||
"""
|
||||
Get jobs
|
||||
|
||||
:param status: job status (accepted, running, successful,
|
||||
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:
|
||||
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:
|
||||
"""
|
||||
@@ -196,8 +212,13 @@ class TinyDBManager(BaseManager):
|
||||
else:
|
||||
try:
|
||||
location = Path(location)
|
||||
with location.open('r', encoding='utf-8') as filehandler:
|
||||
result = json.load(filehandler)
|
||||
if mimetype in (None, FORMAT_TYPES[F_JSON],
|
||||
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):
|
||||
raise JobResultNotFoundError()
|
||||
else:
|
||||
|
||||
@@ -73,7 +73,7 @@ class BaseProvider:
|
||||
self.title_field = provider_def.get('title_field')
|
||||
self.properties = provider_def.get('properties', [])
|
||||
self.file_types = provider_def.get('file_types', [])
|
||||
self.fields = {}
|
||||
self._fields = {}
|
||||
self.filename = None
|
||||
|
||||
# for coverage providers
|
||||
@@ -85,13 +85,31 @@ class BaseProvider:
|
||||
"""
|
||||
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
|
||||
"""
|
||||
|
||||
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):
|
||||
"""
|
||||
Get provider schema model
|
||||
|
||||
@@ -29,10 +29,14 @@
|
||||
|
||||
import logging
|
||||
|
||||
from pygeoapi.provider.base import BaseProvider
|
||||
from pygeoapi.provider.base import BaseProvider, ProviderInvalidDataError
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
EDR_QUERY_TYPES = ['position', 'radius', 'area', 'cube',
|
||||
'trajectory', 'corridor', 'items',
|
||||
'locations', 'instances']
|
||||
|
||||
|
||||
class BaseEDRProvider(BaseProvider):
|
||||
"""Base EDR Provider"""
|
||||
@@ -55,6 +59,11 @@ class BaseEDRProvider(BaseProvider):
|
||||
@classmethod
|
||||
def register(cls):
|
||||
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__)
|
||||
return fn
|
||||
return inner
|
||||
|
||||
+22
-23
@@ -54,7 +54,7 @@ class CSVProvider(BaseProvider):
|
||||
super().__init__(provider_def)
|
||||
self.geometry_x = provider_def['geometry']['x_field']
|
||||
self.geometry_y = provider_def['geometry']['y_field']
|
||||
self.fields = self.get_fields()
|
||||
self.get_fields()
|
||||
|
||||
def get_fields(self):
|
||||
"""
|
||||
@@ -62,32 +62,31 @@ class CSVProvider(BaseProvider):
|
||||
|
||||
: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')
|
||||
with open(self.data) as ff:
|
||||
LOGGER.debug('Serializing DictReader')
|
||||
data_ = csv.DictReader(ff)
|
||||
fields = {}
|
||||
row = next(data_)
|
||||
|
||||
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():
|
||||
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'
|
||||
self._fields[key] = {'type': type_}
|
||||
|
||||
fields[key] = {'type': type_}
|
||||
|
||||
return fields
|
||||
return self._fields
|
||||
|
||||
def _load(self, offset=0, limit=10, resulttype='results',
|
||||
identifier=None, bbox=[], datetime_=None, properties=[],
|
||||
|
||||
@@ -69,7 +69,8 @@ class CSWFacadeProvider(BaseProvider):
|
||||
'language': ('dc:language', 'language')
|
||||
}
|
||||
|
||||
self.fields = self.get_fields()
|
||||
self._fields = {}
|
||||
self.get_fields()
|
||||
|
||||
def get_fields(self):
|
||||
"""
|
||||
@@ -78,17 +79,17 @@ class CSWFacadeProvider(BaseProvider):
|
||||
:returns: dict of fields
|
||||
"""
|
||||
|
||||
fields = {}
|
||||
date_fields = ['date', 'created', 'updated']
|
||||
if not self._fields:
|
||||
date_fields = ['date', 'created', 'updated']
|
||||
|
||||
for key in self.record_mappings.keys():
|
||||
LOGGER.debug(f'key: {key}')
|
||||
fields[key] = {'type': 'string'}
|
||||
for key in self.record_mappings.keys():
|
||||
LOGGER.debug(f'key: {key}')
|
||||
self._fields[key] = {'type': 'string'}
|
||||
|
||||
if key in date_fields:
|
||||
fields[key]['format'] = 'date-time'
|
||||
if key in date_fields:
|
||||
self._fields[key]['format'] = 'date-time'
|
||||
|
||||
return fields
|
||||
return self._fields
|
||||
|
||||
@crs_transform
|
||||
def query(self, offset=0, limit=10, resulttype='results',
|
||||
|
||||
@@ -87,7 +87,7 @@ class ElasticsearchProvider(BaseProvider):
|
||||
|
||||
LOGGER.debug('Grabbing field information')
|
||||
try:
|
||||
self.fields = self.get_fields()
|
||||
self.get_fields()
|
||||
except exceptions.NotFoundError as err:
|
||||
LOGGER.error(err)
|
||||
raise ProviderQueryError(err)
|
||||
@@ -98,38 +98,40 @@ class ElasticsearchProvider(BaseProvider):
|
||||
|
||||
:returns: dict of fields
|
||||
"""
|
||||
if not self._fields:
|
||||
ii = self.es.indices.get(index=self.index_name,
|
||||
allow_no_indices=False)
|
||||
|
||||
fields_ = {}
|
||||
ii = self.es.indices.get(index=self.index_name, allow_no_indices=False)
|
||||
|
||||
LOGGER.debug(f'Response: {ii}')
|
||||
try:
|
||||
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']}
|
||||
LOGGER.debug(f'Response: {ii}')
|
||||
try:
|
||||
if '*' not in self.index_name:
|
||||
mappings = ii[self.index_name]['mappings']
|
||||
p = mappings['properties']['properties']
|
||||
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
|
||||
def query(self, offset=0, limit=10, resulttype='results',
|
||||
|
||||
+13
-12
@@ -62,24 +62,25 @@ class TabledapProvider(BaseProvider):
|
||||
|
||||
LOGGER.debug('Setting provider query filters')
|
||||
self.filters = self.options.get('filters')
|
||||
self.fields = self.get_fields()
|
||||
self.get_fields()
|
||||
|
||||
def get_fields(self):
|
||||
LOGGER.debug('Fetching one feature for field definitions')
|
||||
properties = self.query(limit=1)['features'][0]['properties']
|
||||
if not self._fields:
|
||||
LOGGER.debug('Fetching one feature for field definitions')
|
||||
properties = self.query(limit=1)['features'][0]['properties']
|
||||
|
||||
for key, value in properties.items():
|
||||
LOGGER.debug(f'Field: {key}={value}')
|
||||
for key, value in properties.items():
|
||||
LOGGER.debug(f'Field: {key}={value}')
|
||||
|
||||
data_type = type(value).__name__
|
||||
data_type = type(value).__name__
|
||||
|
||||
if data_type == 'str':
|
||||
data_type = 'string'
|
||||
if data_type == 'float':
|
||||
data_type = 'number'
|
||||
properties[key] = {'type': data_type}
|
||||
if data_type == 'str':
|
||||
data_type = 'string'
|
||||
if data_type == 'float':
|
||||
data_type = 'number'
|
||||
self._fields[key] = {'type': data_type}
|
||||
|
||||
return properties
|
||||
return self._fields
|
||||
|
||||
@crs_transform
|
||||
def query(self, offset=0, limit=10, resulttype='results',
|
||||
|
||||
@@ -62,8 +62,9 @@ class ESRIServiceProvider(BaseProvider):
|
||||
self.crs = provider_def.get('crs', '4326')
|
||||
self.username = provider_def.get('username')
|
||||
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.session = Session()
|
||||
|
||||
self.login()
|
||||
@@ -76,7 +77,7 @@ class ESRIServiceProvider(BaseProvider):
|
||||
:returns: `dict` of fields
|
||||
"""
|
||||
|
||||
if not self.fields:
|
||||
if not self._fields:
|
||||
# Load fields
|
||||
params = {'f': 'pjson'}
|
||||
resp = self.get_response(self.data, params=params)
|
||||
@@ -102,9 +103,9 @@ class ESRIServiceProvider(BaseProvider):
|
||||
raise ProviderTypeError(msg)
|
||||
|
||||
for _ in resp['fields']:
|
||||
self.fields.update({_['name']: {'type': _['type']}})
|
||||
self._fields.update({_['name']: {'type': _['type']}})
|
||||
|
||||
return self.fields
|
||||
return self._fields
|
||||
|
||||
@crs_transform
|
||||
def query(self, offset=0, limit=10, resulttype='results',
|
||||
@@ -194,16 +195,15 @@ class ESRIServiceProvider(BaseProvider):
|
||||
msg = 'Missing ESRI login information, not setting token'
|
||||
LOGGER.debug(msg)
|
||||
return
|
||||
|
||||
params = {
|
||||
'f': 'pjson',
|
||||
'username': self.username,
|
||||
'password': self.password,
|
||||
'referer': ARCGIS_URL
|
||||
'referer': self.token_referer
|
||||
}
|
||||
|
||||
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')
|
||||
# https://enterprise.arcgis.com/en/server/latest/administer/windows/about-arcgis-tokens.htm
|
||||
self.session.headers.update({
|
||||
|
||||
@@ -68,7 +68,7 @@ class GeoJSONProvider(BaseProvider):
|
||||
"""initializer"""
|
||||
|
||||
super().__init__(provider_def)
|
||||
self.fields = self.get_fields()
|
||||
self.get_fields()
|
||||
|
||||
def get_fields(self):
|
||||
"""
|
||||
@@ -77,23 +77,24 @@ class GeoJSONProvider(BaseProvider):
|
||||
:returns: dict of fields
|
||||
"""
|
||||
|
||||
fields = {}
|
||||
LOGGER.debug('Treating all columns as string types')
|
||||
if os.path.exists(self.data):
|
||||
with open(self.data) as src:
|
||||
data = json.loads(src.read())
|
||||
for key, value in data['features'][0]['properties'].items():
|
||||
if isinstance(value, float):
|
||||
type_ = 'number'
|
||||
elif isinstance(value, int):
|
||||
type_ = 'integer'
|
||||
else:
|
||||
type_ = 'string'
|
||||
if not self._fields:
|
||||
LOGGER.debug('Treating all columns as string types')
|
||||
if os.path.exists(self.data):
|
||||
with open(self.data) as src:
|
||||
data = json.loads(src.read())
|
||||
for key, value in data['features'][0]['properties'].items():
|
||||
if isinstance(value, float):
|
||||
type_ = 'number'
|
||||
elif isinstance(value, int):
|
||||
type_ = 'integer'
|
||||
else:
|
||||
type_ = 'string'
|
||||
|
||||
fields[key] = {'type': type_}
|
||||
else:
|
||||
LOGGER.warning(f'File {self.data} does not exist.')
|
||||
return fields
|
||||
self._fields[key] = {'type': type_}
|
||||
else:
|
||||
LOGGER.warning(f'File {self.data} does not exist.')
|
||||
|
||||
return self._fields
|
||||
|
||||
def _load(self, skip_geometry=None, properties=[], select_properties=[]):
|
||||
"""Load and validate the source GeoJSON file
|
||||
|
||||
+16
-17
@@ -66,7 +66,7 @@ class MongoProvider(BaseProvider):
|
||||
self.featuredb = dbclient.get_default_database()
|
||||
self.collection = provider_def['collection']
|
||||
self.featuredb[self.collection].create_index([("geometry", GEOSPHERE)])
|
||||
self.fields = self.get_fields()
|
||||
self.get_fields()
|
||||
|
||||
def get_fields(self):
|
||||
"""
|
||||
@@ -75,25 +75,24 @@ class MongoProvider(BaseProvider):
|
||||
:returns: dict of fields
|
||||
"""
|
||||
|
||||
pipeline = [
|
||||
{"$project": {"properties": 1}},
|
||||
{"$unwind": "$properties"},
|
||||
{"$group": {"_id": "$properties", "count": {"$sum": 1}}},
|
||||
{"$project": {"_id": 1}}
|
||||
]
|
||||
if not self._fields:
|
||||
pipeline = [
|
||||
{"$project": {"properties": 1}},
|
||||
{"$unwind": "$properties"},
|
||||
{"$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
|
||||
# set the field type to 'string'.
|
||||
# by operating without a schema, mongo can query any data type.
|
||||
fields = {}
|
||||
# prepare a dictionary with fields
|
||||
# set the field type to 'string'.
|
||||
# by operating without a schema, mongo can query any data type.
|
||||
for i in result:
|
||||
for key in result[0]['_id'].keys():
|
||||
self._fields[key] = {'type': 'string'}
|
||||
|
||||
for i in result:
|
||||
for key in result[0]['_id'].keys():
|
||||
fields[key] = {'type': 'string'}
|
||||
|
||||
return fields
|
||||
return self._fields
|
||||
|
||||
def _get_feature_list(self, filterObj, sortList=[], skip=0, maxitems=1,
|
||||
skip_geometry=False):
|
||||
|
||||
+30
-30
@@ -188,7 +188,7 @@ class OGRProvider(BaseProvider):
|
||||
self.conn = None
|
||||
|
||||
LOGGER.debug('Grabbing field information')
|
||||
self.fields = self.get_fields()
|
||||
self.get_fields()
|
||||
|
||||
def _list_open_options(self):
|
||||
return [
|
||||
@@ -260,43 +260,43 @@ class OGRProvider(BaseProvider):
|
||||
:returns: dict of fields
|
||||
"""
|
||||
|
||||
fields = {}
|
||||
try:
|
||||
layer_defn = self._get_layer().GetLayerDefn()
|
||||
for fld in range(layer_defn.GetFieldCount()):
|
||||
field_defn = layer_defn.GetFieldDefn(fld)
|
||||
fieldName = field_defn.GetName()
|
||||
fieldTypeCode = field_defn.GetType()
|
||||
fieldType = field_defn.GetFieldTypeName(fieldTypeCode)
|
||||
if not self._fields:
|
||||
try:
|
||||
layer_defn = self._get_layer().GetLayerDefn()
|
||||
for fld in range(layer_defn.GetFieldCount()):
|
||||
field_defn = layer_defn.GetFieldDefn(fld)
|
||||
fieldName = field_defn.GetName()
|
||||
fieldTypeCode = field_defn.GetType()
|
||||
fieldType = field_defn.GetFieldTypeName(fieldTypeCode)
|
||||
|
||||
fieldName2 = fieldType.lower()
|
||||
fieldName2 = fieldType.lower()
|
||||
|
||||
if fieldName2 == 'integer64':
|
||||
fieldName2 = 'integer'
|
||||
elif fieldName2 == 'real':
|
||||
fieldName2 = 'number'
|
||||
if fieldName2 == 'integer64':
|
||||
fieldName2 = 'integer'
|
||||
elif fieldName2 == 'real':
|
||||
fieldName2 = 'number'
|
||||
|
||||
fields[fieldName] = {'type': fieldName2}
|
||||
self._fields[fieldName] = {'type': fieldName2}
|
||||
|
||||
if fieldName2 == 'datetime':
|
||||
fields[fieldName] = {
|
||||
'type': 'string',
|
||||
'format': 'date-time'
|
||||
}
|
||||
if fieldName2 == 'datetime':
|
||||
self._fields[fieldName] = {
|
||||
'type': 'string',
|
||||
'format': 'date-time'
|
||||
}
|
||||
|
||||
# fieldWidth = layer_defn.GetFieldDefn(fld).GetWidth()
|
||||
# GetPrecision = layer_defn.GetFieldDefn(fld).GetPrecision()
|
||||
# fieldWidth = layer_defn.GetFieldDefn(fld).GetWidth()
|
||||
# GetPrecision = layer_defn.GetFieldDefn(fld).GetPrecision() # noqa
|
||||
|
||||
except RuntimeError as err:
|
||||
LOGGER.error(err)
|
||||
raise ProviderConnectionError(err)
|
||||
except Exception as err:
|
||||
LOGGER.error(err)
|
||||
except RuntimeError as err:
|
||||
LOGGER.error(err)
|
||||
raise ProviderConnectionError(err)
|
||||
except Exception as err:
|
||||
LOGGER.error(err)
|
||||
|
||||
finally:
|
||||
self._close()
|
||||
finally:
|
||||
self._close()
|
||||
|
||||
return fields
|
||||
return self._fields
|
||||
|
||||
def query(self, offset=0, limit=10, resulttype='results',
|
||||
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
|
||||
Lock is implemented before function call at __init__"""
|
||||
dsn = cls._make_dsn(conn_dict)
|
||||
# Create the pool
|
||||
|
||||
p = oracledb.create_pool(
|
||||
user=conn_dict["user"],
|
||||
password=conn_dict["password"],
|
||||
dsn=dsn,
|
||||
min=oracle_pool_min,
|
||||
max=oracle_pool_max,
|
||||
increment=1,
|
||||
)
|
||||
LOGGER.debug("Connection pool created successfully.")
|
||||
connect_kwargs = {
|
||||
'dsn': dsn,
|
||||
'min': oracle_pool_min,
|
||||
'max': oracle_pool_max,
|
||||
'increment': 1
|
||||
}
|
||||
|
||||
# Create the pool
|
||||
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
|
||||
|
||||
@@ -435,12 +449,12 @@ class OracleProvider(BaseProvider):
|
||||
"""
|
||||
LOGGER.debug("Get available fields/properties")
|
||||
|
||||
if not self.fields:
|
||||
if not self._fields:
|
||||
with DatabaseConnection(
|
||||
self.conn_dic, self.table, properties=self.properties
|
||||
) as db:
|
||||
self.fields = db.fields
|
||||
return self.fields
|
||||
self._fields = db.fields
|
||||
return self._fields
|
||||
|
||||
def _get_where_clauses(
|
||||
self,
|
||||
@@ -633,6 +647,19 @@ class OracleProvider(BaseProvider):
|
||||
|
||||
: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
|
||||
property_dict = dict(properties)
|
||||
@@ -790,6 +817,7 @@ class OracleProvider(BaseProvider):
|
||||
q,
|
||||
language,
|
||||
filterq,
|
||||
extra_params=extra_params
|
||||
)
|
||||
|
||||
# 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
|
||||
from sqlalchemy import create_engine, MetaData, PrimaryKeyConstraint, asc, desc
|
||||
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.orm import Session, load_only
|
||||
from sqlalchemy.sql.expression import and_
|
||||
@@ -124,7 +125,7 @@ class PostgreSQLProvider(BaseProvider):
|
||||
)
|
||||
|
||||
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',
|
||||
bbox=[], datetime_=None, properties=[], sortby=[],
|
||||
@@ -204,8 +205,6 @@ class PostgreSQLProvider(BaseProvider):
|
||||
|
||||
LOGGER.debug('Get available fields/properties')
|
||||
|
||||
fields = {}
|
||||
|
||||
# sql-schema only allows these types, so we need to map from sqlalchemy
|
||||
# string, number, integer, object, array, boolean, null,
|
||||
# https://json-schema.org/understanding-json-schema/reference/type.html
|
||||
@@ -248,17 +247,18 @@ class PostgreSQLProvider(BaseProvider):
|
||||
LOGGER.debug('No string format detected')
|
||||
return None
|
||||
|
||||
for column in self.table_model.__table__.columns:
|
||||
LOGGER.debug(f'Testing {column.name}')
|
||||
if column.name == self.geom:
|
||||
continue
|
||||
if not self._fields:
|
||||
for column in self.table_model.__table__.columns:
|
||||
LOGGER.debug(f'Testing {column.name}')
|
||||
if column.name == self.geom:
|
||||
continue
|
||||
|
||||
fields[str(column.name)] = {
|
||||
'type': _column_type_to_json_schema_type(column.type),
|
||||
'format': _column_format_to_json_schema_format(column.type)
|
||||
}
|
||||
self._fields[str(column.name)] = {
|
||||
'type': _column_type_to_json_schema_type(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):
|
||||
"""
|
||||
@@ -516,7 +516,7 @@ def get_table_model(
|
||||
sqlalchemy_table_def = metadata.tables[f'{schema}.{table_name}']
|
||||
try:
|
||||
sqlalchemy_table_def.append_constraint(PrimaryKeyConstraint(id_field))
|
||||
except KeyError:
|
||||
except (ConstraintColumnNotFoundError, KeyError):
|
||||
raise ProviderQueryError(
|
||||
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.crs = self._coverage_properties['bbox_crs']
|
||||
self.num_bands = self._coverage_properties['num_bands']
|
||||
self.fields = self.get_fields()
|
||||
self.get_fields()
|
||||
self.native_format = provider_def['format']['name']
|
||||
except Exception as err:
|
||||
LOGGER.warning(err)
|
||||
raise ProviderConnectionError(err)
|
||||
|
||||
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):
|
||||
LOGGER.debug(f'Adding field for band {i}')
|
||||
i2 = str(i)
|
||||
parameter = _get_parameter_metadata(
|
||||
self._data.profile['driver'], self._data.tags(i))
|
||||
|
||||
parameter = _get_parameter_metadata(
|
||||
self._data.profile['driver'], self._data.tags(i))
|
||||
name = parameter['description']
|
||||
units = parameter.get('unit_label')
|
||||
|
||||
name = parameter['description']
|
||||
units = parameter.get('unit_label')
|
||||
dtype2 = dtype
|
||||
if dtype.startswith('float'):
|
||||
dtype2 = 'number'
|
||||
elif dtype.startswith('int'):
|
||||
dtype2 = 'integer'
|
||||
|
||||
dtype2 = dtype
|
||||
if dtype.startswith('float'):
|
||||
dtype2 = 'number'
|
||||
self._fields[i2] = {
|
||||
'title': name,
|
||||
'type': dtype2,
|
||||
'_meta': self._data.tags(i)
|
||||
}
|
||||
if units is not None:
|
||||
self._fields[i2]['x-ogc-unit'] = units
|
||||
|
||||
fields[i2] = {
|
||||
'title': name,
|
||||
'type': dtype2,
|
||||
'_meta': self._data.tags(i)
|
||||
}
|
||||
if units is not None:
|
||||
fields[i2]['x-ogc-unit'] = units
|
||||
|
||||
return fields
|
||||
return self._fields
|
||||
|
||||
def query(self, properties=[], subsets={}, bbox=None, bbox_crs=4326,
|
||||
datetime_=None, format_='json', **kwargs):
|
||||
@@ -241,16 +242,15 @@ class RasterioProvider(BaseProvider):
|
||||
out_meta['units'] = _data.units
|
||||
|
||||
LOGGER.debug('Serializing data in memory')
|
||||
with MemoryFile() as memfile:
|
||||
with memfile.open(**out_meta) as dest:
|
||||
dest.write(out_image)
|
||||
if format_ == 'json':
|
||||
LOGGER.debug('Creating output in CoverageJSON')
|
||||
out_meta['bands'] = args['indexes']
|
||||
return self.gen_covjson(out_meta, out_image)
|
||||
|
||||
if format_ == 'json':
|
||||
LOGGER.debug('Creating output in CoverageJSON')
|
||||
out_meta['bands'] = args['indexes']
|
||||
return self.gen_covjson(out_meta, out_image)
|
||||
|
||||
else: # return data in native format
|
||||
else: # return data in native format
|
||||
with MemoryFile() as memfile:
|
||||
with memfile.open(**out_meta) as dest:
|
||||
dest.write(out_image)
|
||||
LOGGER.debug('Returning data in native format')
|
||||
return memfile.read()
|
||||
|
||||
|
||||
+147
-112
@@ -30,14 +30,14 @@
|
||||
# =================================================================
|
||||
|
||||
from json.decoder import JSONDecodeError
|
||||
import os
|
||||
import logging
|
||||
from requests import Session
|
||||
|
||||
from pygeoapi.config import get_config
|
||||
from pygeoapi.provider.base import (
|
||||
BaseProvider, ProviderQueryError, ProviderConnectionError)
|
||||
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__)
|
||||
|
||||
@@ -51,10 +51,10 @@ ENTITY = {
|
||||
_EXPAND = {
|
||||
'Things': 'Locations,Datastreams',
|
||||
'Observations': 'Datastream,FeatureOfInterest',
|
||||
'ObservedProperties': 'Datastreams/Thing/Locations',
|
||||
'Datastreams': """
|
||||
Sensor
|
||||
,ObservedProperty
|
||||
,Thing
|
||||
,Thing/Locations
|
||||
,Observations(
|
||||
$select=@iot.id;
|
||||
@@ -71,6 +71,7 @@ EXPAND = {k: ''.join(v.split()).replace('_', ' ')
|
||||
|
||||
class SensorThingsProvider(BaseProvider):
|
||||
"""SensorThings API (STA) Provider"""
|
||||
expand = EXPAND
|
||||
|
||||
def __init__(self, provider_def):
|
||||
"""
|
||||
@@ -82,64 +83,12 @@ class SensorThingsProvider(BaseProvider):
|
||||
:returns: pygeoapi.provider.sensorthings.SensorThingsProvider
|
||||
"""
|
||||
LOGGER.debug('Setting SensorThings API (STA) provider')
|
||||
|
||||
self.linked_entity = {}
|
||||
super().__init__(provider_def)
|
||||
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}')
|
||||
|
||||
self._generate_mappings(provider_def)
|
||||
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
|
||||
self.http = Session()
|
||||
self.get_fields()
|
||||
@@ -150,7 +99,7 @@ class SensorThingsProvider(BaseProvider):
|
||||
|
||||
:returns: dict of fields
|
||||
"""
|
||||
if not self.fields:
|
||||
if not self._fields:
|
||||
r = self._get_response(self._url, {'$top': 1})
|
||||
try:
|
||||
results = r['value'][0]
|
||||
@@ -161,11 +110,11 @@ class SensorThingsProvider(BaseProvider):
|
||||
for (n, v) in results.items():
|
||||
if isinstance(v, (int, float)) or \
|
||||
(isinstance(v, (dict, list)) and n in ENTITY):
|
||||
self.fields[n] = {'type': 'number'}
|
||||
self._fields[n] = {'type': 'number'}
|
||||
elif isinstance(v, str):
|
||||
self.fields[n] = {'type': 'string'}
|
||||
self._fields[n] = {'type': 'string'}
|
||||
|
||||
return self.fields
|
||||
return self._fields
|
||||
|
||||
@crs_transform
|
||||
def query(self, offset=0, limit=10, resulttype='results',
|
||||
@@ -272,17 +221,19 @@ class SensorThingsProvider(BaseProvider):
|
||||
|
||||
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
|
||||
|
||||
:param entity: `dict` of STA entity
|
||||
:param feature: `dict` of STA entity
|
||||
:param select_properties: list of property names
|
||||
:param skip_geometry: bool of whether to skip geometry (default False)
|
||||
:param entity: SensorThings entity name
|
||||
|
||||
:returns: dict of GeoJSON Feature
|
||||
"""
|
||||
_ = entity.pop(self.id_field)
|
||||
_ = feature.pop(self.id_field)
|
||||
id = f"'{_}'" if isinstance(_, str) else str(_)
|
||||
f = {
|
||||
'type': 'Feature', 'id': id, 'properties': {}, 'geometry': None
|
||||
@@ -290,28 +241,35 @@ class SensorThingsProvider(BaseProvider):
|
||||
|
||||
# Make geometry
|
||||
if not skip_geometry:
|
||||
f['geometry'] = self._geometry(entity)
|
||||
f['geometry'] = self._geometry(feature, entity)
|
||||
|
||||
# Fill properties block
|
||||
try:
|
||||
f['properties'] = self._expand_properties(
|
||||
entity, select_properties)
|
||||
feature, select_properties, entity)
|
||||
except KeyError as err:
|
||||
LOGGER.error(err)
|
||||
raise ProviderQueryError(err)
|
||||
|
||||
return f
|
||||
|
||||
def _get_response(self, url, params={}):
|
||||
def _get_response(self, url, params={}, entity=None, expand=None):
|
||||
"""
|
||||
Private function: Get STA response
|
||||
|
||||
:param url: request url
|
||||
:param params: query parameters
|
||||
:param entity: SensorThings entity name
|
||||
:param expand: SensorThings expand query
|
||||
|
||||
|
||||
: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)
|
||||
|
||||
@@ -327,13 +285,15 @@ class SensorThingsProvider(BaseProvider):
|
||||
|
||||
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
|
||||
|
||||
:param properties: list of tuples (name, value)
|
||||
:param bbox: bounding box [minx,miny,maxx,maxy]
|
||||
:param datetime_: temporal (datestamp or extent)
|
||||
:param entity: SensorThings entity name
|
||||
|
||||
:returns: STA $filter string of properties
|
||||
"""
|
||||
@@ -345,16 +305,8 @@ class SensorThingsProvider(BaseProvider):
|
||||
ret.append(f'{name} eq {value}')
|
||||
|
||||
if bbox:
|
||||
minx, miny, maxx, maxy = bbox
|
||||
bbox_ = f'POLYGON (({minx} {miny}, {maxx} {miny}, \
|
||||
{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_}')")
|
||||
entity_ = entity or self.entity
|
||||
ret.append(self._make_bbox(bbox, entity_))
|
||||
|
||||
if datetime_ is not None:
|
||||
if self.time_field is None:
|
||||
@@ -373,6 +325,20 @@ class SensorThingsProvider(BaseProvider):
|
||||
|
||||
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):
|
||||
"""
|
||||
Private function: Make STA filter from query properties
|
||||
@@ -393,79 +359,85 @@ class SensorThingsProvider(BaseProvider):
|
||||
|
||||
return ','.join(ret)
|
||||
|
||||
def _geometry(self, entity):
|
||||
def _geometry(self, feature, entity=None):
|
||||
"""
|
||||
Private function: Retrieve STA geometry
|
||||
|
||||
:param entity: SensorThings entity
|
||||
:param feature: SensorThings entity
|
||||
:param entity: SensorThings entity name
|
||||
|
||||
:returns: GeoJSON Geometry for feature
|
||||
"""
|
||||
entity_ = entity or self.entity
|
||||
try:
|
||||
if self.entity == 'Things':
|
||||
return entity['Locations'][0]['location']
|
||||
if entity_ == 'Things':
|
||||
return feature['Locations'][0]['location']
|
||||
|
||||
elif self.entity == 'Observations':
|
||||
return entity['FeatureOfInterest'].pop('feature')
|
||||
elif entity_ == 'Observations':
|
||||
return feature['FeatureOfInterest'].pop('feature')
|
||||
|
||||
elif self.entity == 'Datastreams':
|
||||
elif entity_ == 'Datastreams':
|
||||
try:
|
||||
return entity['Observations'][0]['FeatureOfInterest'].pop('feature') # noqa
|
||||
return feature['Observations'][0]['FeatureOfInterest'].pop('feature') # noqa
|
||||
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):
|
||||
LOGGER.warning('No geometry found')
|
||||
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
|
||||
|
||||
:param entity: SensorThings entity
|
||||
:param feature: `dict` of SensorThings entity
|
||||
:param keys: keys used in properties block
|
||||
:param uri: uri of STA entity
|
||||
:param entity: SensorThings entity name
|
||||
|
||||
:returns: dict of SensorThings feature properties
|
||||
"""
|
||||
LOGGER.debug('Adding extra properties')
|
||||
|
||||
# Properties filter & display
|
||||
keys = (() if not self.properties and not keys else
|
||||
set(self.properties) | set(keys))
|
||||
|
||||
if self.entity == 'Things':
|
||||
self._expand_location(entity)
|
||||
elif 'Thing' in entity.keys():
|
||||
self._expand_location(entity['Thing'])
|
||||
entity = entity or self.entity
|
||||
if entity == 'Things':
|
||||
self._expand_location(feature)
|
||||
elif 'Thing' in feature.keys():
|
||||
self._expand_location(feature['Thing'])
|
||||
|
||||
# Retain URI if present
|
||||
if entity.get('properties') and self.uri_field:
|
||||
uri = entity['properties']
|
||||
if feature.get('properties') and self.uri_field:
|
||||
uri = feature['properties']
|
||||
|
||||
# Create intra links
|
||||
LOGGER.debug('Creating intralinks')
|
||||
for k, v in entity.items():
|
||||
if k in self.links:
|
||||
entity[k] = [self._get_uri(_v, **self.links[k]) for _v in v]
|
||||
for k, v in feature.items():
|
||||
if k in self.linked_entity:
|
||||
feature[k] = [self._get_uri(_v, **self.linked_entity[k])
|
||||
for _v in v]
|
||||
LOGGER.debug(f'Created link for {k}')
|
||||
elif f'{k}s' in self.links:
|
||||
entity[k] = self._get_uri(v, **self.links[f'{k}s'])
|
||||
elif f'{k}s' in self.linked_entity:
|
||||
feature[k] = \
|
||||
self._get_uri(v, **self.linked_entity[f'{k}s'])
|
||||
LOGGER.debug(f'Created link for {k}')
|
||||
|
||||
# Make properties block
|
||||
LOGGER.debug('Making properties block')
|
||||
if entity.get('properties'):
|
||||
entity.update(entity.pop('properties'))
|
||||
if feature.get('properties'):
|
||||
feature.update(feature.pop('properties'))
|
||||
|
||||
if keys:
|
||||
ret = {k: entity.pop(k) for k in keys}
|
||||
entity = ret
|
||||
ret = {k: feature.pop(k) for k in keys}
|
||||
feature = ret
|
||||
|
||||
if self.uri_field is not None and uri != '':
|
||||
entity[self.uri_field] = uri
|
||||
feature[self.uri_field] = uri
|
||||
|
||||
return entity
|
||||
return feature
|
||||
|
||||
@staticmethod
|
||||
def _expand_location(entity):
|
||||
@@ -517,5 +489,68 @@ class SensorThingsProvider(BaseProvider):
|
||||
else:
|
||||
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):
|
||||
return f'<SensorThingsProvider> {self.data}, {self.entity}'
|
||||
|
||||
@@ -75,7 +75,7 @@ class SODAServiceProvider(BaseProvider):
|
||||
:returns: dict of fields
|
||||
"""
|
||||
|
||||
if not self.fields:
|
||||
if not self._fields:
|
||||
|
||||
try:
|
||||
[dataset] = self.client.datasets(ids=[self.resource_id])
|
||||
@@ -87,9 +87,9 @@ class SODAServiceProvider(BaseProvider):
|
||||
fields = self.properties or resource[FIELD_NAME]
|
||||
for field in fields:
|
||||
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
|
||||
def query(self, offset=0, limit=10, resulttype='results',
|
||||
|
||||
@@ -88,7 +88,7 @@ class SQLiteGPKGProvider(BaseProvider):
|
||||
:returns: dict of fields
|
||||
"""
|
||||
|
||||
if not self.fields:
|
||||
if not self._fields:
|
||||
results = self.cursor.execute(
|
||||
f'PRAGMA table_info({self.table})').fetchall()
|
||||
for item in results:
|
||||
@@ -100,9 +100,9 @@ class SQLiteGPKGProvider(BaseProvider):
|
||||
json_type = 'string'
|
||||
|
||||
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=[]):
|
||||
"""
|
||||
|
||||
@@ -74,7 +74,7 @@ class TinyDBProvider(BaseProvider):
|
||||
else:
|
||||
self.db = TinyDB(self.data)
|
||||
|
||||
self.fields = self.get_fields()
|
||||
self.get_fields()
|
||||
|
||||
def get_fields(self):
|
||||
"""
|
||||
@@ -83,38 +83,37 @@ class TinyDBProvider(BaseProvider):
|
||||
:returns: dict of fields
|
||||
"""
|
||||
|
||||
fields = {}
|
||||
if not self._fields:
|
||||
try:
|
||||
r = self.db.all()[0]
|
||||
except IndexError as err:
|
||||
LOGGER.debug(err)
|
||||
return {}
|
||||
|
||||
try:
|
||||
r = self.db.all()[0]
|
||||
except IndexError as err:
|
||||
LOGGER.debug(err)
|
||||
return fields
|
||||
|
||||
for key, value in r['properties'].items():
|
||||
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'
|
||||
for key, value in r['properties'].items():
|
||||
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:
|
||||
fields[key]['format'] = 'date'
|
||||
except Exception:
|
||||
LOGGER.debug('No date types detected')
|
||||
pass
|
||||
typed_value_type = 'string'
|
||||
|
||||
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
|
||||
def query(self, offset=0, limit=10, resulttype='results',
|
||||
@@ -349,7 +348,10 @@ class TinyDBCatalogueProvider(TinyDBProvider):
|
||||
def __init__(self, provider_def):
|
||||
super().__init__(provider_def)
|
||||
|
||||
LOGGER.debug('Refreshing fields')
|
||||
self._excludes = ['_metadata-anytext']
|
||||
self._fields = {}
|
||||
self.get_fields()
|
||||
|
||||
def get_fields(self):
|
||||
fields = super().get_fields()
|
||||
|
||||
@@ -84,7 +84,9 @@ class WMSFacadeProvider(BaseProvider):
|
||||
|
||||
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)')
|
||||
bbox2 = ','.join(str(c) for c in
|
||||
[bbox[1], bbox[0], bbox[3], bbox[2]])
|
||||
@@ -106,12 +108,14 @@ class WMSFacadeProvider(BaseProvider):
|
||||
if not transparent:
|
||||
self._transparent = 'FALSE'
|
||||
|
||||
crs_param = 'crs' if version == '1.3.0' else 'srs'
|
||||
|
||||
params = {
|
||||
'version': '1.3.0',
|
||||
'version': version,
|
||||
'service': 'WMS',
|
||||
'request': 'GetMap',
|
||||
'bbox': bbox2,
|
||||
'crs': CRS_CODES[crs],
|
||||
crs_param: CRS_CODES[crs],
|
||||
'layers': self.options['layer'],
|
||||
'styles': self.options.get('style', 'default'),
|
||||
'width': width,
|
||||
@@ -128,7 +132,7 @@ class WMSFacadeProvider(BaseProvider):
|
||||
else:
|
||||
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)
|
||||
|
||||
|
||||
+210
-80
@@ -37,12 +37,16 @@ import zipfile
|
||||
import xarray
|
||||
import fsspec
|
||||
import numpy as np
|
||||
import pyproj
|
||||
from pyproj.exceptions import CRSError
|
||||
|
||||
from pygeoapi.api import DEFAULT_STORAGE_CRS
|
||||
|
||||
from pygeoapi.provider.base import (BaseProvider,
|
||||
ProviderConnectionError,
|
||||
ProviderNoDataError,
|
||||
ProviderQueryError)
|
||||
from pygeoapi.util import read_data
|
||||
from pygeoapi.util import get_crs_from_uri, read_data
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -81,35 +85,43 @@ class XarrayProvider(BaseProvider):
|
||||
else:
|
||||
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.axes = [self._coverage_properties['x_axis_label'],
|
||||
self._coverage_properties['y_axis_label'],
|
||||
self._coverage_properties['time_axis_label']]
|
||||
self.axes = self._coverage_properties['axes']
|
||||
|
||||
self.fields = self.get_fields()
|
||||
self.get_fields()
|
||||
except Exception as err:
|
||||
LOGGER.warning(err)
|
||||
raise ProviderConnectionError(err)
|
||||
|
||||
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():
|
||||
if len(value.shape) >= 3:
|
||||
LOGGER.debug('Adding variable')
|
||||
dtype = value.dtype
|
||||
if dtype.name.startswith('float'):
|
||||
dtype = 'number'
|
||||
self._fields[key] = {
|
||||
'type': dtype,
|
||||
'title': value.attrs.get('long_name'),
|
||||
'x-ogc-unit': value.attrs.get('units')
|
||||
}
|
||||
|
||||
fields[key] = {
|
||||
'type': dtype,
|
||||
'title': value.attrs['long_name'],
|
||||
'x-ogc-unit': value.attrs.get('units')
|
||||
}
|
||||
|
||||
return fields
|
||||
return self._fields
|
||||
|
||||
def query(self, properties=[], subsets={}, bbox=[], bbox_crs=4326,
|
||||
datetime_=None, format_='json', **kwargs):
|
||||
@@ -138,9 +150,9 @@ class XarrayProvider(BaseProvider):
|
||||
|
||||
data = self._data[[*properties]]
|
||||
|
||||
if any([self._coverage_properties['x_axis_label'] in subsets,
|
||||
self._coverage_properties['y_axis_label'] in subsets,
|
||||
self._coverage_properties['time_axis_label'] in subsets,
|
||||
if any([self._coverage_properties.get('x_axis_label') in subsets,
|
||||
self._coverage_properties.get('y_axis_label') in subsets,
|
||||
self._coverage_properties.get('time_axis_label') in subsets,
|
||||
datetime_ is not None]):
|
||||
|
||||
LOGGER.debug('Creating spatio-temporal subset')
|
||||
@@ -159,18 +171,36 @@ class XarrayProvider(BaseProvider):
|
||||
self._coverage_properties['y_axis_label'] in subsets,
|
||||
len(bbox) > 0]):
|
||||
msg = 'bbox and subsetting by coordinates are exclusive'
|
||||
LOGGER.warning(msg)
|
||||
LOGGER.error(msg)
|
||||
raise ProviderQueryError(msg)
|
||||
else:
|
||||
query_params[self._coverage_properties['x_axis_label']] = \
|
||||
slice(bbox[0], bbox[2])
|
||||
query_params[self._coverage_properties['y_axis_label']] = \
|
||||
slice(bbox[1], bbox[3])
|
||||
x_axis_label = self._coverage_properties['x_axis_label']
|
||||
x_coords = data.coords[x_axis_label]
|
||||
if x_coords.values[0] > x_coords.values[-1]:
|
||||
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')
|
||||
|
||||
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'
|
||||
LOGGER.error(msg)
|
||||
raise ProviderQueryError(msg)
|
||||
@@ -192,13 +222,15 @@ class XarrayProvider(BaseProvider):
|
||||
LOGGER.warning(err)
|
||||
raise ProviderQueryError(err)
|
||||
|
||||
if (any([data.coords[self.x_field].size == 0,
|
||||
data.coords[self.y_field].size == 0,
|
||||
data.coords[self.time_field].size == 0])):
|
||||
if any(size == 0 for size in data.sizes.values()):
|
||||
msg = 'No data found'
|
||||
LOGGER.warning(msg)
|
||||
raise ProviderNoDataError(msg)
|
||||
|
||||
if format_ == 'json':
|
||||
# json does not support float32
|
||||
data = _convert_float32_to_float64(data)
|
||||
|
||||
out_meta = {
|
||||
'bbox': [
|
||||
data.coords[self.x_field].values[0],
|
||||
@@ -206,18 +238,20 @@ class XarrayProvider(BaseProvider):
|
||||
data.coords[self.x_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",
|
||||
"height": data.sizes[self.y_field],
|
||||
"width": data.sizes[self.x_field],
|
||||
"time_steps": data.sizes[self.time_field],
|
||||
"variables": {var_name: var.attrs
|
||||
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')
|
||||
if format_ == 'json':
|
||||
LOGGER.debug('Creating output in CoverageJSON')
|
||||
@@ -226,9 +260,11 @@ class XarrayProvider(BaseProvider):
|
||||
LOGGER.debug('Returning data in native zarr format')
|
||||
return _get_zarr_data(data)
|
||||
else: # return data in native format
|
||||
with tempfile.TemporaryFile() as fp:
|
||||
with tempfile.NamedTemporaryFile() as fp:
|
||||
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)
|
||||
return fp.read()
|
||||
|
||||
@@ -238,14 +274,18 @@ class XarrayProvider(BaseProvider):
|
||||
|
||||
:param metadata: coverage metadata
|
||||
:param data: rasterio DatasetReader object
|
||||
:param fields: fields dict
|
||||
:param fields: fields
|
||||
|
||||
:returns: dict of CoverageJSON representation
|
||||
"""
|
||||
|
||||
LOGGER.debug('Creating CoverageJSON domain')
|
||||
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:
|
||||
tmp_min = data.coords[self.y_field].values[0]
|
||||
@@ -276,11 +316,6 @@ class XarrayProvider(BaseProvider):
|
||||
'start': maxy,
|
||||
'stop': miny,
|
||||
'num': metadata['height']
|
||||
},
|
||||
self.time_field: {
|
||||
'start': mint,
|
||||
'stop': maxt,
|
||||
'num': metadata['time_steps']
|
||||
}
|
||||
},
|
||||
'referencing': [{
|
||||
@@ -295,7 +330,15 @@ class XarrayProvider(BaseProvider):
|
||||
'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 = {
|
||||
'type': 'Parameter',
|
||||
'description': value['title'],
|
||||
@@ -313,21 +356,25 @@ class XarrayProvider(BaseProvider):
|
||||
cj['parameters'][key] = parameter
|
||||
|
||||
data = data.fillna(None)
|
||||
data = _convert_float32_to_float64(data)
|
||||
|
||||
try:
|
||||
for key, value in self.fields.items():
|
||||
for key, value in selected_fields.items():
|
||||
cj['ranges'][key] = {
|
||||
'type': 'NdArray',
|
||||
'dataType': value['type'],
|
||||
'axisNames': [
|
||||
'y', 'x', self._coverage_properties['time_axis_label']
|
||||
'y', 'x'
|
||||
],
|
||||
'shape': [metadata['height'],
|
||||
metadata['width'],
|
||||
metadata['time_steps']]
|
||||
metadata['width']]
|
||||
}
|
||||
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:
|
||||
LOGGER.warning(err)
|
||||
raise ProviderQueryError('Invalid query parameter')
|
||||
@@ -337,6 +384,7 @@ class XarrayProvider(BaseProvider):
|
||||
def _get_coverage_properties(self):
|
||||
"""
|
||||
Helper function to normalize coverage properties
|
||||
:param provider_def: provider definition
|
||||
|
||||
:returns: `dict` of coverage properties
|
||||
"""
|
||||
@@ -372,48 +420,61 @@ class XarrayProvider(BaseProvider):
|
||||
self._data.coords[self.x_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',
|
||||
'crs_type': 'GeographicCRS',
|
||||
'x_axis_label': self.x_field,
|
||||
'y_axis_label': self.y_field,
|
||||
'time_axis_label': self.time_field,
|
||||
'width': self._data.sizes[self.x_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',
|
||||
'resx': np.abs(self._data.coords[self.x_field].values[1]
|
||||
- self._data.coords[self.x_field].values[0]),
|
||||
'resy': np.abs(self._data.coords[self.y_field].values[1]
|
||||
- self._data.coords[self.y_field].values[0]),
|
||||
'restime': self.get_time_resolution()
|
||||
'resx': np.abs(
|
||||
self._data.coords[self.x_field].values[1]
|
||||
- self._data.coords[self.x_field].values[0]
|
||||
),
|
||||
'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():
|
||||
try:
|
||||
properties['bbox_crs'] = f'http://www.opengis.net/def/crs/OGC/1.3/{self._data.crs.epsg_code}' # noqa
|
||||
|
||||
properties['inverse_flattening'] = self._data.crs.\
|
||||
inverse_flattening
|
||||
if self.time_field is not None:
|
||||
properties['time_axis_label'] = self.time_field
|
||||
properties['time_range'] = [
|
||||
_to_datetime_string(
|
||||
self._data.coords[self.time_field].values[0]
|
||||
),
|
||||
_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'
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
LOGGER.debug(f'properties: {properties}')
|
||||
|
||||
properties['axes'] = [
|
||||
properties['x_axis_label'],
|
||||
properties['y_axis_label'],
|
||||
properties['time_axis_label']
|
||||
properties['y_axis_label']
|
||||
]
|
||||
|
||||
if self.time_field is not None:
|
||||
properties['axes'].append(properties['time_axis_label'])
|
||||
|
||||
return properties
|
||||
|
||||
@staticmethod
|
||||
@@ -440,7 +501,8 @@ class XarrayProvider(BaseProvider):
|
||||
: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] -
|
||||
self._data[self.time_field][0])
|
||||
|
||||
@@ -457,6 +519,9 @@ class XarrayProvider(BaseProvider):
|
||||
: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]
|
||||
ms_difference = dur.values.astype('timedelta64[ms]').astype(np.double)
|
||||
|
||||
@@ -472,6 +537,71 @@ class XarrayProvider(BaseProvider):
|
||||
|
||||
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):
|
||||
"""
|
||||
@@ -554,7 +684,7 @@ def _convert_float32_to_float64(data):
|
||||
for var_name in data.variables:
|
||||
if data[var_name].dtype == 'float32':
|
||||
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
|
||||
|
||||
return data
|
||||
|
||||
@@ -81,14 +81,14 @@ class XarrayEDRProvider(BaseEDRProvider, XarrayProvider):
|
||||
wkt = kwargs.get('wkt')
|
||||
if wkt is not None:
|
||||
LOGGER.debug('Processing WKT')
|
||||
LOGGER.debug(f'Geometry type: {wkt.type}')
|
||||
if wkt.type == 'Point':
|
||||
LOGGER.debug(f'Geometry type: {wkt.geom_type}')
|
||||
if wkt.geom_type == 'Point':
|
||||
query_params[self._coverage_properties['x_axis_label']] = wkt.x
|
||||
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['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['y_axis_label']] = slice(wkt.bounds[1], wkt.bounds[3]) # noqa
|
||||
pass
|
||||
@@ -109,7 +109,7 @@ class XarrayEDRProvider(BaseEDRProvider, XarrayProvider):
|
||||
|
||||
try:
|
||||
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]]
|
||||
else:
|
||||
data = self._data
|
||||
@@ -206,7 +206,7 @@ class XarrayEDRProvider(BaseEDRProvider, XarrayProvider):
|
||||
LOGGER.debug(f'query parameters: {query_params}')
|
||||
try:
|
||||
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]]
|
||||
else:
|
||||
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:
|
||||
item_id = request.path_params['item_id']
|
||||
if item_id is None:
|
||||
if request.method == 'GET': # list 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
|
||||
if request.method == 'POST': # filter or manage items
|
||||
content_type = request.headers.get('content-type')
|
||||
if content_type is not None:
|
||||
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,
|
||||
'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':
|
||||
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)
|
||||
|
||||
|
||||
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
|
||||
|
||||
:param collection_id: collection identifier
|
||||
:param instance_id: instance identifier
|
||||
:param location_id: location id of a /locations/<location_id> query
|
||||
|
||||
: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:
|
||||
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(
|
||||
edr_api.get_collection_edr_query, request, collection_id,
|
||||
instance_id, query_type,
|
||||
instance_id, query_type, location_id,
|
||||
skip_valid_check=True,
|
||||
)
|
||||
|
||||
@@ -746,6 +752,7 @@ if CONFIG['server'].get('cors', False):
|
||||
CORSMiddleware,
|
||||
allow_origins=['*'],
|
||||
allow_methods=['*'],
|
||||
expose_headers=['*']
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -27,6 +27,15 @@ main {
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
#coverages-map {
|
||||
width: 100%;
|
||||
height: 80vh;
|
||||
}
|
||||
|
||||
.c3-tooltip-container {
|
||||
z-index: 300;
|
||||
}
|
||||
|
||||
/* cancel mini-css header>button uppercase */
|
||||
header button, header [type="button"], header .button, header [role="button"] {
|
||||
text-transform: none;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="{{ (locale|lower)[:2] }}" dir="{% trans %}text_direction{% endtrans %}" >
|
||||
<head>
|
||||
<meta charset="{{ config['server']['encoding'] }}">
|
||||
<title>{% block title %}{% endblock %}{% if not self.title() %}{{ config['metadata']['identification']['title'] }}{% endif %}</title>
|
||||
@@ -37,9 +37,9 @@
|
||||
<body>
|
||||
<div class="bg-light sticky-top border-bottom">
|
||||
<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'] }}"
|
||||
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"
|
||||
title="{{ config['metadata']['identification']['title'] }}" style="height:40px;vertical-align: middle;" /></a>
|
||||
<ul class="nav nav-pills">
|
||||
@@ -66,11 +66,11 @@
|
||||
{% block crumbs %}
|
||||
<a href="{{ config['server']['url'] }}">{% trans %}Home{% endtrans %}</a>
|
||||
{% endblock %}
|
||||
<span style="float:right">
|
||||
<span style="float: inline-end">
|
||||
{% set links_found = namespace(json=0, jsonld=0) %}
|
||||
|
||||
{% 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 %}
|
||||
<a href="{{ link['href'] }}">{% trans %}json{% endtrans %}</a>
|
||||
{% elif link['rel'] == 'alternate' and link['type'] and link['type'] == 'application/ld+json' %}
|
||||
@@ -102,9 +102,13 @@
|
||||
</div>
|
||||
</div>
|
||||
</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
|
||||
src="{{ config['server']['url'] }}/static/img/pygeoapi.png" class="mx-1" title="pygeoapi logo"
|
||||
style="height:24px;vertical-align: middle;" /></a> {{ version }}</footer>
|
||||
<footer class="sticky-bottom bg-light d-flex justify-content-center align-items-center py-3 px-3 border-top">
|
||||
<div class="text-center w-100">
|
||||
{% 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 %}
|
||||
{% endblock %}
|
||||
<script>
|
||||
|
||||
@@ -8,56 +8,281 @@
|
||||
{% set col_title = link['title'] %}
|
||||
{% endif %}
|
||||
{% 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 %}
|
||||
{% block extrahead %}
|
||||
<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">
|
||||
<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/covjson-reader@0.16/covjson-reader.src.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 %}
|
||||
|
||||
{% block body %}
|
||||
<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>
|
||||
{% endblock %}
|
||||
|
||||
{% block extrafoot %}
|
||||
{% if data %}
|
||||
<script>
|
||||
var map = L.map('items-map').setView([{{ 45 }}, {{ -75 }}], 5);
|
||||
map.addLayer(new L.TileLayer(
|
||||
var map = L.map('coverages-map').setView([40, -85], 3);
|
||||
var baseLayers = {
|
||||
'Map': new L.TileLayer(
|
||||
'{{ config['server']['map']['url'] }}', {
|
||||
maxZoom: 18,
|
||||
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) {
|
||||
new C.DraggableValuePopup({
|
||||
layers: [layer]
|
||||
}).setLatLng(e.latlng).openOn(map)
|
||||
function closeValuePopup () {
|
||||
if (map.hasLayer(valuePopup)) {
|
||||
map.closePopup(valuePopup)
|
||||
}
|
||||
}
|
||||
|
||||
// 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>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
<section id="items"></section>
|
||||
<section id="collection">
|
||||
<h1>{% for l in data['links'] if l.rel == 'collection' %} {{ l['title'] }} {% endfor %}</h1>
|
||||
<p>{% trans %}Items in this collection{% endtrans %}.</p>
|
||||
</section>
|
||||
<section id="items">
|
||||
{% if data['features'] %}
|
||||
@@ -35,7 +34,9 @@
|
||||
<div class="col-sm-12">
|
||||
<div class="row">
|
||||
<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 class="row">
|
||||
@@ -47,6 +48,7 @@
|
||||
<option value="1000">1,000</option>
|
||||
<option value="2000">2,000</option>
|
||||
</select>
|
||||
<p>{% trans %}Warning: Higher limits not recommended!{% endtrans %}</p>
|
||||
<script>
|
||||
var select = document.getElementById('limits');
|
||||
var defaultValue = select.getElementsByTagName('option')[0].value;
|
||||
@@ -134,6 +136,12 @@
|
||||
</table>
|
||||
</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 %}
|
||||
<div class="row col-sm-12">
|
||||
<p>{% trans %}No items{% endtrans %}</p>
|
||||
@@ -161,7 +169,6 @@
|
||||
layer.bindPopup(html);
|
||||
}
|
||||
});
|
||||
{% if data['features'][0]['geometry']['type'] == 'Point' %}
|
||||
var markers = L.markerClusterGroup({
|
||||
disableClusteringAtZoom: 9,
|
||||
chunkedLoading: true,
|
||||
@@ -169,9 +176,6 @@
|
||||
});
|
||||
markers.clearLayers().addLayer(items);
|
||||
map.addLayer(markers);
|
||||
{% else %}
|
||||
map.addLayer(items);
|
||||
{% endif %}
|
||||
|
||||
map.fitBounds(items.getBounds());
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% 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 tags %}{{ data['properties'].get('themes', [{}])[0].get('concepts', []) | join(',') }}{% endblock %}
|
||||
{# Optionally renders an img element, otherwise standard value or link rendering #}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
{% block title %}{{ super() }} {{ data['title'] }} {% endblock %}
|
||||
{% block crumbs %}{{ super() }}
|
||||
/ <a href="{{ data['collections_path'] }}">{% trans %}Collections{% endtrans %}</a>
|
||||
/ <a href="./{{ data['id'] }}">{{ data['title'] | truncate( 25 ) }}</a>
|
||||
/ <a href="./{{ data['id'] }}queryables">{% trans %}Queryables{% endtrans %}</a>
|
||||
/ <a href="{{ data['dataset_path'] }}">{{ data['title'] | truncate( 25 ) }}</a>
|
||||
/ <a href="{{ data['dataset_path'] }}/queryables">{% trans %}Queryables{% endtrans %}</a>
|
||||
{% endblock %}
|
||||
{% block body %}
|
||||
<section id="collection">
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
{% block title %}{{ super() }} {{ data['title'] }} {% endblock %}
|
||||
{% block crumbs %}{{ super() }}
|
||||
/ <a href="{{ data['collections_path'] }}">{% trans %}Collections{% endtrans %}</a>
|
||||
/ <a href="./{{ data['id'] }}">{{ data['title'] | truncate( 25 ) }}</a>
|
||||
/ <a href="./{{ data['id'] }}schema">{% trans %}Schema{% endtrans %}</a>
|
||||
/ <a href="{{ data['dataset_path'] }}">{{ data['title'] | truncate( 25 ) }}</a>
|
||||
/ <a href="{{ data['dataset_path'] }}/schema">{% trans %}Schema{% endtrans %}</a>
|
||||
{% endblock %}
|
||||
{% block body %}
|
||||
<section id="collection-schema">
|
||||
|
||||
@@ -48,5 +48,38 @@
|
||||
</table>
|
||||
</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>
|
||||
{% endblock %}
|
||||
|
||||
+6
-1
@@ -168,7 +168,7 @@ def yaml_load(fh: IO) -> dict:
|
||||
# # https://stackoverflow.com/a/55301129
|
||||
|
||||
env_matcher = re.compile(
|
||||
r'.*?\$\{(?P<varname>\w+)(:-(?P<default>[^}]+))?\}')
|
||||
r'.*?\$\{(?P<varname>\w+)(:-(?P<default>[^}]*))?\}')
|
||||
|
||||
def env_constructor(loader, node):
|
||||
result = ""
|
||||
@@ -597,6 +597,11 @@ class RequestedProcessExecutionMode(Enum):
|
||||
respond_async = 'respond-async'
|
||||
|
||||
|
||||
class RequestedResponse(Enum):
|
||||
raw = 'raw'
|
||||
document = 'document'
|
||||
|
||||
|
||||
class JobStatus(Enum):
|
||||
"""
|
||||
Enum for the job status options specified in the WPS 2.0 specification
|
||||
|
||||
@@ -6,11 +6,15 @@ elasticsearch-dsl
|
||||
fiona
|
||||
GDAL<=3.8.4
|
||||
geoalchemy2
|
||||
geopandas
|
||||
netCDF4
|
||||
numpy
|
||||
numpy==2.0.1
|
||||
opensearch-dsl
|
||||
opensearch-py
|
||||
oracledb
|
||||
pandas
|
||||
psycopg2
|
||||
pyarrow
|
||||
pygeofilter[backend-sqlalchemy]
|
||||
pygeoif
|
||||
pygeometa
|
||||
|
||||
+1
-2
@@ -14,6 +14,5 @@ PyYAML
|
||||
rasterio
|
||||
requests
|
||||
shapely
|
||||
SQLAlchemy<2.0.0
|
||||
SQLAlchemy
|
||||
tinydb
|
||||
unicodecsv
|
||||
|
||||
@@ -389,6 +389,9 @@ def test_api(config, api_, openapi):
|
||||
assert rsp_headers['Content-Language'] == 'en-US'
|
||||
assert code == HTTPStatus.BAD_REQUEST
|
||||
|
||||
response = json.loads(response)
|
||||
assert response['description'] == 'Invalid format requested'
|
||||
|
||||
assert api_.get_collections_url() == 'http://localhost:5000/collections'
|
||||
|
||||
|
||||
@@ -572,7 +575,7 @@ def test_conformance(config, api_):
|
||||
|
||||
assert isinstance(root, dict)
|
||||
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' \
|
||||
in root['conformsTo']
|
||||
|
||||
@@ -601,7 +604,7 @@ def test_describe_collections(config, api_):
|
||||
collections = json.loads(response)
|
||||
|
||||
assert len(collections) == 2
|
||||
assert len(collections['collections']) == 9
|
||||
assert len(collections['collections']) == 10
|
||||
assert len(collections['links']) == 3
|
||||
|
||||
rsp_headers, code, response = api_.describe_collections(req, 'foo')
|
||||
|
||||
@@ -62,6 +62,11 @@ def test_get_collection_queryables(config, api_):
|
||||
api_, req, 'notfound')
|
||||
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'})
|
||||
rsp_headers, code, response = get_collection_queryables(api_, req, 'obs')
|
||||
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 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
|
||||
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 '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_):
|
||||
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.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
|
||||
@@ -198,6 +198,12 @@ def test_execute_process(config, api_):
|
||||
'failedUri': 'https://example.com/failed',
|
||||
}
|
||||
}
|
||||
req_body_8 = {
|
||||
'inputs': {
|
||||
'name': 'Test document'
|
||||
},
|
||||
'response': 'document'
|
||||
}
|
||||
|
||||
cleanup_jobs = set()
|
||||
|
||||
@@ -346,6 +352,14 @@ def test_execute_process(config, api_):
|
||||
cleanup_jobs.add(tuple(['hello-world',
|
||||
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
|
||||
time.sleep(2) # Allow time for any outstanding async jobs
|
||||
for _, job_id in cleanup_jobs:
|
||||
@@ -428,4 +442,51 @@ def test_get_job_result(api_):
|
||||
)
|
||||
assert code == HTTPStatus.OK
|
||||
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,
|
||||
"limit": 10,
|
||||
"map": {
|
||||
"url": "https://maps.wikimedia.org/osm-intl/{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>"
|
||||
"url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||
"attribution": "© <a href=\"https://openstreetmap.org/copyright\">OpenStreetMap contributors</a>"
|
||||
}
|
||||
},
|
||||
"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:
|
||||
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))
|
||||
|
||||
@@ -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
|
||||
limit: 10
|
||||
map:
|
||||
url: https://maps.wikimedia.org/osm-intl/{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>
|
||||
url: https://tile.openstreetmap.org/{z}/{x}/{y}.png
|
||||
attribution: '© <a href="https://openstreetmap.org/copyright">OpenStreetMap contributors</a>'
|
||||
# manager:
|
||||
# name: TinyDB
|
||||
# connection: /tmp/pygeoapi-process-manager.db
|
||||
|
||||
@@ -46,8 +46,8 @@ server:
|
||||
limit: 10
|
||||
# templates: /path/to/templates
|
||||
map:
|
||||
url: https://maps.wikimedia.org/osm-intl/{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>'
|
||||
url: https://tile.openstreetmap.org/{z}/{x}/{y}.png
|
||||
attribution: '© <a href="https://openstreetmap.org/copyright">OpenStreetMap contributors</a>'
|
||||
manager:
|
||||
name: TinyDB
|
||||
connection: /tmp/pygeoapi-test-process-manager.db
|
||||
|
||||
@@ -44,8 +44,8 @@ server:
|
||||
limit: 10
|
||||
# templates: /path/to/templates
|
||||
map:
|
||||
url: https://maps.wikimedia.org/osm-intl/{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>'
|
||||
url: https://tile.openstreetmap.org/{z}/{x}/{y}.png
|
||||
attribution: '© <a href="https://openstreetmap.org/copyright">OpenStreetMap contributors</a>'
|
||||
manager:
|
||||
name: TinyDB
|
||||
connection: /tmp/pygeoapi-test-process-manager.db
|
||||
|
||||
@@ -31,7 +31,7 @@ server:
|
||||
bind:
|
||||
host: 0.0.0.0
|
||||
port: ${PYGEOAPI_PORT}
|
||||
url: http://localhost:5000/
|
||||
url: ${PYGEOAPI_URL:-http://localhost:5000/}
|
||||
mimetype: application/json; charset=UTF-8
|
||||
encoding: utf-8
|
||||
language: en-US
|
||||
@@ -41,8 +41,10 @@ server:
|
||||
limit: 10
|
||||
# templates: /path/to/templates
|
||||
map:
|
||||
url: https://maps.wikimedia.org/osm-intl/{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>'
|
||||
url: https://tile.openstreetmap.org/{z}/{x}/{y}.png
|
||||
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:
|
||||
level: DEBUG
|
||||
|
||||
@@ -44,8 +44,8 @@ server:
|
||||
limit: 10
|
||||
# templates: /path/to/templates
|
||||
map:
|
||||
url: https://maps.wikimedia.org/osm-intl/{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>'
|
||||
url: https://tile.openstreetmap.org/{z}/{x}/{y}.png
|
||||
attribution: '© <a href="https://openstreetmap.org/copyright">OpenStreetMap contributors</a>'
|
||||
manager:
|
||||
name: TinyDB
|
||||
connection: /tmp/pygeoapi-test-process-manager.db
|
||||
|
||||
@@ -41,8 +41,8 @@ server:
|
||||
limit: 10
|
||||
# templates: /path/to/templates
|
||||
map:
|
||||
url: https://maps.wikimedia.org/osm-intl/{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>'
|
||||
url: https://tile.openstreetmap.org/{z}/{x}/{y}.png
|
||||
attribution: '© <a href="https://openstreetmap.org/copyright">OpenStreetMap contributors</a>'
|
||||
|
||||
logging:
|
||||
level: DEBUG
|
||||
|
||||
@@ -44,8 +44,8 @@ server:
|
||||
limit: 10
|
||||
# templates: /path/to/templates
|
||||
map:
|
||||
url: https://maps.wikimedia.org/osm-intl/{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>'
|
||||
url: https://tile.openstreetmap.org/{z}/{x}/{y}.png
|
||||
attribution: '© <a href="https://openstreetmap.org/copyright">OpenStreetMap contributors</a>'
|
||||
manager:
|
||||
name: PostgreSQL
|
||||
connection:
|
||||
|
||||
@@ -44,8 +44,8 @@ server:
|
||||
limit: 10
|
||||
# templates: /path/to/templates
|
||||
map:
|
||||
url: https://maps.wikimedia.org/osm-intl/{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>'
|
||||
url: https://tile.openstreetmap.org/{z}/{x}/{y}.png
|
||||
attribution: '© <a href="https://openstreetmap.org/copyright">OpenStreetMap contributors</a>'
|
||||
manager:
|
||||
name: TinyDB
|
||||
connection: /tmp/pygeoapi-test-process-manager.db
|
||||
@@ -398,6 +398,44 @@ resources:
|
||||
name: 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:
|
||||
type: process
|
||||
processor:
|
||||
|
||||
@@ -54,8 +54,19 @@ def test_config_envvars():
|
||||
|
||||
assert isinstance(config, dict)
|
||||
assert config['server']['bind']['port'] == 5001
|
||||
assert config['server']['url'] == 'http://localhost:5000/'
|
||||
assert config['metadata']['identification']['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')
|
||||
|
||||
|
||||
+29
-1
@@ -30,9 +30,11 @@ from typing import Dict
|
||||
|
||||
import pytest
|
||||
|
||||
from pygeoapi.process.base import UnknownProcessError
|
||||
from pygeoapi.process.base import UnknownProcessError, JobNotFoundError
|
||||
from pygeoapi.process.manager.base import get_manager
|
||||
|
||||
from .util import get_test_file_path
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def config() -> Dict:
|
||||
@@ -41,6 +43,7 @@ def config() -> Dict:
|
||||
'manager': {
|
||||
'name': 'TinyDB',
|
||||
'output_dir': '/tmp',
|
||||
'connection': '/tmp/pygeoapi-process-manager-test.db'
|
||||
}
|
||||
},
|
||||
'resources': {
|
||||
@@ -71,3 +74,28 @@ def test_get_processor_raises_exception(config):
|
||||
manager = get_manager(config)
|
||||
with pytest.raises(expected_exception=UnknownProcessError):
|
||||
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,
|
||||
language,
|
||||
filterq,
|
||||
extra_params
|
||||
):
|
||||
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:
|
||||
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")])
|
||||
|
||||
|
||||
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()
|
||||
def database_connection_pool(config_db_conn):
|
||||
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)
|
||||
|
||||
|
||||
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):
|
||||
"""
|
||||
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
|
||||
error_response = json.loads(response)
|
||||
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_):
|
||||
@@ -642,7 +642,7 @@ def test_post_collection_items_postgresql_cql_bad_cql(pg_api_, bad_cql):
|
||||
assert code == HTTPStatus.BAD_REQUEST
|
||||
error_response = json.loads(response)
|
||||
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_):
|
||||
|
||||
+1
-1
@@ -172,7 +172,7 @@ def test_path_basename():
|
||||
def test_filter_dict_by_key_value(config):
|
||||
collections = util.filter_dict_by_key_value(config['resources'],
|
||||
'type', 'collection')
|
||||
assert len(collections) == 9
|
||||
assert len(collections) == 10
|
||||
|
||||
notfound = util.filter_dict_by_key_value(config['resources'],
|
||||
'type', 'foo')
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
from numpy import float64, int64
|
||||
|
||||
import pytest
|
||||
import xarray as xr
|
||||
|
||||
from pygeoapi.provider.xarray_ import XarrayProvider
|
||||
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):
|
||||
p = XarrayProvider(config)
|
||||
|
||||
@@ -85,3 +100,14 @@ def test_numpy_json_serial():
|
||||
|
||||
d = float64(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