diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
index c94189a7c..c8b2a6b48 100644
--- a/.github/FUNDING.yml
+++ b/.github/FUNDING.yml
@@ -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']
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
index a59b51153..b09b56899 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.md
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -7,6 +7,12 @@ assignees: ''
---
+
+
**Description**
A clear and concise description of what the bug is.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
index 11fc491ef..31138e7df 100644
--- a/.github/ISSUE_TEMPLATE/feature_request.md
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -7,6 +7,12 @@ assignees: ''
---
+
+
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index 023067888..2d61c7ebb 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -1,6 +1,15 @@
# Overview
-# Related issue / discussion
+# Related Issue / discussion
+
+
# Additional information
diff --git a/.github/workflows/containers.yml b/.github/workflows/containers.yml
index 67544fed2..f04834da5 100644
--- a/.github/workflows/containers.yml
+++ b/.github/workflows/containers.yml
@@ -12,7 +12,7 @@ on:
branches: [master]
env:
- DOCKER_REPOSITORY: geopython/pygeoapi
+ DOCKER_REPOSITORY: ${{ secrets.DOCKER_REPOSITORY || 'geopython/pygeoapi' }}
# DOCKER_TEST_IMAGE: geopython/pygeoapi:test
jobs:
@@ -25,7 +25,7 @@ jobs:
contents: read
steps:
- name: Check out the repo
- uses: actions/checkout@v3
+ uses: actions/checkout@master
- name: Set up QEMU
uses: docker/setup-qemu-action@v2.1.0
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
new file mode 100644
index 000000000..9de9ab025
--- /dev/null
+++ b/.github/workflows/docs.yml
@@ -0,0 +1,36 @@
+name: Build documentation ⚙️
+
+on:
+ push:
+ branches:
+ - master
+ paths:
+ - 'docs/**'
+ pull_request:
+ branches:
+ - master
+ paths:
+ - 'docs/**'
+ release:
+ types:
+ - released
+
+jobs:
+ main:
+ runs-on: ubuntu-22.04
+ strategy:
+ matrix:
+ include:
+ - python-version: '3.10'
+ steps:
+ - uses: actions/checkout@master
+ - uses: actions/setup-python@v5
+ name: Setup Python ${{ matrix.python-version }}
+ with:
+ python-version: ${{ matrix.python-version }}
+ - name: Install requirements 📦
+ run: |
+ pip3 install -r requirements.txt
+ pip3 install -r docs/requirements.txt
+ - name: build docs 🏗️
+ run: cd docs && make html
diff --git a/.github/workflows/flake8.yml b/.github/workflows/flake8.yml
new file mode 100644
index 000000000..b57ba36c2
--- /dev/null
+++ b/.github/workflows/flake8.yml
@@ -0,0 +1,24 @@
+name: flake8
+
+on:
+ [ push, pull_request ]
+
+jobs:
+ flake8_py3:
+ runs-on: ubuntu-22.04
+ steps:
+ - uses: actions/checkout@master
+ - uses: actions/setup-python@v5
+ name: setup Python
+ with:
+ python-version: '3.10'
+ - name: Checkout pygeoapi
+ uses: actions/checkout@master
+ - name: Install flake8
+ run: pip3 install flake8
+ - name: Run flake8
+ uses: suo/flake8-github-action@releases/v1
+ with:
+ checkName: 'flake8_py3'
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 6b154baed..069914afb 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -4,51 +4,41 @@ on:
push:
paths-ignore:
- '**.md'
+ - 'docs/**'
pull_request:
branches:
- master
paths-ignore:
- '!**.md'
+ - 'docs/**'
release:
types:
- released
jobs:
- flake8_py3:
- runs-on: ubuntu-22.04
- steps:
- - name: Setup Python
- uses: actions/setup-python@v1
- with:
- python-version: 3.8
- architecture: x64
- - name: Checkout pygeoapi
- uses: actions/checkout@master
- - name: Install flake8
- run: pip install flake8
- - name: Run flake8
- uses: suo/flake8-github-action@releases/v1
- with:
- checkName: 'flake8_py3' # NOTE: this needs to be the same as the job name
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-
main:
- needs: [flake8_py3]
runs-on: ubuntu-22.04
strategy:
matrix:
include:
- - python-version: 3.8
+ - python-version: '3.10'
env:
PYGEOAPI_CONFIG: "$(pwd)/pygeoapi-config.yml"
steps:
+ - name: Clear up GitHub runner diskspace
+ run: |
+ echo "Space before"
+ df -h /
+ sudo rm -rf /usr/local/lib/android
+ sudo rm -rf /usr/share/dotnet
+ echo "Space after"
+ df -h /
- name: Chown user
run: |
sudo chown -R $USER:$USER $GITHUB_WORKSPACE
- - uses: actions/checkout@v2
- - uses: actions/setup-python@v2
+ - uses: actions/checkout@master
+ - uses: actions/setup-python@v5
name: Setup Python ${{ matrix.python-version }}
with:
python-version: ${{ matrix.python-version }}
@@ -66,28 +56,37 @@ jobs:
- name: Install and run Elasticsearch 📦
uses: getong/elasticsearch-action@v1.2
with:
- elasticsearch version: '8.3.1'
+ elasticsearch version: '8.17.0'
host port: 9200
container port: 9200
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.18.0
+ security-disabled: true
+ port: 9209
- name: Install and run MongoDB
- uses: supercharge/mongodb-github-action@1.5.0
+ uses: supercharge/mongodb-github-action@1.12.0
with:
- mongodb-version: 4.4
+ mongodb-version: '8.0.4'
- name: Install and run SensorThingsAPI
- uses: cgs-earth/sensorthings-action@v0.0.2
+ uses: cgs-earth/sensorthings-action@v0.1.0
- name: Install sqlite and gpkg dependencies
- uses: awalsh128/cache-apt-pkgs-action@latest
+ uses: awalsh128/cache-apt-pkgs-action@v1.4.3
with:
packages: libsqlite3-mod-spatialite
version: 4.3.0a-6build1
+ - name: Use ubuntuGIS unstable ppa
+ run: sudo add-apt-repository ppa:ubuntugis/ubuntugis-unstable && sudo apt update
+ shell: bash
- name: Install GDAL with Python bindings
- uses: awalsh128/cache-apt-pkgs-action@latest
+ uses: awalsh128/cache-apt-pkgs-action@v1.4.3
with:
packages: gdal-bin libgdal-dev
- version: 3.0.4
+ version: 3.8.4
- name: Install and run Oracle
run: |
docker run -d --name oracledb -e ORACLE_PWD=oracle -v ${{ github.workspace }}/tests/data/oracle/init-db:/opt/oracle/scripts/startup -p 1521:1521 container-registry.oracle.com/database/express:21.3.0-xe
@@ -98,37 +97,38 @@ jobs:
pip3 install -r requirements-starlette.txt
pip3 install -r requirements-dev.txt
pip3 install -r requirements-provider.txt
+ pip3 install -r requirements-manager.txt
pip3 install -r requirements-django.txt
- pip3 install -r docs/requirements.txt
python3 setup.py install
- pip3 install --upgrade numpy elasticsearch
- pip3 install --upgrade numpy "sqlalchemy<2"
- pip3 install --upgrade flake8
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_es_data.py tests/cite/ogcapi-features/canada-hydat-daily-mean-02HC003.geojson IDENTIFIER
+ 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
+ psql postgresql://postgres:${{ secrets.DatabasePassword || 'postgres' }}@localhost:5432/test -f tests/data/dummy_types_data.sql
+ psql postgresql://postgres:${{ secrets.DatabasePassword || 'postgres' }}@localhost:5432/test -f tests/data/postgres_manager_full_structure.backup.sql
docker ps
python3 tests/load_oracle_data.py
- name: run unit tests ⚙️
env:
POSTGRESQL_PASSWORD: ${{ secrets.DatabasePassword || 'postgres' }}
run: |
- pytest tests/test_api.py
+ pytest tests/api
pytest tests/test_api_ogr_provider.py
pytest tests/test_config.py
pytest tests/test_csv__formatter.py
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
+ pytest tests/test_linked_data.py
pytest tests/test_mongo_provider.py
pytest tests/test_ogr_csv_provider.py
pytest tests/test_ogr_esrijson_provider.py
@@ -136,60 +136,23 @@ jobs:
pytest tests/test_ogr_shapefile_provider.py
pytest tests/test_ogr_sqlite_provider.py
pytest tests/test_ogr_wfs_provider.py
+ pytest tests/test_postgresql_manager.py
+ # pytest tests/test_ogr_wfs_provider_live.py # NOTE: these are skipped in the file but listed here for completeness
pytest tests/test_openapi.py
pytest tests/test_oracle_provider.py
+ pytest tests/test_parquet_provider.py
pytest tests/test_postgresql_provider.py
pytest tests/test_rasterio_provider.py
+ pytest tests/test_sensorthings_edr_provider.py
pytest tests/test_sensorthings_provider.py
pytest tests/test_socrata_provider.py
+ # pytest tests/test_socrata_provider_live.py.py # NOTE: these are skipped in the file but listed here for completeness
pytest tests/test_sqlite_geopackage_provider.py
pytest tests/test_tinydb_catalogue_provider.py
pytest tests/test_tinydb_manager_for_parallel_requests.py
pytest tests/test_util.py
pytest tests/test_xarray_netcdf_provider.py
pytest tests/test_xarray_zarr_provider.py
- - name: build docs 🏗️
- run: cd docs && make html
- - name: failed tests 🚩
- if: ${{ failure() }}
- run: |
- pip3 list -v
-
- admin:
- needs: [flake8_py3]
- runs-on: ubuntu-20.04
- strategy:
- matrix:
- include:
- - python-version: 3.8
- env:
- PYGEOAPI_CONFIG: "tests/pygeoapi-test-config-admin.yml"
- PYGEOAPI_OPENAPI: "tests/pygeoapi-test-openapi-admin.yml"
- steps:
- - uses: actions/checkout@v2
- - uses: actions/setup-python@v2
- name: Setup Python ${{ matrix.python-version }}
- with:
- python-version: ${{ matrix.python-version }}
- - uses: awalsh128/cache-apt-pkgs-action@latest
- with:
- packages: gunicorn python3-gevent
- version: 1.0
- - name: Install requirements 📦
- run: |
- pip3 install -r requirements.txt
- pip3 install -r requirements-dev.txt
- pip3 install -r requirements-admin.txt
- python3 setup.py install
- - name: Run pygeoapi with admin API ⚙️
- run: |
- pygeoapi openapi generate ${PYGEOAPI_CONFIG} --output-file ${PYGEOAPI_OPENAPI}
- gunicorn --bind 0.0.0.0:5000 \
- --reload \
- --reload-extra-file ${PYGEOAPI_CONFIG} \
- pygeoapi.flask_app:APP &
- - name: run integration tests ⚙️
- run: |
pytest tests/test_admin_api.py
- name: failed tests 🚩
if: ${{ failure() }}
diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml
new file mode 100644
index 000000000..61629fb5d
--- /dev/null
+++ b/.github/workflows/stale.yml
@@ -0,0 +1,48 @@
+name: 'Close stale Issues and Pull Requests'
+
+env:
+ STALE_AFTER_INACTIVE_DAYS: 90
+ CLOSE_AFTER_INACTIVE_DAYS: 7
+
+on:
+ schedule:
+ - cron: '1 3 * * 0' # runs every Sunday at 03h01 UTC
+ # - cron: '0 * * * *' # runs every hour, for debugging
+jobs:
+ stale:
+ permissions:
+ issues: write
+ pull-requests: write
+ if: github.repository == 'geopython/pygeoapi'
+ runs-on: ubuntu-latest
+ steps:
+ - uses: 'actions/stale@v9'
+ with:
+ # debug-only: true
+ operations-per-run: 1000
+ enable-statistics: true
+ stale-issue-label: stale
+ stale-pr-label: stale
+ exempt-issue-labels: blocker
+ exempt-pr-labels: blocker
+ days-before-stale: ${{ env.STALE_AFTER_INACTIVE_DAYS }}
+ days-before-close: ${{ env.CLOSE_AFTER_INACTIVE_DAYS }}
+ remove-stale-when-updated: true
+ stale-issue-message: >
+ This Issue has been inactive for ${{env.STALE_AFTER_INACTIVE_DAYS }}
+ days. As per [RFC4](https://pygeoapi.io/development/rfc/4), in order
+ to manage maintenance burden, it will be automatically closed
+ in ${{ env.CLOSE_AFTER_INACTIVE_DAYS }} days.
+ stale-pr-message: >
+ This Pull Request has been inactive for ${{env.STALE_AFTER_INACTIVE_DAYS }}
+ days. As per [RFC4](https://pygeoapi.io/development/rfc/4), in order
+ to manage maintenance burden, it will be automatically closed
+ in ${{ env.CLOSE_AFTER_INACTIVE_DAYS }} days.
+ close-issue-message: >
+ As per [RFC4](https://pygeoapi.io/development/rfc/4), this Issue has
+ been closed due to there being no activity for more
+ than ${{ env.STALE_AFTER_INACTIVE_DAYS }} days.
+ close-pr-message: >
+ As per [RFC4](https://pygeoapi.io/development/rfc/4), this Pull Request
+ has been closed due to there being no activity for more
+ than ${{ env.STALE_AFTER_INACTIVE_DAYS }} days.
diff --git a/.github/workflows/vulnerabilities.yml b/.github/workflows/vulnerabilities.yml
index 3252252ed..d8ac5199c 100644
--- a/.github/workflows/vulnerabilities.yml
+++ b/.github/workflows/vulnerabilities.yml
@@ -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
diff --git a/.gitignore b/.gitignore
index dfb7c6861..2829d7060 100644
--- a/.gitignore
+++ b/.gitignore
@@ -103,6 +103,10 @@ ENV/
# pygeoapi artifacts
*.openapi.yml
+# development setup examples
+example-config.yml
+example-openapi.yml
+
# misc
*.swp
.pytest_cache
diff --git a/.readthedocs.yaml b/.readthedocs.yaml
index 3eab1cefe..350ac42d8 100644
--- a/.readthedocs.yaml
+++ b/.readthedocs.yaml
@@ -16,3 +16,6 @@ python:
- requirements: docs/requirements.txt
# - requirements: requirements.txt
# - requirements: requirements-dev.txt
+
+formats:
+ - pdf
diff --git a/Dockerfile b/Dockerfile
index 1d8d6a296..04f1a20db 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -8,7 +8,7 @@
# Copyright (c) 2020 Tom Kralidis
# Copyright (c) 2019 Just van den Broecke
# Copyright (c) 2020 Francesco Bartoli
-# Copyright (c) 2021 Angelos Tzotsos
+# Copyright (c) 2024 Angelos Tzotsos
# Copyright (c) 2023 Bernhard Mallinger
#
# Permission is hereby granted, free of charge, to any person
@@ -34,7 +34,7 @@
#
# =================================================================
-FROM ubuntu:jammy-20231211.1
+FROM ubuntu:jammy-20240911.1
LABEL maintainer="Just van den Broecke
+
+
+
-
-
+
+
+Dataset level templates
+-----------------------
-Featured templates
-------------------
+The ``templates`` configuration directive is applied to the entire server by default. It can also be used for a dataset specific look and feel. As example use case is defining a template for a specific dataset to be able to add custom UI/UX functionality (e.g. search/filter widget).
-The following themes provide useful examples of pygeoapi templates implemented
-by downstream applications.
+.. note::
-.. csv-table::
- :header: "Plugin(s)", "Organization/Project","Description"
- :align: left
+ Dataset level templates apply to ``/collections/{collectionId}`` and below.
- `pygeoapi-skin-dashboard`_,GeoCat bv,skin for pygeoapi based on a typical dashboard interface
+Example
+^^^^^^^
+
+The below is an example dataset specific template using pygeoapi's default theme:
+
+
+.. code-block:: html
+
+ {% extends "_base.html" %}
+
+ {% block body %}
+
+ `` query parameter, where ```` should be replaced by a well-known language code.
- This can be an ISO 639-1 code (e.g. `de` for German), optionally accompanied by an ISO 3166-1 alpha-2 country code (e.g. `de-CH` for Swiss-German).
- Please refer to this `W3C article `_ for more information or
- this `list of language codes `_ for more examples.
+ This can be an `ISO 639-1 code `_ (e.g. `de` for German), optionally accompanied by an `ISO 3166-1 alpha-2 country code `_ (e.g. `de-CH` for Swiss-German).
+
+ Please refer to this `W3C article `_ for more information.
Another option is to send a complex definition with quality weights (e.g. `de-CH, de;q=0.9, en;q=0.8, fr;q=0.7, \*;q=0.5`).
pygeoapi will then figure out the best match for the requested language.
@@ -176,7 +176,7 @@ Translator guide
Hardcoded strings in pygeoapi templates are translated using the Babel translation system.
By default, pygeoapi stores translation files in the `locale` directory at the root of the
-source code repository. This value can be overriden in the pygeoapi configuration with
+source code repository. This value can be overridden in the pygeoapi configuration with
the `server.locale_dir` directive.
Translators can follow these steps to prepare their environment for translations.
diff --git a/docs/source/openapi.rst b/docs/source/openapi.rst
index 1d3593316..71383c61c 100644
--- a/docs/source/openapi.rst
+++ b/docs/source/openapi.rst
@@ -13,7 +13,7 @@ The official OpenAPI specification can be found `on GitHub {self.name}'
+
+
+The example above handles a dictionary of the JSON payload passed from the client, calculates the square root of a float or integer, and returns the result in an output JSON payload. The plugin is responsible for defining the expected inputs and outputs in ``PROCESS_METADATA`` and to return the output in any format along with the corresponding media type.
+
+.. note::
+
+ Additional processing plugins can also be found in ``pygeoapi/process``.
Example: custom pygeoapi formatter
----------------------------------
@@ -295,47 +366,20 @@ The below template provides a minimal example (let's call the file ``mycooljsonf
def write(self, options={}, data=None):
"""custom writer"""
- out_data {'rows': []}
+ out_data = {'rows': []}
for feature in data['features']:
- out_data.append(feature['properties'])
+ out_data['rows'].append(feature['properties'])
return out_data
-Processing plugins
-------------------
-
-Processing plugins are following the OGC API - Processes development. Given that the specification is
-under development, the implementation in ``pygeoapi/process/hello_world.py`` provides a suitable example
-for the time being.
-
-
Featured plugins
----------------
-The following plugins provide useful examples of pygeoapi plugins implemented
-by downstream applications.
-
-.. csv-table::
- :header: "Plugin(s)", "Organization/Project","Description"
- :align: left
-
- `msc-pygeoapi`_,Meteorological Service of Canada,processes for weather/climate/water data workflows
- `pygeoapi-kubernetes-papermill`_,Euro Data Cube,processes for executing Jupyter notebooks via Kubernetes
- `local-outlier-factor-plugin`_,Manaaki Whenua – Landcare Research,processes for local outlier detection
- `ogc-edc`_,Euro Data Cube,coverage provider atop the EDC API
- `nldi_xstool`_,United States Geological Survey,Water data processing
- `pygeometa-plugin`_,pygeometa project,pygeometa as a service
- `cgs-plugins`_,Center for Geospatial Solutions,feature and processes plugins
+Community based plugins can be found on the `pygeoapi Community Plugins and Themes wiki page`_.
-.. _`cgs-plugins`: https://github.com/cgs-earth/pygeoapi-plugins
+.. _`pygeoapi Community Plugins and Themes wiki page`: https://github.com/geopython/pygeoapi/wiki/CommunityPluginsThemes
.. _`Cookiecutter`: https://github.com/audreyfeldroy/cookiecutter-pypackage
-.. _`msc-pygeoapi`: https://github.com/ECCC-MSC/msc-pygeoapi
-.. _`pygeoapi-kubernetes-papermill`: https://github.com/eurodatacube/pygeoapi-kubernetes-papermill
-.. _`local-outlier-factor-plugin`: https://github.com/manaakiwhenua/local-outlier-factor-plugin
-.. _`ogc-edc`: https://github.com/eurodatacube/ogc-edc/tree/oapi/edc_ogc/pygeoapi
-.. _`nldi_xstool`: https://code.usgs.gov/wma/nhgf/toolsteam/nldi-xstool
.. _`pygeoapi-plugin-cookiecutter`: https://code.usgs.gov/wma/nhgf/pygeoapi-plugin-cookiecutter
-.. _`pygeometa-plugin`: https://geopython.github.io/pygeometa/pygeoapi-plugin
diff --git a/docs/source/running-with-docker.rst b/docs/source/running-with-docker.rst
index afd9533f3..a1094fef4 100644
--- a/docs/source/running-with-docker.rst
+++ b/docs/source/running-with-docker.rst
@@ -29,6 +29,12 @@ To run with the default built-in configuration and data:
...then browse to http://localhost:5000
+You can also run pygeoapi with run-with-hot-reload of the configuration enabled
+
+.. code-block:: bash
+
+ docker run -p 5000:80 -it geopython/pygeoapi run-with-hot-reload
+
You can also run all unit tests to verify:
.. code-block:: bash
@@ -71,6 +77,32 @@ Or you can create a ``Dockerfile`` extending the base image and **copy** in your
A corresponding example can be found in https://github.com/geopython/demo.pygeoapi.io/tree/master/services/pygeoapi_master
+Environment Variables for Configuration
+---------------------------------------
+
+The base Docker image supports two additional environment variables for configuring the `pygeoapi` server behavior:
+
+1. **`PYGEOAPI_SERVER_URL`**:
+ This variable sets the `pygeoapi` server URL in the configuration. It is useful for dynamically configuring the server URL during container deployment. For example:
+
+ .. code-block:: bash
+
+ docker run -p 2018:80 -e PYGEOAPI_SERVER_URL='http://localhost:2018' -it geopython/pygeoapi
+
+ This ensures the service URLs in the configuration file are automatically updated to reflect the specified URL.
+
+2. **`PYGEOAPI_SERVER_ADMIN`**:
+ This boolean environment variable enables or disables the `pygeoapi` Admin API. By default, the Admin API is disabled. To enable it:
+
+ .. code-block:: bash
+
+ docker run -p 5000:80 -e PYGEOAPI_SERVER_ADMIN=true -it geopython/pygeoapi
+ # run with hot reload
+ docker run -p 5000:80 -e PYGEOAPI_SERVER_ADMIN=true -it geopython/pygeoapi run-with-hot-reload
+
+ To learn more about the Admin API see :ref:`admin-api`.
+
+
Deploying on a sub-path
-----------------------
diff --git a/docs/source/running.rst b/docs/source/running.rst
index 8ea173f01..c92bbee80 100644
--- a/docs/source/running.rst
+++ b/docs/source/running.rst
@@ -100,7 +100,7 @@ To integrate pygeoapi as part of another Starlette application:
from starlette.applications import Starlette
from starlette.responses import PlainTextResponse
from starlette.routing import Route
- from pygeoapi.starlette_app import app as pygeoapi_app
+ from pygeoapi.starlette_app import APP as pygeoapi_app
async def homepage(request):
@@ -158,8 +158,12 @@ main Django application urls:
]
-This integration can be seen in the provided example Django project. Refer to `examples/django/sample_project/README.md`
-for the integration of pygeoapi with an already exising Django application.
+This integration can be seen in the provided example Django project. Refer to the `Django example in the pygeoapi-examples repository`_
+for the integration of pygeoapi with an already existing Django application.
+
+
+.. note::
+ To enable HTTP POST/PUT/PATCH/DELETE functionality, `django.middleware.csrf.CsrfViewMiddleware` must not be set. Note that this enables create/replace/update/delete functionality against resources in your application.
Hot-reloading
^^^^^^^^^^^^^
@@ -172,7 +176,7 @@ By hot-reloading we mean to be able to directly see changes reflected in the app
This is useful for development, as the changes made by developers are easily and rapidly reflected and they can take advantage
of the hot-reloading capabilities that offer each of the web servers available.
-For enabling hot-reloading, install the pygeoapi package using pip (instead of the setup.py script) with the following command:
+To enable hot-reloading, install the pygeoapi package using pip (instead of `setup.py`) with the following command:
.. code-block:: bash
@@ -181,8 +185,7 @@ For enabling hot-reloading, install the pygeoapi package using pip (instead of t
.. note::
This command must be run from the root directory of pygeoapi.
-After the local package is built, you can use the ``pygeoapi serve``
-again and the changes on the codebase will be directly reflected on the running instance.
+After the local package is built, run ``pygeoapi serve`` again and the changes to the codebase will be reflected live on the running instance.
Running in production
@@ -294,3 +297,4 @@ and modify accordingly.
.. _`Uvicorn`: https://www.uvicorn.org
.. _`mod_wsgi`: https://modwsgi.readthedocs.io/en/master
.. _`Django`: https://www.djangoproject.com
+.. _`Django example in the pygeoapi-examples repository`: https://github.com/geopython/pygeoapi-examples/blob/main/django/sample_project/README.md
diff --git a/docs/source/security.rst b/docs/source/security.rst
new file mode 100644
index 000000000..df81f8a18
--- /dev/null
+++ b/docs/source/security.rst
@@ -0,0 +1,18 @@
+.. _security:
+
+Security
+========
+
+There exist use cases which require authentication and authorization against an API at various granularities
+(collections, processes, etc.), restricting access to a given user, group or role. Implementing security
+can be as simple as HTTP basic authentication, or as complex as fine-grained access control against a specific
+collection item.
+
+By design, pygeoapi does not have built-in support for access control. It is up to the user to secure pygeoapi
+as required.
+
+The following projects provide security frameworks atop pygeoapi:
+
+* `fastgeoapi `_
+* `pygeoapi-auth-deployment `_
+* `pygeoapi-auth `_ (Python package for use along with pygeoapi-auth-deployment)
diff --git a/docs/source/tour.rst b/docs/source/tour.rst
index 06a6e363d..4fd4e51da 100644
--- a/docs/source/tour.rst
+++ b/docs/source/tour.rst
@@ -117,19 +117,12 @@ Delete an item from a collection:
Raster data
-----------
-Collection coverage domainset
-^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+Collection coverage schema
+^^^^^^^^^^^^^^^^^^^^^^^^^^
-This page provides information on a collection coverage spatial properties and axis information.
+This page provides information on a collection coverage information.
-http://localhost:5000/collections/gdps-temperature/coverage/domainset
-
-Collection coverage rangetype
-^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
-
-This page provides information on a collection coverage rangetype (bands) information.
-
-http://localhost:5000/collections/gdps-temperature/coverage/rangetype
+http://localhost:5000/collections/gdps-temperature/schema
Collection coverage data
^^^^^^^^^^^^^^^^^^^^^^^^
diff --git a/docs/source/transactions.rst b/docs/source/transactions.rst
index ac846d0f2..4c6327174 100644
--- a/docs/source/transactions.rst
+++ b/docs/source/transactions.rst
@@ -14,6 +14,6 @@ Access control
^^^^^^^^^^^^^^
It should be made clear that authentication and authorization is beyond the responsibility of pygeoapi. This means that
-if a pygeoapi user enables transactions, they must provide access control explicity via another service.
+if a pygeoapi user enables transactions, they must provide access control explicitly via another service.
.. _`OGC API - Features - Part 4: Create, Replace, Update and Delete`: https://docs.ogc.org/DRAFTS/20-002.html
diff --git a/locale/README.md b/locale/README.md
index ee3c40741..49f8bdc19 100644
--- a/locale/README.md
+++ b/locale/README.md
@@ -1,4 +1,4 @@
-# Howto set up language files
+# How to set up language files
Inspired by https://phrase.com/blog/posts/i18n-advantages-babel-python/#Integration_with_Jinja2_templates
@@ -22,6 +22,21 @@ Then extract the base messages from templates:
This file is not persisted on github.
+If the above command gives this ouput:
+```
+AttributeError: module 'jinja2.ext' has no attribute 'autoescape'
+```
+
+Just comment out the extensions line in the `babel-mapping.ini` file:
+```
+[python: **.py]
+[jinja2: **/templates/**.html]
+;extensions=jinja2.ext.i18n,jinja2.ext.autoescape,jinja2.ext.with_
+```
+
+Then rerun the command for extracting base messages.
+
+
Now setup a new language (french) using the init command:
> pybabel init -d locale -l fr -i locale/messages.pot
diff --git a/locale/ar/LC_MESSAGES/messages.po b/locale/ar/LC_MESSAGES/messages.po
new file mode 100644
index 000000000..87e035ba5
--- /dev/null
+++ b/locale/ar/LC_MESSAGES/messages.po
@@ -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 \n"
+"Language: ar\n"
+"Language-Team: ar \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 "مجموعة مصفوفة البلاطات"
diff --git a/locale/bs/LC_MESSAGES/messages.po b/locale/bs/LC_MESSAGES/messages.po
new file mode 100644
index 000000000..15d33b9f8
--- /dev/null
+++ b/locale/bs/LC_MESSAGES/messages.po
@@ -0,0 +1,686 @@
+# Bosnian translations for PROJECT.
+# Copyright (C) 2024 ORGANIZATION
+# This file is distributed under the same license as the PROJECT project.
+# FIRST AUTHOR , 2024.
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: PROJECT VERSION\n"
+"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
+"POT-Creation-Date: 2024-02-27 14:39+0100\n"
+"PO-Revision-Date: 2024-02-27 14:40+0100\n"
+"Last-Translator: Bojan Kuzmanović \n"
+"Language: bs\n"
+"Language-Team: bs \n"
+"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && "
+"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=utf-8\n"
+"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"
+
+#: pygeoapi/templates/_base.html:67 pygeoapi/templates/landing_page.html:2
+msgid "Home"
+msgstr "Početna"
+
+#: pygeoapi/templates/_base.html:75 pygeoapi/templates/_base.html:83
+msgid "json"
+msgstr "json"
+
+#: pygeoapi/templates/_base.html:78 pygeoapi/templates/_base.html:86
+msgid "jsonld"
+msgstr "jsonld"
+
+#: pygeoapi/templates/_base.html:105
+msgid "Powered by "
+msgstr "Pokreće ga"
+
+#: pygeoapi/templates/conformance.html:2 pygeoapi/templates/conformance.html:4
+#: pygeoapi/templates/conformance.html:8
+#: pygeoapi/templates/landing_page.html:86
+msgid "Conformance"
+msgstr "Usklađenost"
+
+#: pygeoapi/templates/exception.html:2 pygeoapi/templates/exception.html:5
+msgid "Exception"
+msgstr "Izuzetak"
+
+#: pygeoapi/templates/landing_page.html:25
+msgid "Terms of service"
+msgstr "Uslovi korišćenja"
+
+#: pygeoapi/templates/collections/collection.html:38
+#: pygeoapi/templates/landing_page.html:35
+msgid "License"
+msgstr "Licensa"
+
+#: pygeoapi/templates/collections/collection.html:6
+#: pygeoapi/templates/collections/coverage/domainset.html:4
+#: pygeoapi/templates/collections/coverage/rangetype.html:4
+#: 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/tiles/index.html:4
+#: pygeoapi/templates/collections/tiles/metadata.html:4
+#: pygeoapi/templates/landing_page.html:48
+#: pygeoapi/templates/stac/collection_base.html:19
+msgid "Collections"
+msgstr "Kolekcije"
+
+#: pygeoapi/templates/landing_page.html:50
+msgid "View the collections in this service"
+msgstr "Pogledajte kolekcije u ovoj usluzi"
+
+#: pygeoapi/templates/landing_page.html:56
+msgid "SpatioTemporal Assets"
+msgstr "Prostorno-vremenski resursi"
+
+#: pygeoapi/templates/landing_page.html:58
+msgid "View the SpatioTemporal Assets in this service"
+msgstr "Pretražite prostorno-vremenske resurse u ovoj usluzi"
+
+#: pygeoapi/templates/landing_page.html:64
+#: pygeoapi/templates/processes/index.html:2
+#: pygeoapi/templates/processes/index.html:4
+#: pygeoapi/templates/processes/process.html:4
+msgid "Processes"
+msgstr "Procesi"
+
+#: pygeoapi/templates/landing_page.html:66
+msgid "View the processes in this service"
+msgstr "Pogledajte procese u ovoj usluzi"
+
+#: 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:70
+#: pygeoapi/templates/processes/process.html:76
+msgid "Jobs"
+msgstr "Zadaci"
+
+#: pygeoapi/templates/landing_page.html:72
+#: pygeoapi/templates/processes/process.html:77
+msgid "Browse jobs"
+msgstr "Pregledajte zadatke"
+
+#: pygeoapi/templates/landing_page.html:77
+msgid "API Definition"
+msgstr "API definicija"
+
+#: pygeoapi/templates/landing_page.html:79
+msgid "Documentation"
+msgstr "Dokumentacija"
+
+#: pygeoapi/templates/landing_page.html:79
+msgid "Swagger UI"
+msgstr "Swagger UI"
+
+#: pygeoapi/templates/landing_page.html:79
+msgid "ReDoc"
+msgstr "ReDoc"
+
+#: pygeoapi/templates/landing_page.html:82
+msgid "OpenAPI Document"
+msgstr "OpenAPI dokument"
+
+#: pygeoapi/templates/landing_page.html:88
+msgid "View the conformance classes of this service"
+msgstr "Poglejte klase usklađenosti ove usluge"
+
+#: pygeoapi/templates/landing_page.html:92
+msgid "Tile Matrix Sets"
+msgstr "Matrica tile-ova"
+
+#: pygeoapi/templates/landing_page.html:94
+msgid "View the Tile Matrix Sets available on this service"
+msgstr "Pogledajte matrice tile-ova dostupne u ovoj usluzi"
+
+#: pygeoapi/templates/landing_page.html:101
+msgid "Provider"
+msgstr "Provajder"
+
+#: pygeoapi/templates/landing_page.html:110
+msgid "Contact point"
+msgstr "Kontakt tačka"
+
+#: pygeoapi/templates/landing_page.html:113
+msgid "Address"
+msgstr "Adresa"
+
+#: pygeoapi/templates/landing_page.html:122
+msgid "Email"
+msgstr "Email"
+
+#: pygeoapi/templates/landing_page.html:125
+msgid "Telephone"
+msgstr "Telefon"
+
+#: pygeoapi/templates/landing_page.html:129
+msgid "Fax"
+msgstr "Fax"
+
+#: pygeoapi/templates/landing_page.html:133
+msgid "Contact URL"
+msgstr "Konktakt URL"
+
+#: pygeoapi/templates/landing_page.html:137
+msgid "Hours"
+msgstr "Sati"
+
+#: pygeoapi/templates/landing_page.html:141
+msgid "Contact instructions"
+msgstr "Konktakt instrukcije"
+
+#: pygeoapi/templates/collections/collection.html:51
+msgid "Browse"
+msgstr "Pretražite"
+
+#: pygeoapi/templates/collections/collection.html:55
+msgid "Browse Items"
+msgstr "Pretražite stavke"
+
+#: pygeoapi/templates/collections/collection.html:56
+msgid "Browse through the items of"
+msgstr "Pretražite kroz stavke od"
+
+#: pygeoapi/templates/collections/collection.html:59
+#: pygeoapi/templates/collections/queryables.html:6
+#: pygeoapi/templates/collections/queryables.html:17
+msgid "Queryables"
+msgstr "Upitni"
+
+#: pygeoapi/templates/collections/collection.html:63
+msgid "Display Queryables"
+msgstr "Prikaži upitne"
+
+#: pygeoapi/templates/collections/collection.html:64
+msgid "Display Queryables of"
+msgstr "Prikaži upitne od"
+
+#: pygeoapi/templates/collections/collection.html:69
+#: pygeoapi/templates/collections/tiles/index.html:6
+#: pygeoapi/templates/collections/tiles/metadata.html:6
+msgid "Tiles"
+msgstr "Tile-ovi"
+
+#: pygeoapi/templates/collections/collection.html:73
+msgid "Display Tiles"
+msgstr "Prikaži tile-ove"
+
+#: pygeoapi/templates/collections/collection.html:73
+msgid "Display Tiles of"
+msgstr "Prikaži tile-ove od"
+
+#: pygeoapi/templates/collections/collection.html:80
+#: pygeoapi/templates/jobs/job.html:50
+#: pygeoapi/templates/processes/process.html:78
+msgid "Links"
+msgstr "Linkovi"
+
+#: pygeoapi/templates/collections/collection.html:90
+msgid "Reference Systems"
+msgstr "Referentni sistemi"
+
+#: pygeoapi/templates/collections/collection.html:98
+msgid "Storage CRS"
+msgstr "Skladišni CRS"
+
+#: pygeoapi/templates/collections/index.html:12
+#: pygeoapi/templates/processes/index.html:14
+#: pygeoapi/templates/stac/catalog.html:17
+#: pygeoapi/templates/stac/collection.html:17
+msgid "Name"
+msgstr "Naziv"
+
+#: pygeoapi/templates/collections/index.html:13
+#: pygeoapi/templates/stac/catalog.html:18
+msgid "Type"
+msgstr "Tip"
+
+#: pygeoapi/templates/collections/index.html:14
+#: 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 "Opis"
+
+#: pygeoapi/templates/collections/coverage/domainset.html:11
+msgid "Coverage domain set"
+msgstr "Pokrivenost domenskog skupa"
+
+#: pygeoapi/templates/collections/coverage/domainset.html:12
+msgid "Axis labels"
+msgstr "Labele osa"
+
+#: pygeoapi/templates/collections/coverage/domainset.html:18
+msgid "Extent"
+msgstr "Extent"
+
+#: pygeoapi/templates/collections/coverage/domainset.html:24
+msgid "Coordinate reference system"
+msgstr "Koordinate referentnog sistema"
+
+#: pygeoapi/templates/collections/coverage/domainset.html:26
+#: pygeoapi/templates/stac/catalog.html:20
+#: pygeoapi/templates/stac/collection_base.html:34
+#: pygeoapi/templates/stac/item.html:34
+msgid "Size"
+msgstr "Veličina"
+
+#: pygeoapi/templates/collections/coverage/domainset.html:28
+msgid "width"
+msgstr "širina"
+
+#: pygeoapi/templates/collections/coverage/domainset.html:29
+msgid "height"
+msgstr "visina"
+
+#: pygeoapi/templates/collections/coverage/domainset.html:31
+msgid "Resolution"
+msgstr "Rezolucija"
+
+#: pygeoapi/templates/collections/coverage/domainset.html:33
+msgid "x"
+msgstr "x"
+
+#: pygeoapi/templates/collections/coverage/domainset.html:34
+msgid "y"
+msgstr "y"
+
+#: pygeoapi/templates/collections/coverage/rangetype.html:11
+msgid "Coverage range type"
+msgstr "Pokrivenost tipa opsega"
+
+#: pygeoapi/templates/collections/coverage/rangetype.html:12
+msgid "Fields"
+msgstr "Polja"
+
+#: pygeoapi/templates/collections/edr/query.html:11
+#: pygeoapi/templates/collections/items/index.html:11
+#: pygeoapi/templates/collections/items/item.html:33
+msgid "Items"
+msgstr "Stavke"
+
+#: pygeoapi/templates/collections/items/index.html:25
+msgid "Items in this collection"
+msgstr "Stavke u ovoj kolekciji"
+
+#: pygeoapi/templates/collections/items/index.html:38
+msgid "Warning: Higher limits not recommended!"
+msgstr "Upozorenje: Veće granice nisu preporučljivi!"
+
+#: pygeoapi/templates/collections/items/index.html:43
+msgid "Limit"
+msgstr "Granica"
+
+#: pygeoapi/templates/collections/items/index.html:45
+msgid "default"
+msgstr "podrazumijevano"
+
+#: pygeoapi/templates/collections/items/index.html:66
+#: pygeoapi/templates/collections/items/item.html:62
+msgid "Prev"
+msgstr "Prethodno"
+
+#: pygeoapi/templates/collections/items/index.html:68
+#: pygeoapi/templates/collections/items/item.html:64
+msgid "Next"
+msgstr "Sljedeće"
+
+#: pygeoapi/templates/collections/items/index.html:139
+msgid "No items"
+msgstr "Nema stavki"
+
+#: pygeoapi/templates/collections/items/item.html:77
+#: pygeoapi/templates/stac/collection_base.html:58
+#: pygeoapi/templates/stac/item.html:58
+msgid "Property"
+msgstr "Svojstvo"
+
+#: pygeoapi/templates/collections/items/item.html:78
+#: pygeoapi/templates/stac/collection_base.html:59
+#: pygeoapi/templates/stac/item.html:59
+msgid "Value"
+msgstr "Vrijednost"
+
+#: pygeoapi/templates/collections/tiles/index.html:31
+msgid "Tile Matrix Set"
+msgstr "Matrica tile-ova"
+
+#: pygeoapi/templates/collections/tiles/index.html:42
+msgid "Metadata"
+msgstr "Meta podaci"
+
+#: pygeoapi/templates/collections/tiles/metadata.html:15
+msgid "TileJSON"
+msgstr "TileJSON"
+
+#: pygeoapi/templates/collections/tiles/metadata.html:17
+msgid "JSON"
+msgstr "JSON"
+
+#: pygeoapi/templates/jobs/index.html:14
+msgid "Job ID"
+msgstr "ID zadatka"
+
+#: pygeoapi/templates/jobs/index.html:15
+msgid "Process ID"
+msgstr "ID procesa"
+
+#: pygeoapi/templates/jobs/index.html:16
+msgid "Start"
+msgstr "Početak"
+
+#: pygeoapi/templates/jobs/index.html:17 pygeoapi/templates/jobs/job.html:37
+msgid "Duration"
+msgstr "Trajanje"
+
+#: pygeoapi/templates/jobs/index.html:18 pygeoapi/templates/jobs/job.html:17
+#: pygeoapi/templates/jobs/job.html:35
+msgid "Progress"
+msgstr "Napredak"
+
+#: pygeoapi/templates/jobs/index.html:19 pygeoapi/templates/jobs/job.html:16
+msgid "Status"
+msgstr "Status"
+
+#: pygeoapi/templates/jobs/index.html:20 pygeoapi/templates/jobs/job.html:21
+msgid "Message"
+msgstr "Poruka"
+
+#: pygeoapi/templates/jobs/job.html:2 pygeoapi/templates/jobs/job.html:10
+msgid "Job status"
+msgstr "Status zadatka"
+
+#: pygeoapi/templates/jobs/job.html:26
+msgid "Parameters"
+msgstr "Parametri"
+
+#: pygeoapi/templates/jobs/job.html:45
+msgid "Started processing"
+msgstr "Započeto procesiranje"
+
+#: pygeoapi/templates/jobs/job.html:47
+msgid "Finished processing"
+msgstr "Završeno procesiranje"
+
+#: pygeoapi/templates/jobs/results/index.html:2
+msgid "Job result"
+msgstr "Rezuultat zadatka"
+
+#: pygeoapi/templates/jobs/results/index.html:6
+msgid "Results"
+msgstr "Rezuultati"
+
+#: pygeoapi/templates/jobs/results/index.html:10
+msgid "Results of job"
+msgstr "Rezultati zadatka"
+
+#: pygeoapi/templates/processes/index.html:8
+msgid "Processes in this service"
+msgstr "Procesi u ovoj usluzi"
+
+#: pygeoapi/templates/processes/process.html:20
+msgid "Inputs"
+msgstr "Ulazi"
+
+#: pygeoapi/templates/processes/process.html:23
+#: pygeoapi/templates/processes/process.html:54
+msgid "Id"
+msgstr "Id"
+
+#: pygeoapi/templates/processes/process.html:24
+#: pygeoapi/templates/processes/process.html:55
+#: pygeoapi/templates/tilematrixsets/index.html:15
+msgid "Title"
+msgstr "Naslov"
+
+#: pygeoapi/templates/processes/process.html:25
+msgid "Data Type"
+msgstr "Vrst podataka"
+
+#: pygeoapi/templates/processes/process.html:51
+msgid "Outputs"
+msgstr "Izlazi"
+
+#: pygeoapi/templates/processes/process.html:71
+msgid "Execution modes"
+msgstr "Modovi izvršavanja"
+
+#: pygeoapi/templates/processes/process.html:73
+msgid "Synchronous"
+msgstr "Sinhroni"
+
+#: pygeoapi/templates/processes/process.html:74
+msgid "Asynchronous"
+msgstr "Asinhroni"
+
+#: 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 "Prostorno-vremenski katalog resursa"
+
+#: pygeoapi/templates/stac/catalog.html:19
+msgid "Last modified"
+msgstr "Zadnji put izmijenjeno"
+
+#: pygeoapi/templates/stac/collection.html:9
+msgid "STAC Version"
+msgstr "STAC verzija"
+
+#: pygeoapi/templates/stac/collection_base.html:28
+#: pygeoapi/templates/stac/item.html:28
+msgid "Assets"
+msgstr "Resursi"
+
+#: pygeoapi/templates/stac/collection_base.html:32
+#: pygeoapi/templates/stac/item.html:32
+msgid "URL"
+msgstr "URL"
+
+#: pygeoapi/templates/stac/collection_base.html:33
+#: pygeoapi/templates/stac/item.html:33
+msgid "Last Modified"
+msgstr "Zadnji put izmijenjeno"
+
+#: pygeoapi/templates/stac/collection_base.html:64
+#: pygeoapi/templates/stac/item.html:64
+msgid "id"
+msgstr "id"
+
+#: pygeoapi/templates/stac/collection_base.html:68
+msgid "description"
+msgstr "opis"
+
+#: pygeoapi/templates/stac/collection_base.html:72
+msgid "extent"
+msgstr "extent"
+
+#: pygeoapi/templates/stac/collection_base.html:77
+msgid "cube:dimensions"
+msgstr "kocka:dimenzije"
+
+#: pygeoapi/templates/stac/collection_base.html:83
+msgid "cube:variables"
+msgstr "kocka:varijable"
+
+#: pygeoapi/templates/stac/item.html:19
+msgid "Item"
+msgstr "Stavka"
+
+#: pygeoapi/templates/tilematrixsets/index.html:2
+#: pygeoapi/templates/tilematrixsets/index.html:4
+msgid "TileMatrixSets"
+msgstr ""
+
+#: pygeoapi/templates/tilematrixsets/index.html:9
+msgid "Tile matrix sets available in this service"
+msgstr "Matrica tile-ova dostupna u ovoj usluzi"
+
+#: pygeoapi/templates/tilematrixsets/tilematrixset.html:2
+#: pygeoapi/templates/tilematrixsets/tilematrixset.html:4
+msgid "TileMatrixSet"
+msgstr "Matrica tile-ova"
+
+msgid "This document as JSON"
+msgstr ""
+
+msgid "This document as RDF (JSON-LD)"
+msgstr ""
+
+msgid "This document as HTML"
+msgstr ""
+
+msgid "The OpenAPI definition as JSON"
+msgstr ""
+
+msgid "The list of supported tiling schemes as JSON"
+msgstr ""
+
+msgid "The list of supported tiling schemes as HTML"
+msgstr ""
+
+msgid "The landing page of this server as JSON"
+msgstr ""
+
+msgid "The landing page of this server as HTML"
+msgstr ""
+
+msgid "Schema of collection in JSON"
+msgstr ""
+
+msgid "Schema of collection in HTML"
+msgstr ""
+
+msgid "Queryables for this collection as JSON"
+msgstr ""
+
+msgid "Queryables for this collection as HTML"
+msgstr ""
+
+msgid "Items as GeoJSON"
+msgstr ""
+
+msgid "Items as HTML"
+msgstr ""
+
+msgid "Items as RDF (GeoJSON-LD)"
+msgstr ""
+
+msgid "Coverage data"
+msgstr ""
+
+msgid "Coverage data as"
+msgstr ""
+
+msgid "Tiles as HTML"
+msgstr ""
+
+msgid "Tiles as JSON"
+msgstr ""
+
+msgid "query for this collection as HTML"
+msgstr ""
+
+msgid "query for this collection as JSON"
+msgstr ""
+
+msgid "Items (prev)"
+msgstr ""
+
+msgid "Items (next)"
+msgstr ""
+
+msgid "Process description as JSON"
+msgstr ""
+
+msgid "Process description as HTML"
+msgstr ""
+
+msgid "query for this collection as HTML"
+msgstr ""
+
+msgid "Execution for process as JSON"
+msgstr ""
+
+msgid "Jobs list as JSON"
+msgstr ""
+
+msgid "Jobs list as HTML"
+msgstr ""
+
+msgid "Results of job as JSON"
+msgstr ""
+
+msgid "Results of job as HTML"
+msgstr ""
+
+msgid "The job list for the current process"
+msgstr ""
+
+msgid "TileMatrixSet definition in JSON"
+msgstr ""
+
+msgid "Data collections in this service"
+msgstr ""
+
+msgid "Record collections in this service"
+msgstr ""
+
+msgid "Display Schema"
+msgstr ""
+
+msgid "Display Schema of"
+msgstr ""
+
+msgid "Contact"
+msgstr ""
+
+msgid "CRS"
+msgstr ""
+
+msgid "Epoch"
+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 ""
diff --git a/locale/de/LC_MESSAGES/messages.po b/locale/de/LC_MESSAGES/messages.po
index a47e6a6d6..3648a2767 100644
--- a/locale/de/LC_MESSAGES/messages.po
+++ b/locale/de/LC_MESSAGES/messages.po
@@ -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
@@ -584,3 +588,149 @@ msgstr "Ergebnis"
msgid "Results of job"
msgstr "Ergebnis des Auftrags"
+msgid "This document as JSON"
+msgstr ""
+
+msgid "This document as RDF (JSON-LD)"
+msgstr ""
+
+msgid "This document as HTML"
+msgstr ""
+
+msgid "The OpenAPI definition as JSON"
+msgstr ""
+
+msgid "The list of supported tiling schemes as JSON"
+msgstr ""
+
+msgid "The list of supported tiling schemes as HTML"
+msgstr ""
+
+msgid "The landing page of this server as JSON"
+msgstr ""
+
+msgid "The landing page of this server as HTML"
+msgstr ""
+
+msgid "Schema of collection in JSON"
+msgstr ""
+
+msgid "Schema of collection in HTML"
+msgstr ""
+
+msgid "Queryables for this collection as JSON"
+msgstr ""
+
+msgid "Queryables for this collection as HTML"
+msgstr ""
+
+msgid "Items as GeoJSON"
+msgstr ""
+
+msgid "Items as HTML"
+msgstr ""
+
+msgid "Items as RDF (GeoJSON-LD)"
+msgstr ""
+
+msgid "Coverage data"
+msgstr ""
+
+msgid "Coverage data as"
+msgstr ""
+
+msgid "Tiles as HTML"
+msgstr ""
+
+msgid "Tiles as JSON"
+msgstr ""
+
+msgid "query for this collection as HTML"
+msgstr ""
+
+msgid "query for this collection as JSON"
+msgstr ""
+
+msgid "Items (prev)"
+msgstr ""
+
+msgid "Items (next)"
+msgstr ""
+
+msgid "Process description as JSON"
+msgstr ""
+
+msgid "Process description as HTML"
+msgstr ""
+
+msgid "query for this collection as HTML"
+msgstr ""
+
+msgid "Execution for process as JSON"
+msgstr ""
+
+msgid "Jobs list as JSON"
+msgstr ""
+
+msgid "Jobs list as HTML"
+msgstr ""
+
+msgid "Results of job as JSON"
+msgstr ""
+
+msgid "Results of job as HTML"
+msgstr ""
+
+msgid "The job list for the current process"
+msgstr ""
+
+msgid "TileMatrixSet definition in JSON"
+msgstr ""
+
+msgid "Data collections in this service"
+msgstr ""
+
+msgid "Record collections in this service"
+msgstr ""
+
+msgid "Display Schema"
+msgstr ""
+
+msgid "Display Schema of"
+msgstr ""
+
+msgid "Contact"
+msgstr ""
+
+msgid "CRS"
+msgstr ""
+
+msgid "Epoch"
+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 ""
diff --git a/locale/en/LC_MESSAGES/messages.po b/locale/en/LC_MESSAGES/messages.po
index e3ed1ce45..17a2698e9 100644
--- a/locale/en/LC_MESSAGES/messages.po
+++ b/locale/en/LC_MESSAGES/messages.po
@@ -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
@@ -586,3 +590,149 @@ msgstr ""
#~ msgid "Powered by"
#~ msgstr ""
+msgid "This document as JSON"
+msgstr ""
+
+msgid "This document as RDF (JSON-LD)"
+msgstr ""
+
+msgid "This document as HTML"
+msgstr ""
+
+msgid "The OpenAPI definition as JSON"
+msgstr ""
+
+msgid "The list of supported tiling schemes as JSON"
+msgstr ""
+
+msgid "The list of supported tiling schemes as HTML"
+msgstr ""
+
+msgid "The landing page of this server as JSON"
+msgstr ""
+
+msgid "The landing page of this server as HTML"
+msgstr ""
+
+msgid "Schema of collection in JSON"
+msgstr ""
+
+msgid "Schema of collection in HTML"
+msgstr ""
+
+msgid "Queryables for this collection as JSON"
+msgstr ""
+
+msgid "Queryables for this collection as HTML"
+msgstr ""
+
+msgid "Items as GeoJSON"
+msgstr ""
+
+msgid "Items as HTML"
+msgstr ""
+
+msgid "Items as RDF (GeoJSON-LD)"
+msgstr ""
+
+msgid "Coverage data"
+msgstr ""
+
+msgid "Coverage data as"
+msgstr ""
+
+msgid "Tiles as HTML"
+msgstr ""
+
+msgid "Tiles as JSON"
+msgstr ""
+
+msgid "query for this collection as HTML"
+msgstr ""
+
+msgid "query for this collection as JSON"
+msgstr ""
+
+msgid "Items (prev)"
+msgstr ""
+
+msgid "Items (next)"
+msgstr ""
+
+msgid "Process description as JSON"
+msgstr ""
+
+msgid "Process description as HTML"
+msgstr ""
+
+msgid "query for this collection as HTML"
+msgstr ""
+
+msgid "Execution for process as JSON"
+msgstr ""
+
+msgid "Jobs list as JSON"
+msgstr ""
+
+msgid "Jobs list as HTML"
+msgstr ""
+
+msgid "Results of job as JSON"
+msgstr ""
+
+msgid "Results of job as HTML"
+msgstr ""
+
+msgid "The job list for the current process"
+msgstr ""
+
+msgid "TileMatrixSet definition in JSON"
+msgstr ""
+
+msgid "Data collections in this service"
+msgstr ""
+
+msgid "Record collections in this service"
+msgstr ""
+
+msgid "Display Schema"
+msgstr ""
+
+msgid "Display Schema of"
+msgstr ""
+
+msgid "Contact"
+msgstr ""
+
+msgid "CRS"
+msgstr ""
+
+msgid "Epoch"
+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 ""
diff --git a/locale/es/LC_MESSAGES/messages.po b/locale/es/LC_MESSAGES/messages.po
new file mode 100644
index 000000000..36bce5c22
--- /dev/null
+++ b/locale/es/LC_MESSAGES/messages.po
@@ -0,0 +1,551 @@
+# Spanish translations for PROJECT.
+# Copyright (C) 2023 ORGANIZATION
+# This file is distributed under the same license as the PROJECT project.
+# FIRST AUTHOR , 2023.
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: PROJECT VERSION\n"
+"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
+"POT-Creation-Date: 2024-06-28 13:12+0200\n"
+"PO-Revision-Date: 2024-07-01 13:15+0200\n"
+"Last-Translator: IDEE \n"
+"Language: de\n"
+"Language-Team: de \n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=utf-8\n"
+"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"
+
+#: pygeoapi/templates/_base.html:67 pygeoapi/templates/landing_page.html:2
+msgid "Home"
+msgstr "Inicio"
+
+#: pygeoapi/templates/_base.html:75 pygeoapi/templates/_base.html:83
+msgid "json"
+msgstr "json"
+
+#: pygeoapi/templates/_base.html:78 pygeoapi/templates/_base.html:86
+msgid "jsonld"
+msgstr "jsonld"
+
+#: pygeoapi/templates/_base.html:105
+msgid "Powered by "
+msgstr "Desarrollado por "
+
+#: pygeoapi/templates/conformance.html:2 pygeoapi/templates/conformance.html:4
+#: pygeoapi/templates/conformance.html:8
+#: pygeoapi/templates/landing_page.html:86
+msgid "Conformance"
+msgstr "Conformidad"
+
+#: pygeoapi/templates/exception.html:2 pygeoapi/templates/exception.html:5
+msgid "Exception"
+msgstr "Excepción"
+
+#: pygeoapi/templates/landing_page.html:25
+msgid "Terms of service"
+msgstr "Términos del servicio"
+
+#: pygeoapi/templates/collections/collection.html:38
+#: pygeoapi/templates/landing_page.html:35
+msgid "License"
+msgstr "Licencia"
+
+#: 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:48
+#: pygeoapi/templates/stac/collection_base.html:19
+msgid "Collections"
+msgstr "Colecciones"
+
+#: pygeoapi/templates/landing_page.html:50
+msgid "View the collections in this service"
+msgstr "Ver las colecciones de este servicio"
+
+#: pygeoapi/templates/landing_page.html:56
+msgid "SpatioTemporal Assets"
+msgstr "SpatioTemporal Assets"
+
+#: pygeoapi/templates/landing_page.html:58
+msgid "View the SpatioTemporal Assets in this service"
+msgstr "Ver los SpatioTemporal Assets de este servicio"
+
+#: pygeoapi/templates/landing_page.html:64
+#: pygeoapi/templates/processes/index.html:2
+#: pygeoapi/templates/processes/index.html:4
+#: pygeoapi/templates/processes/process.html:4
+msgid "Processes"
+msgstr "Procesos"
+
+#: pygeoapi/templates/landing_page.html:66
+msgid "View the processes in this service"
+msgstr "Ver los procesos de este servicio"
+
+#: 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:70
+#: pygeoapi/templates/processes/process.html:76
+msgid "Jobs"
+msgstr "Tareas"
+
+#: pygeoapi/templates/landing_page.html:72
+#: pygeoapi/templates/processes/process.html:77
+msgid "Browse jobs"
+msgstr "Ver tareas"
+
+#: pygeoapi/templates/landing_page.html:77
+msgid "API Definition"
+msgstr "Definición de API"
+
+#: pygeoapi/templates/landing_page.html:79
+msgid "Documentation"
+msgstr "Documentación de peticiones"
+
+#: pygeoapi/templates/landing_page.html:79
+msgid "Swagger UI"
+msgstr "Swagger UI"
+
+#: pygeoapi/templates/landing_page.html:79
+msgid "ReDoc"
+msgstr "ReDoc"
+
+#: pygeoapi/templates/landing_page.html:82
+msgid "OpenAPI Document"
+msgstr "Documento OpenAPI"
+
+#: pygeoapi/templates/landing_page.html:88
+msgid "View the conformance classes of this service"
+msgstr "Ver las clases de conformidad de este servicio"
+
+#: pygeoapi/templates/landing_page.html:92
+msgid "Tile Matrix Sets"
+msgstr "Tile Matrix Sets"
+
+#: pygeoapi/templates/landing_page.html:94
+msgid "View the Tile Matrix Sets available on this service"
+msgstr "Ver los Tile Matrix Sets disponibles en este servicio"
+
+#: pygeoapi/templates/landing_page.html:101
+msgid "Provider"
+msgstr "Proveedor"
+
+#: pygeoapi/templates/landing_page.html:110
+msgid "Contact point"
+msgstr "Punto de contacto"
+
+#: pygeoapi/templates/landing_page.html:113
+msgid "Address"
+msgstr "Dirección"
+
+#: pygeoapi/templates/landing_page.html:122
+msgid "Email"
+msgstr "Correo electrónico"
+
+#: pygeoapi/templates/landing_page.html:125
+msgid "Telephone"
+msgstr "Teléfono"
+
+#: pygeoapi/templates/landing_page.html:129
+msgid "Fax"
+msgstr "Fax"
+
+#: pygeoapi/templates/landing_page.html:133
+msgid "Contact URL"
+msgstr "URL de contacto"
+
+#: pygeoapi/templates/landing_page.html:137
+msgid "Hours"
+msgstr "Horario"
+
+#: pygeoapi/templates/landing_page.html:141
+msgid "Contact instructions"
+msgstr "Instrucciones de contacto"
+
+#: pygeoapi/templates/collections/collection.html:51
+msgid "Browse"
+msgstr "Explorar"
+
+#: pygeoapi/templates/collections/collection.html:55
+msgid "Browse Items"
+msgstr "Ver los objetos"
+
+#: pygeoapi/templates/collections/collection.html:56
+msgid "Browse through the items of"
+msgstr "Ver los objetos de"
+
+#: pygeoapi/templates/collections/collection.html:59
+#: pygeoapi/templates/collections/queryables.html:6
+#: pygeoapi/templates/collections/queryables.html:17
+msgid "Queryables"
+msgstr "Atributos consultables"
+
+#: pygeoapi/templates/collections/collection.html:63
+msgid "Display Queryables"
+msgstr "Mostrar los atributos consultables"
+
+#: pygeoapi/templates/collections/collection.html:64
+msgid "Display Queryables of"
+msgstr "Mostrar los atributos consultables de"
+
+#: pygeoapi/templates/collections/collection.html:67
+#: pygeoapi/templates/collections/schema.html:6
+#: pygeoapi/templates/collections/schema.html:17
+msgid "Schema"
+msgstr "Esquema"
+
+#: pygeoapi/templates/collections/collection.html:71
+msgid "Display Schema"
+msgstr "Mostrar esquema"
+
+#: pygeoapi/templates/collections/collection.html:72
+msgid "Display Schema of"
+msgstr "Mostrar el esquema de"
+
+#: pygeoapi/templates/collections/collection.html:77
+#: pygeoapi/templates/collections/tiles/index.html:6
+#: pygeoapi/templates/collections/tiles/metadata.html:6
+msgid "Tiles"
+msgstr "Teselas"
+
+#: pygeoapi/templates/collections/collection.html:81
+msgid "Display Tiles"
+msgstr "Mostrar las teselas"
+
+#: pygeoapi/templates/collections/collection.html:81
+msgid "Display Tiles of"
+msgstr "Mostrar las teselas de"
+
+#: pygeoapi/templates/collections/collection.html:88
+#: pygeoapi/templates/jobs/job.html:50
+#: pygeoapi/templates/processes/process.html:78
+msgid "Links"
+msgstr "Enlaces"
+
+#: pygeoapi/templates/collections/collection.html:98
+msgid "Reference Systems"
+msgstr "Sistemas de referencia"
+
+#: pygeoapi/templates/collections/collection.html:106
+msgid "Storage CRS"
+msgstr "SRC de almacenamiento"
+
+#: pygeoapi/templates/collections/index.html:8
+msgid "Data collections in this service"
+msgstr "Colecciones de datos en este servicio"
+
+#: 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 "Nombre"
+
+#: 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 "Descripción"
+
+#: pygeoapi/templates/collections/index.html:35
+msgid "Record collections in this service"
+msgstr "Colecciones de registros en este servicio"
+
+#: pygeoapi/templates/collections/edr/query.html:11
+#: pygeoapi/templates/collections/items/index.html:11
+#: pygeoapi/templates/collections/items/item.html:33
+msgid "Items"
+msgstr "Objetos"
+
+#: pygeoapi/templates/collections/items/index.html:25
+msgid "Items in this collection"
+msgstr "Objetos en esta colección"
+
+#: pygeoapi/templates/collections/items/index.html:38
+msgid "Warning: Higher limits not recommended!"
+msgstr "Atención: No se recomiendan límites superiores"
+
+#: pygeoapi/templates/collections/items/index.html:43
+msgid "Limit"
+msgstr "Límite"
+
+#: pygeoapi/templates/collections/items/index.html:45
+msgid "default"
+msgstr "por defecto"
+
+#: pygeoapi/templates/collections/items/index.html:66
+#: pygeoapi/templates/collections/items/item.html:62
+msgid "Prev"
+msgstr "Anterior"
+
+#: pygeoapi/templates/collections/items/index.html:68
+#: pygeoapi/templates/collections/items/item.html:64
+msgid "Next"
+msgstr "Siguiente"
+
+#: pygeoapi/templates/collections/items/index.html:139
+msgid "No items"
+msgstr "No hay objetos"
+
+#: pygeoapi/templates/collections/items/item.html:77
+#: pygeoapi/templates/stac/collection_base.html:58
+#: pygeoapi/templates/stac/item.html:58
+msgid "Property"
+msgstr "Propiedad"
+
+#: pygeoapi/templates/collections/items/item.html:78
+#: pygeoapi/templates/stac/collection_base.html:59
+#: pygeoapi/templates/stac/item.html:59
+msgid "Value"
+msgstr "Valor"
+
+#: pygeoapi/templates/collections/tiles/index.html:31
+msgid "Tile Matrix Set"
+msgstr "Tile Matrix Set"
+
+#: pygeoapi/templates/collections/tiles/index.html:42
+msgid "Metadata"
+msgstr "Metadatos"
+
+#: pygeoapi/templates/collections/tiles/metadata.html:15
+msgid "TileJSON"
+msgstr "TileJSON"
+
+#: pygeoapi/templates/collections/tiles/metadata.html:17
+msgid "JSON"
+msgstr "JSON"
+
+#: pygeoapi/templates/jobs/index.html:14
+msgid "Job ID"
+msgstr "ID tarea"
+
+#: pygeoapi/templates/jobs/index.html:15
+msgid "Process ID"
+msgstr "ID proceso"
+
+#: pygeoapi/templates/jobs/index.html:16
+msgid "Start"
+msgstr "Inicio"
+
+#: pygeoapi/templates/jobs/index.html:17 pygeoapi/templates/jobs/job.html:37
+msgid "Duration"
+msgstr "Duración"
+
+#: pygeoapi/templates/jobs/index.html:18 pygeoapi/templates/jobs/job.html:17
+#: pygeoapi/templates/jobs/job.html:35
+msgid "Progress"
+msgstr "Progreso"
+
+#: pygeoapi/templates/jobs/index.html:19 pygeoapi/templates/jobs/job.html:16
+msgid "Status"
+msgstr "Estado"
+
+#: pygeoapi/templates/jobs/index.html:20 pygeoapi/templates/jobs/job.html:21
+msgid "Message"
+msgstr "Mensaje"
+
+#: pygeoapi/templates/jobs/job.html:2 pygeoapi/templates/jobs/job.html:10
+msgid "Job status"
+msgstr "Estado de la tarea"
+
+#: pygeoapi/templates/jobs/job.html:26
+msgid "Parameters"
+msgstr "Parámetros"
+
+#: pygeoapi/templates/jobs/job.html:45
+msgid "Started processing"
+msgstr "Proceso iniciado"
+
+#: pygeoapi/templates/jobs/job.html:47
+msgid "Finished processing"
+msgstr "Proceso finalizado"
+
+#: pygeoapi/templates/jobs/results/index.html:2
+msgid "Job result"
+msgstr "Resultado de la tarea"
+
+#: pygeoapi/templates/jobs/results/index.html:6
+msgid "Results"
+msgstr "Resultados"
+
+#: pygeoapi/templates/jobs/results/index.html:10
+msgid "Results of job"
+msgstr "Resultados de la tarea"
+
+#: pygeoapi/templates/processes/index.html:8
+msgid "Processes in this service"
+msgstr "Procesos en este servicio"
+
+#: pygeoapi/templates/processes/process.html:20
+msgid "Inputs"
+msgstr "Datos de entrada"
+
+#: pygeoapi/templates/processes/process.html:23
+#: pygeoapi/templates/processes/process.html:54
+msgid "Id"
+msgstr "ID"
+
+#: pygeoapi/templates/processes/process.html:24
+#: pygeoapi/templates/processes/process.html:55
+#: pygeoapi/templates/tilematrixsets/index.html:15
+msgid "Title"
+msgstr "Título"
+
+#: pygeoapi/templates/processes/process.html:25
+msgid "Data Type"
+msgstr "Tipo de dato"
+
+#: pygeoapi/templates/processes/process.html:51
+msgid "Outputs"
+msgstr "Datos de salida"
+
+#: pygeoapi/templates/processes/process.html:71
+msgid "Execution modes"
+msgstr "Modos de ejecución"
+
+#: pygeoapi/templates/processes/process.html:73
+msgid "Synchronous"
+msgstr "Síncrono"
+
+#: pygeoapi/templates/processes/process.html:74
+msgid "Asynchronous"
+msgstr "Asíncrono"
+
+#: 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 "Catálogo de SpatioTemporal Asset"
+
+#: pygeoapi/templates/stac/catalog.html:18
+msgid "Type"
+msgstr "Tipo"
+
+#: pygeoapi/templates/stac/catalog.html:19
+msgid "Last modified"
+msgstr "Última modificación"
+
+#: pygeoapi/templates/stac/catalog.html:20
+#: pygeoapi/templates/stac/collection_base.html:34
+#: pygeoapi/templates/stac/item.html:34
+msgid "Size"
+msgstr "Tamaño"
+
+#: pygeoapi/templates/stac/collection.html:9
+msgid "STAC Version"
+msgstr "Versión STAC"
+
+#: pygeoapi/templates/stac/collection_base.html:28
+#: pygeoapi/templates/stac/item.html:28
+msgid "Assets"
+msgstr "Assets"
+
+#: pygeoapi/templates/stac/collection_base.html:32
+#: pygeoapi/templates/stac/item.html:32
+msgid "URL"
+msgstr "URL"
+
+#: pygeoapi/templates/stac/collection_base.html:33
+#: pygeoapi/templates/stac/item.html:33
+msgid "Last Modified"
+msgstr "Última modificación"
+
+#: pygeoapi/templates/stac/collection_base.html:64
+#: pygeoapi/templates/stac/item.html:64
+msgid "id"
+msgstr "ID"
+
+#: pygeoapi/templates/stac/collection_base.html:68
+msgid "description"
+msgstr "Descripción"
+
+#: pygeoapi/templates/stac/collection_base.html:72
+msgid "extent"
+msgstr "Extensión"
+
+#: pygeoapi/templates/stac/collection_base.html:77
+msgid "cube:dimensions"
+msgstr "cubo:dimensiones"
+
+#: pygeoapi/templates/stac/collection_base.html:83
+msgid "cube:variables"
+msgstr "cubo:variables"
+
+#: pygeoapi/templates/stac/item.html:19
+msgid "Item"
+msgstr "Objeto"
+
+#: pygeoapi/templates/tilematrixsets/index.html:2
+#: pygeoapi/templates/tilematrixsets/index.html:4
+#: pygeoapi/templates/tilematrixsets/tilematrixset.html:4
+msgid "TileMatrixSets"
+msgstr "Tile Matrix Sets"
+
+#: pygeoapi/templates/tilematrixsets/index.html:9
+msgid "Tile matrix sets available in this service"
+msgstr "Tile Matrix Sets disponibles en este servicio"
+
+#: pygeoapi/templates/tilematrixsets/tilematrixset.html:2
+msgid "TileMatrixSet"
+msgstr "Tile Matrix Set"
+
+msgid "Contact"
+msgstr ""
+
+msgid "CRS"
+msgstr ""
+
+msgid "Epoch"
+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 ""
diff --git a/locale/fr/LC_MESSAGES/messages.po b/locale/fr/LC_MESSAGES/messages.po
index 364e441e6..f8d62bc3f 100644
--- a/locale/fr/LC_MESSAGES/messages.po
+++ b/locale/fr/LC_MESSAGES/messages.po
@@ -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
@@ -593,3 +597,149 @@ msgstr ""
msgid "Results of job"
msgstr ""
+msgid "This document as JSON"
+msgstr ""
+
+msgid "This document as RDF (JSON-LD)"
+msgstr ""
+
+msgid "This document as HTML"
+msgstr ""
+
+msgid "The OpenAPI definition as JSON"
+msgstr ""
+
+msgid "The list of supported tiling schemes as JSON"
+msgstr ""
+
+msgid "The list of supported tiling schemes as HTML"
+msgstr ""
+
+msgid "The landing page of this server as JSON"
+msgstr ""
+
+msgid "The landing page of this server as HTML"
+msgstr ""
+
+msgid "Schema of collection in JSON"
+msgstr ""
+
+msgid "Schema of collection in HTML"
+msgstr ""
+
+msgid "Queryables for this collection as JSON"
+msgstr ""
+
+msgid "Queryables for this collection as HTML"
+msgstr ""
+
+msgid "Items as GeoJSON"
+msgstr ""
+
+msgid "Items as HTML"
+msgstr ""
+
+msgid "Items as RDF (GeoJSON-LD)"
+msgstr ""
+
+msgid "Coverage data"
+msgstr ""
+
+msgid "Coverage data as"
+msgstr ""
+
+msgid "Tiles as HTML"
+msgstr ""
+
+msgid "Tiles as JSON"
+msgstr ""
+
+msgid "query for this collection as HTML"
+msgstr ""
+
+msgid "query for this collection as JSON"
+msgstr ""
+
+msgid "Items (prev)"
+msgstr ""
+
+msgid "Items (next)"
+msgstr ""
+
+msgid "Process description as JSON"
+msgstr ""
+
+msgid "Process description as HTML"
+msgstr ""
+
+msgid "query for this collection as HTML"
+msgstr ""
+
+msgid "Execution for process as JSON"
+msgstr ""
+
+msgid "Jobs list as JSON"
+msgstr ""
+
+msgid "Jobs list as HTML"
+msgstr ""
+
+msgid "Results of job as JSON"
+msgstr ""
+
+msgid "Results of job as HTML"
+msgstr ""
+
+msgid "The job list for the current process"
+msgstr ""
+
+msgid "TileMatrixSet definition in JSON"
+msgstr ""
+
+msgid "Data collections in this service"
+msgstr ""
+
+msgid "Record collections in this service"
+msgstr ""
+
+msgid "Display Schema"
+msgstr ""
+
+msgid "Display Schema of"
+msgstr ""
+
+msgid "Contact"
+msgstr ""
+
+msgid "CRS"
+msgstr ""
+
+msgid "Epoch"
+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 ""
diff --git a/locale/sr/LC_MESSAGES/messages.po b/locale/sr/LC_MESSAGES/messages.po
new file mode 100644
index 000000000..878ca5e5a
--- /dev/null
+++ b/locale/sr/LC_MESSAGES/messages.po
@@ -0,0 +1,686 @@
+# Serbian translations for PROJECT.
+# Copyright (C) 2024 ORGANIZATION
+# This file is distributed under the same license as the PROJECT project.
+# FIRST AUTHOR , 2024.
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: PROJECT VERSION\n"
+"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
+"POT-Creation-Date: 2024-02-27 14:39+0100\n"
+"PO-Revision-Date: 2024-02-27 14:40+0100\n"
+"Last-Translator: Bojan Kuzmanović \n"
+"Language: sr\n"
+"Language-Team: sr \n"
+"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && "
+"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=utf-8\n"
+"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"
+
+#: pygeoapi/templates/_base.html:67 pygeoapi/templates/landing_page.html:2
+msgid "Home"
+msgstr "Početna"
+
+#: pygeoapi/templates/_base.html:75 pygeoapi/templates/_base.html:83
+msgid "json"
+msgstr "json"
+
+#: pygeoapi/templates/_base.html:78 pygeoapi/templates/_base.html:86
+msgid "jsonld"
+msgstr "jsonld"
+
+#: pygeoapi/templates/_base.html:105
+msgid "Powered by "
+msgstr "Pokreće ga "
+
+#: pygeoapi/templates/conformance.html:2 pygeoapi/templates/conformance.html:4
+#: pygeoapi/templates/conformance.html:8
+#: pygeoapi/templates/landing_page.html:86
+msgid "Conformance"
+msgstr "Usaglašenost"
+
+#: pygeoapi/templates/exception.html:2 pygeoapi/templates/exception.html:5
+msgid "Exception"
+msgstr "Izuzetak"
+
+#: pygeoapi/templates/landing_page.html:25
+msgid "Terms of service"
+msgstr "Uslovi korišćenja"
+
+#: pygeoapi/templates/collections/collection.html:38
+#: pygeoapi/templates/landing_page.html:35
+msgid "License"
+msgstr "Licensa"
+
+#: pygeoapi/templates/collections/collection.html:6
+#: pygeoapi/templates/collections/coverage/domainset.html:4
+#: pygeoapi/templates/collections/coverage/rangetype.html:4
+#: 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/tiles/index.html:4
+#: pygeoapi/templates/collections/tiles/metadata.html:4
+#: pygeoapi/templates/landing_page.html:48
+#: pygeoapi/templates/stac/collection_base.html:19
+msgid "Collections"
+msgstr "Kolekcije"
+
+#: pygeoapi/templates/landing_page.html:50
+msgid "View the collections in this service"
+msgstr "Pogledajte kolekcije u ovom servisu"
+
+#: pygeoapi/templates/landing_page.html:56
+msgid "SpatioTemporal Assets"
+msgstr "Prostorno-vremenski resursi"
+
+#: pygeoapi/templates/landing_page.html:58
+msgid "View the SpatioTemporal Assets in this service"
+msgstr "Pogledajte prostorno-vremenske resurse u ovom servisu"
+
+#: pygeoapi/templates/landing_page.html:64
+#: pygeoapi/templates/processes/index.html:2
+#: pygeoapi/templates/processes/index.html:4
+#: pygeoapi/templates/processes/process.html:4
+msgid "Processes"
+msgstr "Procesi"
+
+#: pygeoapi/templates/landing_page.html:66
+msgid "View the processes in this service"
+msgstr "Poglejte procese u ovom servisu"
+
+#: 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:70
+#: pygeoapi/templates/processes/process.html:76
+msgid "Jobs"
+msgstr "Zadaci"
+
+#: pygeoapi/templates/landing_page.html:72
+#: pygeoapi/templates/processes/process.html:77
+msgid "Browse jobs"
+msgstr "Pogledajte zadatke"
+
+#: pygeoapi/templates/landing_page.html:77
+msgid "API Definition"
+msgstr "API definicija"
+
+#: pygeoapi/templates/landing_page.html:79
+msgid "Documentation"
+msgstr "Dokuemntacija"
+
+#: pygeoapi/templates/landing_page.html:79
+msgid "Swagger UI"
+msgstr "Swagger UI"
+
+#: pygeoapi/templates/landing_page.html:79
+msgid "ReDoc"
+msgstr "ReDoc"
+
+#: pygeoapi/templates/landing_page.html:82
+msgid "OpenAPI Document"
+msgstr "OpenAPI dokument"
+
+#: pygeoapi/templates/landing_page.html:88
+msgid "View the conformance classes of this service"
+msgstr "Pogledajte usaglašenost ovog servisa"
+
+#: pygeoapi/templates/landing_page.html:92
+msgid "Tile Matrix Sets"
+msgstr "Matrica skupova tile-ova"
+
+#: pygeoapi/templates/landing_page.html:94
+msgid "View the Tile Matrix Sets available on this service"
+msgstr "Pogledajte matrice skupova tile-ova dostupne u ovom servisu"
+
+#: pygeoapi/templates/landing_page.html:101
+msgid "Provider"
+msgstr "Provajder"
+
+#: pygeoapi/templates/landing_page.html:110
+msgid "Contact point"
+msgstr "Kontakt tačka"
+
+#: pygeoapi/templates/landing_page.html:113
+msgid "Address"
+msgstr "Adresa"
+
+#: pygeoapi/templates/landing_page.html:122
+msgid "Email"
+msgstr "Email"
+
+#: pygeoapi/templates/landing_page.html:125
+msgid "Telephone"
+msgstr "Telefon"
+
+#: pygeoapi/templates/landing_page.html:129
+msgid "Fax"
+msgstr "Fax"
+
+#: pygeoapi/templates/landing_page.html:133
+msgid "Contact URL"
+msgstr "Kontakt URL"
+
+#: pygeoapi/templates/landing_page.html:137
+msgid "Hours"
+msgstr "Sati"
+
+#: pygeoapi/templates/landing_page.html:141
+msgid "Contact instructions"
+msgstr "Kontakt instrukcije"
+
+#: pygeoapi/templates/collections/collection.html:51
+msgid "Browse"
+msgstr "Pretraži"
+
+#: pygeoapi/templates/collections/collection.html:55
+msgid "Browse Items"
+msgstr "Pretraži stavke"
+
+#: pygeoapi/templates/collections/collection.html:56
+msgid "Browse through the items of"
+msgstr "Pretrazite kroz stavke od"
+
+#: pygeoapi/templates/collections/collection.html:59
+#: pygeoapi/templates/collections/queryables.html:6
+#: pygeoapi/templates/collections/queryables.html:17
+msgid "Queryables"
+msgstr "Upitni"
+
+#: pygeoapi/templates/collections/collection.html:63
+msgid "Display Queryables"
+msgstr "Prikaži upitne"
+
+#: pygeoapi/templates/collections/collection.html:64
+msgid "Display Queryables of"
+msgstr "Prikaži upitne od"
+
+#: pygeoapi/templates/collections/collection.html:69
+#: pygeoapi/templates/collections/tiles/index.html:6
+#: pygeoapi/templates/collections/tiles/metadata.html:6
+msgid "Tiles"
+msgstr "Tile-ovi"
+
+#: pygeoapi/templates/collections/collection.html:73
+msgid "Display Tiles"
+msgstr "Prikaži tile-ove"
+
+#: pygeoapi/templates/collections/collection.html:73
+msgid "Display Tiles of"
+msgstr "Prikaži tile-ove od"
+
+#: pygeoapi/templates/collections/collection.html:80
+#: pygeoapi/templates/jobs/job.html:50
+#: pygeoapi/templates/processes/process.html:78
+msgid "Links"
+msgstr "Linkovi"
+
+#: pygeoapi/templates/collections/collection.html:90
+msgid "Reference Systems"
+msgstr "Referentni sistemi"
+
+#: pygeoapi/templates/collections/collection.html:98
+msgid "Storage CRS"
+msgstr "Skaldišni CRS"
+
+#: pygeoapi/templates/collections/index.html:12
+#: pygeoapi/templates/processes/index.html:14
+#: pygeoapi/templates/stac/catalog.html:17
+#: pygeoapi/templates/stac/collection.html:17
+msgid "Name"
+msgstr "Naziv"
+
+#: pygeoapi/templates/collections/index.html:13
+#: pygeoapi/templates/stac/catalog.html:18
+msgid "Type"
+msgstr "Tip"
+
+#: pygeoapi/templates/collections/index.html:14
+#: 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 "Opis"
+
+#: pygeoapi/templates/collections/coverage/domainset.html:11
+msgid "Coverage domain set"
+msgstr "Pokrivenost domenskog skupa"
+
+#: pygeoapi/templates/collections/coverage/domainset.html:12
+msgid "Axis labels"
+msgstr "Labele osa"
+
+#: pygeoapi/templates/collections/coverage/domainset.html:18
+msgid "Extent"
+msgstr "Extent"
+
+#: pygeoapi/templates/collections/coverage/domainset.html:24
+msgid "Coordinate reference system"
+msgstr "Koordiantni referentni sistem"
+
+#: pygeoapi/templates/collections/coverage/domainset.html:26
+#: pygeoapi/templates/stac/catalog.html:20
+#: pygeoapi/templates/stac/collection_base.html:34
+#: pygeoapi/templates/stac/item.html:34
+msgid "Size"
+msgstr "Veličina"
+
+#: pygeoapi/templates/collections/coverage/domainset.html:28
+msgid "width"
+msgstr "širina"
+
+#: pygeoapi/templates/collections/coverage/domainset.html:29
+msgid "height"
+msgstr "visina"
+
+#: pygeoapi/templates/collections/coverage/domainset.html:31
+msgid "Resolution"
+msgstr "rezolucija"
+
+#: pygeoapi/templates/collections/coverage/domainset.html:33
+msgid "x"
+msgstr "x"
+
+#: pygeoapi/templates/collections/coverage/domainset.html:34
+msgid "y"
+msgstr "y"
+
+#: pygeoapi/templates/collections/coverage/rangetype.html:11
+msgid "Coverage range type"
+msgstr "Pokrivenost tipa opsega"
+
+#: pygeoapi/templates/collections/coverage/rangetype.html:12
+msgid "Fields"
+msgstr "Polja"
+
+#: pygeoapi/templates/collections/edr/query.html:11
+#: pygeoapi/templates/collections/items/index.html:11
+#: pygeoapi/templates/collections/items/item.html:33
+msgid "Items"
+msgstr "Stavke"
+
+#: pygeoapi/templates/collections/items/index.html:25
+msgid "Items in this collection"
+msgstr "Stavke u ovoj kolekciji"
+
+#: pygeoapi/templates/collections/items/index.html:38
+msgid "Warning: Higher limits not recommended!"
+msgstr "Upozorenje: Veća granica nije preporučljiva!"
+
+#: pygeoapi/templates/collections/items/index.html:43
+msgid "Limit"
+msgstr "Granica"
+
+#: pygeoapi/templates/collections/items/index.html:45
+msgid "default"
+msgstr "podrazumevano"
+
+#: pygeoapi/templates/collections/items/index.html:66
+#: pygeoapi/templates/collections/items/item.html:62
+msgid "Prev"
+msgstr "Prethodno"
+
+#: pygeoapi/templates/collections/items/index.html:68
+#: pygeoapi/templates/collections/items/item.html:64
+msgid "Next"
+msgstr "Sljedeće"
+
+#: pygeoapi/templates/collections/items/index.html:139
+msgid "No items"
+msgstr "Nema stavki"
+
+#: pygeoapi/templates/collections/items/item.html:77
+#: pygeoapi/templates/stac/collection_base.html:58
+#: pygeoapi/templates/stac/item.html:58
+msgid "Property"
+msgstr "Svojstvo"
+
+#: pygeoapi/templates/collections/items/item.html:78
+#: pygeoapi/templates/stac/collection_base.html:59
+#: pygeoapi/templates/stac/item.html:59
+msgid "Value"
+msgstr "Vrednost"
+
+#: pygeoapi/templates/collections/tiles/index.html:31
+msgid "Tile Matrix Set"
+msgstr "Matrica skupa tile-ova"
+
+#: pygeoapi/templates/collections/tiles/index.html:42
+msgid "Metadata"
+msgstr "Metapodaci"
+
+#: pygeoapi/templates/collections/tiles/metadata.html:15
+msgid "TileJSON"
+msgstr "TileJSON"
+
+#: pygeoapi/templates/collections/tiles/metadata.html:17
+msgid "JSON"
+msgstr "JSON"
+
+#: pygeoapi/templates/jobs/index.html:14
+msgid "Job ID"
+msgstr "ID zadatka"
+
+#: pygeoapi/templates/jobs/index.html:15
+msgid "Process ID"
+msgstr "ID procesa"
+
+#: pygeoapi/templates/jobs/index.html:16
+msgid "Start"
+msgstr "Započni"
+
+#: pygeoapi/templates/jobs/index.html:17 pygeoapi/templates/jobs/job.html:37
+msgid "Duration"
+msgstr "Trajanje"
+
+#: pygeoapi/templates/jobs/index.html:18 pygeoapi/templates/jobs/job.html:17
+#: pygeoapi/templates/jobs/job.html:35
+msgid "Progress"
+msgstr "Napredak"
+
+#: pygeoapi/templates/jobs/index.html:19 pygeoapi/templates/jobs/job.html:16
+msgid "Status"
+msgstr "Status"
+
+#: pygeoapi/templates/jobs/index.html:20 pygeoapi/templates/jobs/job.html:21
+msgid "Message"
+msgstr "Poruka"
+
+#: pygeoapi/templates/jobs/job.html:2 pygeoapi/templates/jobs/job.html:10
+msgid "Job status"
+msgstr "Status zadatka"
+
+#: pygeoapi/templates/jobs/job.html:26
+msgid "Parameters"
+msgstr "Parametri"
+
+#: pygeoapi/templates/jobs/job.html:45
+msgid "Started processing"
+msgstr "Započeto procesiranje"
+
+#: pygeoapi/templates/jobs/job.html:47
+msgid "Finished processing"
+msgstr "Završeno procesiranje"
+
+#: pygeoapi/templates/jobs/results/index.html:2
+msgid "Job result"
+msgstr "Rezultat zadatka"
+
+#: pygeoapi/templates/jobs/results/index.html:6
+msgid "Results"
+msgstr "Rezultati"
+
+#: pygeoapi/templates/jobs/results/index.html:10
+msgid "Results of job"
+msgstr "Rezultati zadatka"
+
+#: pygeoapi/templates/processes/index.html:8
+msgid "Processes in this service"
+msgstr "Processi u ovom servisu"
+
+#: pygeoapi/templates/processes/process.html:20
+msgid "Inputs"
+msgstr "Unosi"
+
+#: pygeoapi/templates/processes/process.html:23
+#: pygeoapi/templates/processes/process.html:54
+msgid "Id"
+msgstr "Id"
+
+#: pygeoapi/templates/processes/process.html:24
+#: pygeoapi/templates/processes/process.html:55
+#: pygeoapi/templates/tilematrixsets/index.html:15
+msgid "Title"
+msgstr "Naslov"
+
+#: pygeoapi/templates/processes/process.html:25
+msgid "Data Type"
+msgstr "Tip podatka"
+
+#: pygeoapi/templates/processes/process.html:51
+msgid "Outputs"
+msgstr "Izlazi"
+
+#: pygeoapi/templates/processes/process.html:71
+msgid "Execution modes"
+msgstr "Načini izvršavanja"
+
+#: pygeoapi/templates/processes/process.html:73
+msgid "Synchronous"
+msgstr "Sinhrono"
+
+#: pygeoapi/templates/processes/process.html:74
+msgid "Asynchronous"
+msgstr "Asinhrono"
+
+#: 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 "Prostorno-vremenski katalog resursa"
+
+#: pygeoapi/templates/stac/catalog.html:19
+msgid "Last modified"
+msgstr "Zadnja izmjena"
+
+#: pygeoapi/templates/stac/collection.html:9
+msgid "STAC Version"
+msgstr "STAC verzija"
+
+#: pygeoapi/templates/stac/collection_base.html:28
+#: pygeoapi/templates/stac/item.html:28
+msgid "Assets"
+msgstr "Resursi"
+
+#: pygeoapi/templates/stac/collection_base.html:32
+#: pygeoapi/templates/stac/item.html:32
+msgid "URL"
+msgstr "URL"
+
+#: pygeoapi/templates/stac/collection_base.html:33
+#: pygeoapi/templates/stac/item.html:33
+msgid "Last Modified"
+msgstr "Zadnja izmjena"
+
+#: pygeoapi/templates/stac/collection_base.html:64
+#: pygeoapi/templates/stac/item.html:64
+msgid "id"
+msgstr "id"
+
+#: pygeoapi/templates/stac/collection_base.html:68
+msgid "description"
+msgstr "opis"
+
+#: pygeoapi/templates/stac/collection_base.html:72
+msgid "extent"
+msgstr "extent"
+
+#: pygeoapi/templates/stac/collection_base.html:77
+msgid "cube:dimensions"
+msgstr "kocka:dimenzije"
+
+#: pygeoapi/templates/stac/collection_base.html:83
+msgid "cube:variables"
+msgstr "kocka:varijable"
+
+#: pygeoapi/templates/stac/item.html:19
+msgid "Item"
+msgstr "Stavka"
+
+#: pygeoapi/templates/tilematrixsets/index.html:2
+#: pygeoapi/templates/tilematrixsets/index.html:4
+msgid "TileMatrixSets"
+msgstr "Matrica skupova tile-ova"
+
+#: pygeoapi/templates/tilematrixsets/index.html:9
+msgid "Tile matrix sets available in this service"
+msgstr "Matrice skupova tile-ova dostupne u ovom servisu"
+
+#: pygeoapi/templates/tilematrixsets/tilematrixset.html:2
+#: pygeoapi/templates/tilematrixsets/tilematrixset.html:4
+msgid "TileMatrixSet"
+msgstr "Matrica skupa tile-ova"
+
+msgid "This document as JSON"
+msgstr ""
+
+msgid "This document as RDF (JSON-LD)"
+msgstr ""
+
+msgid "This document as HTML"
+msgstr ""
+
+msgid "The OpenAPI definition as JSON"
+msgstr ""
+
+msgid "The list of supported tiling schemes as JSON"
+msgstr ""
+
+msgid "The list of supported tiling schemes as HTML"
+msgstr ""
+
+msgid "The landing page of this server as JSON"
+msgstr ""
+
+msgid "The landing page of this server as HTML"
+msgstr ""
+
+msgid "Schema of collection in JSON"
+msgstr ""
+
+msgid "Schema of collection in HTML"
+msgstr ""
+
+msgid "Queryables for this collection as JSON"
+msgstr ""
+
+msgid "Queryables for this collection as HTML"
+msgstr ""
+
+msgid "Items as GeoJSON"
+msgstr ""
+
+msgid "Items as HTML"
+msgstr ""
+
+msgid "Items as RDF (GeoJSON-LD)"
+msgstr ""
+
+msgid "Coverage data"
+msgstr ""
+
+msgid "Coverage data as"
+msgstr ""
+
+msgid "Tiles as HTML"
+msgstr ""
+
+msgid "Tiles as JSON"
+msgstr ""
+
+msgid "query for this collection as HTML"
+msgstr ""
+
+msgid "query for this collection as JSON"
+msgstr ""
+
+msgid "Items (prev)"
+msgstr ""
+
+msgid "Items (next)"
+msgstr ""
+
+msgid "Process description as JSON"
+msgstr ""
+
+msgid "Process description as HTML"
+msgstr ""
+
+msgid "query for this collection as HTML"
+msgstr ""
+
+msgid "Execution for process as JSON"
+msgstr ""
+
+msgid "Jobs list as JSON"
+msgstr ""
+
+msgid "Jobs list as HTML"
+msgstr ""
+
+msgid "Results of job as JSON"
+msgstr ""
+
+msgid "Results of job as HTML"
+msgstr ""
+
+msgid "The job list for the current process"
+msgstr ""
+
+msgid "TileMatrixSet definition in JSON"
+msgstr ""
+
+msgid "Data collections in this service"
+msgstr ""
+
+msgid "Record collections in this service"
+msgstr ""
+
+msgid "Display Schema"
+msgstr ""
+
+msgid "Display Schema of"
+msgstr ""
+
+msgid "Contact"
+msgstr ""
+
+msgid "CRS"
+msgstr ""
+
+msgid "Epoch"
+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 ""
diff --git a/pygeoapi-config.yml b/pygeoapi-config.yml
index a889062e3..b260c76d2 100644
--- a/pygeoapi-config.yml
+++ b/pygeoapi-config.yml
@@ -2,7 +2,7 @@
#
# Authors: Tom Kralidis
#
-# Copyright (c) 2020 Tom Kralidis
+# Copyright (c) 2025 Tom Kralidis
#
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation
@@ -41,7 +41,9 @@ server:
- fr-CA
# cors: true
pretty_print: true
- limit: 10
+ limits:
+ default_items: 20
+ max_items: 50
# templates:
# path: /path/to/Jinja2/templates
# static: /path/to/static/folder # css/js/img
@@ -53,7 +55,8 @@ server:
# connection: /tmp/pygeoapi-process-manager.db
# output_dir: /tmp/
# ogc_schemas_location: /opt/schemas.opengis.net
-
+ admin: false # enable admin api
+
logging:
level: ERROR
#logfile: /tmp/pygeoapi.log
diff --git a/pygeoapi/__init__.py b/pygeoapi/__init__.py
index 0f76addea..802b0cb32 100644
--- a/pygeoapi/__init__.py
+++ b/pygeoapi/__init__.py
@@ -5,6 +5,7 @@
#
# Copyright (c) 2021 Tom Kralidis
# Copyright (c) 2023 Ricardo Garcia Silva
+# Copyright (c) 2025 Angelos Tzotsos
#
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation
@@ -29,7 +30,7 @@
#
# =================================================================
-__version__ = '0.16.dev0'
+__version__ = '0.20.0'
import click
try:
@@ -96,11 +97,9 @@ def serve(ctx, server):
if server == "flask":
from pygeoapi.flask_app import serve as serve_flask
- ctx.forward(serve_flask)
ctx.invoke(serve_flask)
elif server == "starlette":
from pygeoapi.starlette_app import serve as serve_starlette
- ctx.forward(serve_starlette)
ctx.invoke(serve_starlette)
elif server == "django":
from pygeoapi.django_app import main as serve_django
diff --git a/pygeoapi/admin.py b/pygeoapi/admin.py
index ff98b611f..860e18bb0 100644
--- a/pygeoapi/admin.py
+++ b/pygeoapi/admin.py
@@ -3,7 +3,7 @@
# Authors: Tom Kralidis
# Benjamin Webb
#
-# Copyright (c) 2023 Tom Kralidis
+# Copyright (c) 2024 Tom Kralidis
# Copyright (c) 2023 Benjamin Webb
#
# Permission is hereby granted, free of charge, to any person
@@ -32,16 +32,16 @@
from copy import deepcopy
import os
import json
-from jsonpatch import make_patch
-from jsonschema.exceptions import ValidationError
import logging
-from typing import Any, Tuple, Union
+from typing import Tuple
-from pygeoapi.api import API, APIRequest, F_HTML, pre_process
+from dateutil.parser import parse as parse_date
+from jsonpatch import make_patch
+from jsonschema.exceptions import ValidationError
+from pygeoapi.api import API, APIRequest, F_HTML
from pygeoapi.config import get_config, validate_config
from pygeoapi.openapi import get_oas
-# from pygeoapi.openapi import validate_openapi_document
from pygeoapi.util import to_json, render_j2_template, yaml_dump
@@ -128,15 +128,15 @@ def write_config(self, config):
# Preserve env variables
LOGGER.debug('Reading env variables in configuration')
- raw_conf = get_config(raw=True)
- conf = get_config()
+ raw_conf = json.loads(to_json(get_config(raw=True)))
+ conf = json.loads(to_json(get_config()))
patch = make_patch(conf, raw_conf)
LOGGER.debug('Merging env variables')
config = patch.apply(config)
# write pygeoapi configuration
- LOGGER.debug('Writing pygeoapi configutation')
+ LOGGER.debug('Writing pygeoapi configuration')
yaml_dump(config, self.PYGEOAPI_CONFIG)
LOGGER.debug('Finished writing pygeoapi configuration')
@@ -157,467 +157,464 @@ def write_oas(self, config):
yaml_dump(oas, self.PYGEOAPI_OPENAPI)
LOGGER.debug('Finished writing OpenAPI document')
- @pre_process
- def get_config(
- self,
- request: Union[APIRequest, Any]
- ) -> Tuple[dict, int, str]:
- """
- Provide admin configuration document
-
- :param request: request object
-
- :returns: tuple of headers, status code, content
- """
-
- if not request.is_valid():
- return self.get_format_exception(request)
-
- headers = request.get_response_headers()
-
- cfg = get_config(raw=True)
-
- if request.format == F_HTML:
- content = render_j2_template(
- self.config, 'admin/index.html', cfg, request.locale
- )
- else:
- content = to_json(cfg, self.pretty_print)
-
- return headers, 200, content
- @pre_process
- def put_config(
- self,
- request: Union[APIRequest, Any]
- ) -> Tuple[dict, int, str]:
- """
- Update complete pygeoapi configuration
+def get_config_(
+ admin: Admin,
+ request: APIRequest,
+) -> Tuple[dict, int, str]:
+ """
+ Provide admin configuration document
- :param request: request object
+ :param request: request object
- :returns: tuple of headers, status code, content
- """
+ :returns: tuple of headers, status code, content
+ """
- LOGGER.debug('Updating configuration')
-
- if not request.is_valid():
- return self.get_format_exception(request)
-
- headers = request.get_response_headers()
-
- data = request.data
- if not data:
- msg = 'missing request data'
- return self.get_exception(
- 400, headers, request.format, 'MissingParameterValue', msg
- )
-
- try:
- # Parse data
- data = data.decode()
- except (UnicodeDecodeError, AttributeError):
- pass
-
- try:
- data = json.loads(data)
- except (json.decoder.JSONDecodeError, TypeError) as err:
- # Input is not valid JSON
- LOGGER.error(err)
- msg = 'invalid request data'
- return self.get_exception(
- 400, headers, request.format, 'InvalidParameterValue', msg
- )
-
- LOGGER.debug('Updating configuration')
- try:
- self.validate(data)
- except ValidationError as err:
- LOGGER.error(err)
- msg = 'Schema validation error'
- return self.get_exception(
- 400, headers, request.format, 'ValidationError', msg
- )
-
- self.write(data)
-
- return headers, 204, {}
-
- @pre_process
- def patch_config(
- self, request: Union[APIRequest, Any]
- ) -> Tuple[dict, int, str]:
- """
- Update partial pygeoapi configuration
+ headers = request.get_response_headers()
+
+ cfg = get_config(raw=True)
+
+ if request.format == F_HTML:
+ content = render_j2_template(
+ admin.tpl_config, admin.config['server']['templates'],
+ 'admin/index.html', cfg, request.locale
+ )
+ else:
+ content = to_json(cfg, admin.pretty_print)
+
+ return headers, 200, content
+
+
+def put_config(
+ admin: Admin,
+ request: APIRequest,
+) -> Tuple[dict, int, str]:
+ """
+ Update complete pygeoapi configuration
+
+ :param request: request object
+
+ :returns: tuple of headers, status code, content
+ """
+
+ LOGGER.debug('Updating configuration')
+
+ headers = request.get_response_headers()
+
+ data = request.data
+ if not data:
+ msg = 'missing request data'
+ return admin.get_exception(
+ 400, headers, request.format, 'MissingParameterValue', msg
+ )
+
+ try:
+ # Parse data
+ data = data.decode()
+ except (UnicodeDecodeError, AttributeError):
+ pass
- :param request: request object
- :param resource_id: resource identifier
+ try:
+ data = json.loads(data)
+ for key, value in data.get('resources', {}).items():
+ temporal_extents_str2datetime(value.get('extents', {}))
+ except (json.decoder.JSONDecodeError, TypeError) as err:
+ # Input is not valid JSON
+ LOGGER.error(err)
+ msg = 'invalid request data'
+ return admin.get_exception(
+ 400, headers, request.format, 'InvalidParameterValue', msg
+ )
- :returns: tuple of headers, status code, content
- """
+ LOGGER.debug('Updating configuration')
+ try:
+ admin.validate(data)
+ except ValidationError as err:
+ LOGGER.error(err)
+ msg = 'Schema validation error'
+ return admin.get_exception(
+ 400, headers, request.format, 'ValidationError', msg
+ )
- if not request.is_valid():
- return self.get_format_exception(request)
-
- config = deepcopy(self.config)
- headers = request.get_response_headers()
-
- data = request.data
- if not data:
- msg = 'missing request data'
- return self.get_exception(
- 400, headers, request.format, 'MissingParameterValue', msg
- )
-
- try:
- # Parse data
- data = data.decode()
- except (UnicodeDecodeError, AttributeError):
- pass
-
- try:
- data = json.loads(data)
- except (json.decoder.JSONDecodeError, TypeError) as err:
- # Input is not valid JSON
- LOGGER.error(err)
- msg = 'invalid request data'
- return self.get_exception(
- 400, headers, request.format, 'InvalidParameterValue', msg
- )
-
- LOGGER.debug('Merging configuration')
- config = self.merge(config, data)
-
- try:
- self.validate(config)
- except ValidationError as err:
- LOGGER.error(err)
- msg = 'Schema validation error'
- return self.get_exception(
- 400, headers, request.format, 'ValidationError', msg
- )
-
- self.write(config)
-
- content = to_json(config, self.pretty_print)
-
- return headers, 204, content
-
- @pre_process
- def get_resources(
- self, request: Union[APIRequest, Any]
- ) -> Tuple[dict, int, str]:
- """
- Provide admin document
+ admin.write(data)
- :param request: request object
+ return headers, 204, {}
- :returns: tuple of headers, status code, content
- """
- if not request.is_valid():
- return self.get_format_exception(request)
+def patch_config(
+ admin: Admin, request: APIRequest,
+) -> Tuple[dict, int, str]:
+ """
+ Update partial pygeoapi configuration
- headers = request.get_response_headers()
+ :param request: request object
+ :param resource_id: resource identifier
- cfg = get_config(raw=True)
+ :returns: tuple of headers, status code, content
+ """
- if request.format == F_HTML:
- content = render_j2_template(
- self.config,
- 'admin/index.html',
- cfg['resources'],
- request.locale,
- )
- else:
- content = to_json(cfg['resources'], self.pretty_print)
-
- return headers, 200, content
-
- @pre_process
- def post_resource(
- self, request: Union[APIRequest, Any]
- ) -> Tuple[dict, int, str]:
- """
- Add resource configuration
+ config = deepcopy(admin.config)
+ headers = request.get_response_headers()
+
+ data = request.data
+ if not data:
+ msg = 'missing request data'
+ return admin.get_exception(
+ 400, headers, request.format, 'MissingParameterValue', msg
+ )
+
+ try:
+ # Parse data
+ data = data.decode()
+ except (UnicodeDecodeError, AttributeError):
+ pass
+
+ try:
+ data = json.loads(data)
+ for key, value in data.get('resources', {}).items():
+ temporal_extents_str2datetime(value.get('extents', {}))
+ except (json.decoder.JSONDecodeError, TypeError) as err:
+ # Input is not valid JSON
+ LOGGER.error(err)
+ msg = 'invalid request data'
+ return admin.get_exception(
+ 400, headers, request.format, 'InvalidParameterValue', msg
+ )
+
+ LOGGER.debug('Merging configuration')
+ config = admin.merge(config, data)
+
+ try:
+ admin.validate(config)
+ except ValidationError as err:
+ LOGGER.error(err)
+ msg = 'Schema validation error'
+ return admin.get_exception(
+ 400, headers, request.format, 'ValidationError', msg
+ )
+
+ admin.write(config)
+
+ content = to_json(config, admin.pretty_print)
+
+ return headers, 204, content
+
+
+def get_resources(
+ admin: Admin, request: APIRequest,
+) -> Tuple[dict, int, str]:
+ """
+ Provide admin document
+
+ :param request: request object
+
+ :returns: tuple of headers, status code, content
+ """
+
+ headers = request.get_response_headers()
+
+ cfg = get_config(raw=True)
+
+ if request.format == F_HTML:
+ content = render_j2_template(
+ admin.tpl_config, admin.config['server']['templates'],
+ 'admin/index.html', cfg['resources'], request.locale
+ )
+ else:
+ content = to_json(cfg['resources'], admin.pretty_print)
+
+ return headers, 200, content
+
+
+def post_resource(
+ admin: Admin, request: APIRequest,
+) -> Tuple[dict, int, str]:
+ """
+ Add resource configuration
+
+ :param request: request object
+
+ :returns: tuple of headers, status code, content
+ """
+
+ config = deepcopy(admin.config)
+ headers = request.get_response_headers()
+
+ data = request.data
+ if not data:
+ msg = 'missing request data'
+ return admin.get_exception(
+ 400, headers, request.format, 'MissingParameterValue', msg
+ )
+
+ try:
+ # Parse data
+ data = data.decode()
+ except (UnicodeDecodeError, AttributeError):
+ pass
+
+ try:
+ data = json.loads(data)
+ res = list(data.keys())[0]
+ temporal_extents_str2datetime(data[res].get('extents', {}))
+ except (json.decoder.JSONDecodeError, TypeError) as err:
+ # Input is not valid JSON
+ LOGGER.error(err)
+ msg = 'invalid request data'
+ return admin.get_exception(
+ 400, headers, request.format, 'InvalidParameterValue', msg
+ )
+
+ resource_id = next(iter(data.keys()))
+
+ if config['resources'].get(resource_id) is not None:
+ # Resource already exists
+ msg = f'Resource exists: {resource_id}'
+ LOGGER.error(msg)
+ return admin.get_exception(
+ 400, headers, request.format, 'NoApplicableCode', msg
+ )
+
+ LOGGER.debug(f'Adding resource: {resource_id}')
+ config['resources'].update(data)
+
+ try:
+ admin.validate(config)
+ except ValidationError as err:
+ LOGGER.error(err)
+ msg = 'Schema validation error'
+ return admin.get_exception(
+ 400, headers, request.format, 'ValidationError', msg
+ )
- :param request: request object
+ admin.write(config)
- :returns: tuple of headers, status code, content
- """
+ content = f'Location: /{request.path_info}/{resource_id}'
+ LOGGER.debug(f'Success at {content}')
+
+ return headers, 201, content
- if not request.is_valid():
- return self.get_format_exception(request)
-
- config = deepcopy(self.config)
- headers = request.get_response_headers()
-
- data = request.data
- if not data:
- msg = 'missing request data'
- return self.get_exception(
- 400, headers, request.format, 'MissingParameterValue', msg
- )
-
- try:
- # Parse data
- data = data.decode()
- except (UnicodeDecodeError, AttributeError):
- pass
-
- try:
- data = json.loads(data)
- except (json.decoder.JSONDecodeError, TypeError) as err:
- # Input is not valid JSON
- LOGGER.error(err)
- msg = 'invalid request data'
- return self.get_exception(
- 400, headers, request.format, 'InvalidParameterValue', msg
- )
-
- resource_id = next(iter(data.keys()))
-
- if config['resources'].get(resource_id) is not None:
- # Resource already exists
- msg = f'Resource exists: {resource_id}'
- LOGGER.error(msg)
- return self.get_exception(
- 400, headers, request.format, 'NoApplicableCode', msg
- )
-
- LOGGER.debug(f'Adding resource: {resource_id}')
- config['resources'].update(data)
-
- try:
- self.validate(config)
- except ValidationError as err:
- LOGGER.error(err)
- msg = 'Schema validation error'
- return self.get_exception(
- 400, headers, request.format, 'ValidationError', msg
- )
-
- self.write(config)
-
- content = f'Location: /{request.path_info}/{resource_id}'
- LOGGER.debug(f'Success at {content}')
-
- return headers, 201, content
-
- @pre_process
- def get_resource(
- self, request: Union[APIRequest, Any], resource_id: str
- ) -> Tuple[dict, int, str]:
- """
- Get resource configuration
-
- :param request: request object
- :param resource_id:
-
- :returns: tuple of headers, status code, content
- """
-
- if not request.is_valid():
- return self.get_format_exception(request)
-
- headers = request.get_response_headers()
-
- cfg = get_config(raw=True)
-
- try:
- resource = cfg['resources'][resource_id]
- except KeyError:
- msg = f'Resource not found: {resource_id}'
- return self.get_exception(
- 400, headers, request.format, 'ResourceNotFound', msg
- )
-
- if request.format == F_HTML:
- content = render_j2_template(
- self.config, 'admin/index.html', resource, request.locale
- )
- else:
- content = to_json(resource, self.pretty_print)
-
- return headers, 200, content
-
- @pre_process
- def delete_resource(
- self, request: Union[APIRequest, Any], resource_id: str
- ) -> Tuple[dict, int, str]:
- """
- Delete resource configuration
-
- :param request: request object
- :param resource_id: resource identifier
-
- :returns: tuple of headers, status code, content
- """
-
- if not request.is_valid():
- return self.get_format_exception(request)
-
- config = deepcopy(self.config)
- headers = request.get_response_headers()
-
- try:
- LOGGER.debug(f'Removing resource configuration for: {resource_id}')
- config['resources'].pop(resource_id)
- except KeyError:
- msg = f'Resource not found: {resource_id}'
- return self.get_exception(
- 400, headers, request.format, 'ResourceNotFound', msg
- )
-
- LOGGER.debug('Resource removed, validating and saving configuration')
- try:
- self.validate(config)
- except ValidationError as err:
- LOGGER.error(err)
- msg = 'Schema validation error'
- return self.get_exception(
- 400, headers, request.format, 'ValidationError', msg
- )
-
- self.write(config)
-
- return headers, 204, {}
-
- @pre_process
- def put_resource(
- self,
- request: Union[APIRequest, Any],
- resource_id: str,
- ) -> Tuple[dict, int, str]:
- """
- Update complete resource configuration
-
- :param request: request object
- :param resource_id: resource identifier
-
- :returns: tuple of headers, status code, content
- """
-
- if not request.is_valid():
- return self.get_format_exception(request)
-
- config = deepcopy(self.config)
- headers = request.get_response_headers()
-
- try:
- LOGGER.debug('Verifying resource exists')
- config['resources'][resource_id]
- except KeyError:
- msg = f'Resource not found: {resource_id}'
- return self.get_exception(
- 400, headers, request.format, 'ResourceNotFound', msg
- )
-
- data = request.data
- if not data:
- msg = 'missing request data'
- return self.get_exception(
- 400, headers, request.format, 'MissingParameterValue', msg
- )
-
- try:
- # Parse data
- data = data.decode()
- except (UnicodeDecodeError, AttributeError):
- pass
-
- try:
- data = json.loads(data)
- except (json.decoder.JSONDecodeError, TypeError) as err:
- # Input is not valid JSON
- LOGGER.error(err)
- msg = 'invalid request data'
- return self.get_exception(
- 400, headers, request.format, 'InvalidParameterValue', msg
- )
-
- LOGGER.debug(f'Updating resource: {resource_id}')
- config['resources'].update({resource_id: data})
- try:
- self.validate(config)
- except ValidationError as err:
- LOGGER.error(err)
- msg = 'Schema validation error'
- return self.get_exception(
- 400, headers, request.format, 'ValidationError', msg
- )
-
- self.write(config)
-
- return headers, 204, {}
-
- @pre_process
- def patch_resource(
- self, request: Union[APIRequest, Any], resource_id: str
- ) -> Tuple[dict, int, str]:
- """
- Update partial resource configuration
-
- :param request: request object
- :param resource_id: resource identifier
-
- :returns: tuple of headers, status code, content
- """
- if not request.is_valid():
- return self.get_format_exception(request)
-
- config = deepcopy(self.config)
- headers = request.get_response_headers()
-
- try:
- LOGGER.debug('Verifying resource exists')
- resource = config['resources'][resource_id]
- except KeyError:
- msg = f'Resource not found: {resource_id}'
- return self.get_exception(
- 400, headers, request.format, 'ResourceNotFound', msg
- )
-
- data = request.data
- if not data:
- msg = 'missing request data'
- return self.get_exception(
- 400, headers, request.format, 'MissingParameterValue', msg
- )
-
- try:
- # Parse data
- data = data.decode()
- except (UnicodeDecodeError, AttributeError):
- pass
-
- try:
- data = json.loads(data)
- except (json.decoder.JSONDecodeError, TypeError) as err:
- # Input is not valid JSON
- LOGGER.error(err)
- msg = 'invalid request data'
- return self.get_exception(
- 400, headers, request.format, 'InvalidParameterValue', msg
- )
-
- LOGGER.debug('Merging resource block')
- data = self.merge(resource, data)
- LOGGER.debug('Updating resource')
- config['resources'].update({resource_id: data})
-
- try:
- self.validate(config)
- except ValidationError as err:
- LOGGER.error(err)
- msg = 'Schema validation error'
- return self.get_exception(
- 400, headers, request.format, 'ValidationError', msg
- )
-
- self.write(config)
-
- content = to_json(resource, self.pretty_print)
-
- return headers, 204, content
+def get_resource(
+ admin: Admin, request: APIRequest, resource_id: str
+) -> Tuple[dict, int, str]:
+ """
+ Get resource configuration
+
+ :param request: request object
+ :param resource_id:
+
+ :returns: tuple of headers, status code, content
+ """
+
+ headers = request.get_response_headers()
+
+ cfg = get_config(raw=True)
+
+ try:
+ resource = cfg['resources'][resource_id]
+ except KeyError:
+ msg = f'Resource not found: {resource_id}'
+ return admin.get_exception(
+ 400, headers, request.format, 'ResourceNotFound', msg
+ )
+
+ if request.format == F_HTML:
+ content = render_j2_template(
+ admin.tpl_config, admin.config['server']['templates'],
+ 'admin/index.html', resource, request.locale
+ )
+ else:
+ content = to_json(resource, admin.pretty_print)
+
+ return headers, 200, content
+
+
+def delete_resource(
+ admin: Admin, request: APIRequest, resource_id: str
+) -> Tuple[dict, int, str]:
+ """
+ Delete resource configuration
+
+ :param request: request object
+ :param resource_id: resource identifier
+
+ :returns: tuple of headers, status code, content
+ """
+
+ config = deepcopy(admin.config)
+ headers = request.get_response_headers()
+
+ try:
+ LOGGER.debug(f'Removing resource configuration for: {resource_id}')
+ config['resources'].pop(resource_id)
+ except KeyError:
+ msg = f'Resource not found: {resource_id}'
+ return admin.get_exception(
+ 400, headers, request.format, 'ResourceNotFound', msg
+ )
+
+ LOGGER.debug('Resource removed, validating and saving configuration')
+ try:
+ admin.validate(config)
+ except ValidationError as err:
+ LOGGER.error(err)
+ msg = 'Schema validation error'
+ return admin.get_exception(
+ 400, headers, request.format, 'ValidationError', msg
+ )
+
+ admin.write(config)
+
+ return headers, 204, {}
+
+
+def put_resource(
+ admin: Admin,
+ request: APIRequest,
+ resource_id: str,
+) -> Tuple[dict, int, str]:
+ """
+ Update complete resource configuration
+
+ :param request: request object
+ :param resource_id: resource identifier
+
+ :returns: tuple of headers, status code, content
+ """
+
+ config = deepcopy(admin.config)
+ headers = request.get_response_headers()
+
+ try:
+ LOGGER.debug('Verifying resource exists')
+ config['resources'][resource_id]
+ except KeyError:
+ msg = f'Resource not found: {resource_id}'
+ return admin.get_exception(
+ 400, headers, request.format, 'ResourceNotFound', msg
+ )
+
+ data = request.data
+ if not data:
+ msg = 'missing request data'
+ return admin.get_exception(
+ 400, headers, request.format, 'MissingParameterValue', msg
+ )
+
+ try:
+ # Parse data
+ data = data.decode()
+ except (UnicodeDecodeError, AttributeError):
+ pass
+
+ try:
+ data = json.loads(data)
+ temporal_extents_str2datetime(data.get('extents', {}))
+ except (json.decoder.JSONDecodeError, TypeError) as err:
+ # Input is not valid JSON
+ LOGGER.error(err)
+ msg = 'invalid request data'
+ return admin.get_exception(
+ 400, headers, request.format, 'InvalidParameterValue', msg
+ )
+
+ LOGGER.debug(f'Updating resource: {resource_id}')
+ config['resources'].update({resource_id: data})
+ try:
+ admin.validate(config)
+ except ValidationError as err:
+ LOGGER.error(err)
+ msg = 'Schema validation error'
+ return admin.get_exception(
+ 400, headers, request.format, 'ValidationError', msg
+ )
+
+ admin.write(config)
+
+ return headers, 204, {}
+
+
+def patch_resource(
+ admin: Admin, request: APIRequest, resource_id: str
+) -> Tuple[dict, int, str]:
+ """
+ Update partial resource configuration
+
+ :param request: request object
+ :param resource_id: resource identifier
+
+ :returns: tuple of headers, status code, content
+ """
+
+ config = deepcopy(admin.config)
+ headers = request.get_response_headers()
+
+ try:
+ LOGGER.debug('Verifying resource exists')
+ resource = config['resources'][resource_id]
+ except KeyError:
+ msg = f'Resource not found: {resource_id}'
+ return admin.get_exception(
+ 400, headers, request.format, 'ResourceNotFound', msg
+ )
+
+ data = request.data
+ if not data:
+ msg = 'missing request data'
+ return admin.get_exception(
+ 400, headers, request.format, 'MissingParameterValue', msg
+ )
+
+ try:
+ # Parse data
+ data = data.decode()
+ except (UnicodeDecodeError, AttributeError):
+ pass
+
+ try:
+ data = json.loads(data)
+ temporal_extents_str2datetime(data.get('extents', {}))
+ except (json.decoder.JSONDecodeError, TypeError) as err:
+ # Input is not valid JSON
+ LOGGER.error(err)
+ msg = 'invalid request data'
+ return admin.get_exception(
+ 400, headers, request.format, 'InvalidParameterValue', msg
+ )
+
+ LOGGER.debug('Merging resource block')
+ data = admin.merge(resource, data)
+ LOGGER.debug('Updating resource')
+ config['resources'].update({resource_id: data})
+
+ try:
+ admin.validate(config)
+ except ValidationError as err:
+ LOGGER.error(err)
+ msg = 'Schema validation error'
+ return admin.get_exception(
+ 400, headers, request.format, 'ValidationError', msg
+ )
+
+ admin.write(config)
+
+ content = to_json(resource, admin.pretty_print)
+
+ return headers, 204, content
+
+
+def temporal_extents_str2datetime(extents: dict) -> None:
+ """
+ Helper function to coerce datetime strings into datetime objects
+
+ :extents: `dict` of pygeoapi resource extents object
+
+ :returns: `None` (changes made directly)
+ """
+
+ try:
+ extents['temporal']['begin'] = parse_date(extents['temporal']['begin'])
+ extents['temporal']['end'] = parse_date(extents['temporal']['end'])
+ except (KeyError, TypeError):
+ LOGGER.debug('No temporal extents found')
diff --git a/pygeoapi/api.py b/pygeoapi/api.py
deleted file mode 100644
index fe14409d7..000000000
--- a/pygeoapi/api.py
+++ /dev/null
@@ -1,4305 +0,0 @@
-# =================================================================
-#
-# Authors: Tom Kralidis
-# Francesco Bartoli
-# Sander Schaminee
-# John A Stevenson
-# Colin Blackburn
-# Ricardo Garcia Silva
-#
-# Copyright (c) 2023 Tom Kralidis
-# Copyright (c) 2022 Francesco Bartoli
-# Copyright (c) 2022 John A Stevenson and Colin Blackburn
-# Copyright (c) 2023 Ricardo Garcia Silva
-#
-# 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.
-#
-# =================================================================
-""" Root level code of pygeoapi, parsing content provided by web framework.
-Returns content from plugins and sets responses.
-"""
-
-import asyncio
-from collections import OrderedDict
-from copy import deepcopy
-from datetime import datetime, timezone
-from functools import partial
-from gzip import compress
-from http import HTTPStatus
-import json
-import logging
-import re
-from typing import Any, Tuple, Union, Optional
-import urllib.parse
-
-from dateutil.parser import parse as dateparse
-from pygeofilter.parsers.ecql import parse as parse_ecql_text
-from pygeofilter.parsers.cql_json import parse as parse_cql_json
-from pyproj.exceptions import CRSError
-import pytz
-from shapely.errors import WKTReadingError
-from shapely.wkt import loads as shapely_loads
-
-from pygeoapi import __version__, l10n
-from pygeoapi.formatter.base import FormatterSerializationError
-from pygeoapi.linked_data import (geojson2jsonld, jsonldify,
- jsonldify_collection)
-from pygeoapi.log import setup_logger
-from pygeoapi.process.base import (
- JobNotFoundError,
- JobResultNotFoundError,
- ProcessorExecuteError,
-)
-from pygeoapi.process.manager.base import get_manager
-from pygeoapi.plugin import load_plugin, PLUGINS
-from pygeoapi.provider.base import (
- ProviderGenericError, ProviderConnectionError, ProviderNotFoundError,
- ProviderTypeError)
-from pygeoapi.models.provider.base import (TilesMetadataFormat,
- TileMatrixSetEnum)
-
-from pygeoapi.models.cql import CQLModel
-from pygeoapi.util import (dategetter, RequestedProcessExecutionMode,
- DATETIME_FORMAT, UrlPrefetcher,
- filter_dict_by_key_value, get_provider_by_type,
- get_provider_default, get_typed_value, JobStatus,
- json_serial, render_j2_template, str2bool,
- TEMPLATES, to_json, get_api_rules, get_base_url,
- get_crs_from_uri, get_supported_crs_list,
- modify_pygeofilter, CrsTransformSpec,
- transform_bbox)
-
-LOGGER = logging.getLogger(__name__)
-
-#: Return headers for requests (e.g:X-Powered-By)
-HEADERS = {
- 'Content-Type': 'application/json',
- 'X-Powered-By': f'pygeoapi {__version__}'
-}
-
-CHARSET = ['utf-8']
-F_JSON = 'json'
-F_HTML = 'html'
-F_JSONLD = 'jsonld'
-F_GZIP = 'gzip'
-F_PNG = 'png'
-F_MVT = 'mvt'
-F_NETCDF = 'NetCDF'
-
-#: Formats allowed for ?f= requests (order matters for complex MIME types)
-FORMAT_TYPES = OrderedDict((
- (F_HTML, 'text/html'),
- (F_JSONLD, 'application/ld+json'),
- (F_JSON, 'application/json'),
- (F_PNG, 'image/png'),
- (F_MVT, 'application/vnd.mapbox-vector-tile'),
- (F_NETCDF, 'application/x-netcdf'),
-))
-
-#: Locale used for system responses (e.g. exceptions)
-SYSTEM_LOCALE = l10n.Locale('en', 'US')
-
-CONFORMANCE = {
- 'common': [
- 'http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/core',
- 'http://www.opengis.net/spec/ogcapi-common-2/1.0/conf/collections',
- 'http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/landing-page',
- 'http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/json',
- 'http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/html',
- 'http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/oas30'
- ],
- 'feature': [
- 'http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/core',
- 'http://www.opengis.net/spec/ogcapi-features-1/1.0/req/oas30',
- 'http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/html',
- 'http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/geojson',
- 'http://www.opengis.net/spec/ogcapi-features-2/1.0/conf/crs',
- 'http://www.opengis.net/spec/ogcapi-features-3/1.0/conf/queryables',
- 'http://www.opengis.net/spec/ogcapi-features-3/1.0/conf/queryables-query-parameters', # noqa
- 'http://www.opengis.net/spec/ogcapi-features-4/1.0/conf/create-replace-delete' # noqa
- ],
- 'coverage': [
- 'http://www.opengis.net/spec/ogcapi-coverages-1/1.0/conf/core',
- 'http://www.opengis.net/spec/ogcapi-coverages-1/1.0/conf/oas30',
- 'http://www.opengis.net/spec/ogcapi-coverages-1/1.0/conf/html',
- 'http://www.opengis.net/spec/ogcapi-coverages-1/1.0/conf/geodata-coverage', # noqa
- 'http://www.opengis.net/spec/ogcapi-coverages-1/1.0/conf/coverage-subset', # noqa
- 'http://www.opengis.net/spec/ogcapi-coverages-1/1.0/conf/coverage-rangesubset', # noqa
- 'http://www.opengis.net/spec/ogcapi-coverages-1/1.0/conf/coverage-bbox', # noqa
- 'http://www.opengis.net/spec/ogcapi-coverages-1/1.0/conf/coverage-datetime' # noqa
- ],
- 'map': [
- 'http://www.opengis.net/spec/ogcapi-maps-1/1.0/conf/core'
- ],
- 'tile': [
- 'http://www.opengis.net/spec/ogcapi-tiles-1/1.0/conf/core',
- 'http://www.opengis.net/spec/ogcapi-tiles-1/1.0/conf/mvt',
- 'http://www.opengis.net/spec/ogcapi-tiles-1/1.0/conf/tileset',
- 'http://www.opengis.net/spec/ogcapi-tiles-1/1.0/conf/tilesets-list',
- 'http://www.opengis.net/spec/ogcapi-tiles-1/1.0/conf/oas30',
- 'http://www.opengis.net/spec/ogcapi-tiles-1/1.0/conf/geodata-tilesets'
- ],
- 'record': [
- 'http://www.opengis.net/spec/ogcapi-records-1/1.0/conf/core',
- 'http://www.opengis.net/spec/ogcapi-records-1/1.0/conf/sorting',
- 'http://www.opengis.net/spec/ogcapi-records-1/1.0/conf/opensearch',
- 'http://www.opengis.net/spec/ogcapi-records-1/1.0/conf/json',
- 'http://www.opengis.net/spec/ogcapi-records-1/1.0/conf/html'
- ],
- 'process': [
- 'http://www.opengis.net/spec/ogcapi-processes-1/1.0/conf/ogc-process-description', # noqa
- 'http://www.opengis.net/spec/ogcapi-processes-1/1.0/conf/core',
- 'http://www.opengis.net/spec/ogcapi-processes-1/1.0/conf/json',
- 'http://www.opengis.net/spec/ogcapi-processes-1/1.0/conf/oas30'
- ],
- 'edr': [
- 'http://www.opengis.net/spec/ogcapi-edr-1/1.0/conf/core'
- ]
-}
-
-OGC_RELTYPES_BASE = 'http://www.opengis.net/def/rel/ogc/1.0'
-
-DEFAULT_CRS_LIST = [
- 'http://www.opengis.net/def/crs/OGC/1.3/CRS84',
- 'http://www.opengis.net/def/crs/OGC/1.3/CRS84h',
-]
-
-DEFAULT_CRS = 'http://www.opengis.net/def/crs/OGC/1.3/CRS84'
-DEFAULT_STORAGE_CRS = DEFAULT_CRS
-
-
-def pre_process(func):
- """
- Decorator that transforms an incoming Request instance specific to the
- web framework (i.e. Flask, Starlette or Django) into a generic
- :class:`APIRequest` instance.
-
- :param func: decorated function
-
- :returns: `func`
- """
-
- def inner(*args):
- cls, req_in = args[:2]
- req_out = APIRequest.with_data(req_in, getattr(cls, 'locales', set()))
- if len(args) > 2:
- return func(cls, req_out, *args[2:])
- else:
- return func(cls, req_out)
-
- return inner
-
-
-def gzip(func):
- """
- Decorator that compresses the content of an outgoing API result
- instance if the Content-Encoding response header was set to gzip.
-
- :param func: decorated function
-
- :returns: `func`
- """
-
- def inner(*args, **kwargs):
- headers, status, content = func(*args, **kwargs)
- charset = CHARSET[0]
- if F_GZIP in headers.get('Content-Encoding', []):
- try:
- if isinstance(content, bytes):
- # bytes means Content-Type needs to be set upstream
- content = compress(content)
- else:
- headers['Content-Type'] = \
- f"{headers['Content-Type']}; charset={charset}"
- content = compress(content.encode(charset))
- except TypeError as err:
- headers.pop('Content-Encoding')
- LOGGER.error(f'Error in compression: {err}')
-
- return headers, status, content
-
- return inner
-
-
-class APIRequest:
- """
- Transforms an incoming server-specific Request into an object
- with some generic helper methods and properties.
-
- .. note:: Typically, this instance is created automatically by the
- :func:`pre_process` decorator. **Every** API method that has
- been routed to a REST endpoint should be decorated by the
- :func:`pre_process` function.
- Therefore, **all** routed API methods should at least have 1
- argument that holds the (transformed) request.
-
- The following example API method will:
-
- - transform the incoming Flask/Starlette/Django `Request` into an
- `APIRequest`using the :func:`pre_process` decorator;
- - call :meth:`is_valid` to check if the incoming request was valid, i.e.
- that the user requested a valid output format or no format at all
- (which means the default format);
- - call :meth:`API.get_format_exception` if the requested format was
- invalid;
- - create a `dict` with the appropriate `Content-Type` header for the
- requested format and a `Content-Language` header if any specific language
- was requested.
-
- .. code-block:: python
-
- @pre_process
- def example_method(self, request: Union[APIRequest, Any], custom_arg):
- if not request.is_valid():
- return self.get_format_exception(request)
-
- headers = request.get_response_headers()
-
- # generate response_body here
-
- return headers, HTTPStatus.OK, response_body
-
-
- The following example API method is similar as the one above, but will also
- allow the user to request a non-standard format (e.g. ``f=xml``).
- If `xml` was requested, we set the `Content-Type` ourselves. For the
- standard formats, the `APIRequest` object sets the `Content-Type`.
-
- .. code-block:: python
-
- @pre_process
- def example_method(self, request: Union[APIRequest, Any], custom_arg):
- if not request.is_valid(['xml']):
- return self.get_format_exception(request)
-
- content_type = 'application/xml' if request.format == 'xml' else None
- headers = request.get_response_headers(content_type)
-
- # generate response_body here
-
- return headers, HTTPStatus.OK, response_body
-
- Note that you don't *have* to call :meth:`is_valid`, but that you can also
- perform a custom check on the requested output format by looking at the
- :attr:`format` property.
- Other query parameters are available through the :attr:`params` property as
- a `dict`. The request body is available through the :attr:`data` property.
-
- .. note:: If the request data (body) is important, **always** create a
- new `APIRequest` instance using the :meth:`with_data` factory
- method.
- The :func:`pre_process` decorator will use this automatically.
-
- :param request: The web platform specific Request instance.
- :param supported_locales: List or set of supported Locale instances.
- """
- def __init__(self, request, supported_locales):
- # Set default request data
- self._data = b''
-
- # Copy request query parameters
- self._args = self._get_params(request)
-
- # Get path info
- if hasattr(request, 'scope'):
- self._path_info = request.scope['path'].strip('/')
- elif hasattr(request.headers, 'environ'):
- self._path_info = request.headers.environ['PATH_INFO'].strip('/')
- elif hasattr(request, 'path_info'):
- self._path_info = request.path_info
-
- # Extract locale from params or headers
- self._raw_locale, self._locale = self._get_locale(request.headers,
- supported_locales)
-
- # Determine format
- self._format = self._get_format(request.headers)
-
- # Get received headers
- self._headers = self.get_request_headers(request.headers)
-
- @classmethod
- def with_data(cls, request, supported_locales) -> 'APIRequest':
- """
- Factory class method to create an `APIRequest` instance with data.
-
- If the request body is required, an `APIRequest` should always be
- instantiated using this class method. The reason for this is, that the
- Starlette request body needs to be awaited (async), which cannot be
- achieved in the :meth:`__init__` method of the `APIRequest`.
- However, `APIRequest` can still be initialized using :meth:`__init__`,
- but then the :attr:`data` property value will always be empty.
-
- :param request: The web platform specific Request instance.
- :param supported_locales: List or set of supported Locale instances.
- :returns: An `APIRequest` instance with data.
- """
-
- api_req = cls(request, supported_locales)
- if hasattr(request, 'data'):
- # Set data from Flask request
- api_req._data = request.data
- elif hasattr(request, 'body'):
- if 'django' in str(request.__class__):
- # Set data from Django request
- api_req._data = request.body
- else:
- # Set data from Starlette request after async
- # coroutine completion
- # TODO:
- # this now blocks, but once Flask v2 with async support
- # has been implemented, with_data() can become async too
- loop = asyncio.get_event_loop()
- api_req._data = asyncio.run_coroutine_threadsafe(
- request.body(), loop).result(1)
- return api_req
-
- @staticmethod
- def _get_params(request):
- """
- Extracts the query parameters from the `Request` object.
-
- :param request: A Flask or Starlette Request instance
- :returns: `ImmutableMultiDict` or empty `dict`
- """
-
- if hasattr(request, 'args'):
- # Return ImmutableMultiDict from Flask request
- return request.args
- elif hasattr(request, 'query_params'):
- # Return ImmutableMultiDict from Starlette request
- return request.query_params
- elif hasattr(request, 'GET'):
- # Return QueryDict from Django GET request
- return request.GET
- elif hasattr(request, 'POST'):
- # Return QueryDict from Django GET request
- return request.POST
- LOGGER.debug('No query parameters found')
- return {}
-
- def _get_locale(self, headers, supported_locales):
- """
- Detects locale from "lang=" param or `Accept-Language`
- header. Returns a tuple of (raw, locale) if found in params or headers.
- Returns a tuple of (raw default, default locale) if not found.
-
- :param headers: A dict with Request headers
- :param supported_locales: List or set of supported Locale instances
- :returns: A tuple of (str, Locale)
- """
-
- raw = None
- try:
- default_locale = l10n.str2locale(supported_locales[0])
- except (TypeError, IndexError, l10n.LocaleError) as err:
- # This should normally not happen, since the API class already
- # loads the supported languages from the config, which raises
- # a LocaleError if any of these languages are invalid.
- LOGGER.error(err)
- raise ValueError(f"{self.__class__.__name__} must be initialized"
- f"with a list of valid supported locales")
-
- for func, mapping in ((l10n.locale_from_params, self._args),
- (l10n.locale_from_headers, headers)):
- loc_str = func(mapping)
- if loc_str:
- if not raw:
- # This is the first-found locale string: set as raw
- raw = loc_str
- # Check if locale string is a good match for the UI
- loc = l10n.best_match(loc_str, supported_locales)
- is_override = func is l10n.locale_from_params
- if loc != default_locale or is_override:
- return raw, loc
-
- return raw, default_locale
-
- def _get_format(self, headers) -> Union[str, None]:
- """
- Get `Request` format type from query parameters or headers.
-
- :param headers: Dict of Request headers
- :returns: format value or None if not found/specified
- """
-
- # Optional f=html or f=json query param
- # Overrides Accept header and might differ from FORMAT_TYPES
- format_ = (self._args.get('f') or '').strip()
- if format_:
- return format_
-
- # Format not specified: get from Accept headers (MIME types)
- # e.g. format_ = 'text/html'
- h = headers.get('accept', headers.get('Accept', '')).strip() # noqa
- (fmts, mimes) = zip(*FORMAT_TYPES.items())
- # basic support for complex types (i.e. with "q=0.x")
- for type_ in (t.split(';')[0].strip() for t in h.split(',') if t):
- if type_ in mimes:
- idx_ = mimes.index(type_)
- format_ = fmts[idx_]
- break
-
- return format_ or None
-
- @property
- def data(self) -> bytes:
- """Returns the additional data send with the Request (bytes)"""
- return self._data
-
- @property
- def params(self) -> dict:
- """Returns the Request query parameters dict"""
- return self._args
-
- @property
- def path_info(self) -> str:
- """Returns the web server request path info part"""
- return self._path_info
-
- @property
- def locale(self) -> l10n.Locale:
- """
- Returns the user-defined locale from the request object.
- If no locale has been defined or if it is invalid,
- the default server locale is returned.
-
- .. note:: The locale here determines the language in which pygeoapi
- should return its responses. This may not be the language
- that the user requested. It may also not be the language
- that is supported by a collection provider, for example.
- For this reason, you should pass the `raw_locale` property
- to the :func:`l10n.get_plugin_locale` function, so that
- the best match for the provider can be determined.
-
- :returns: babel.core.Locale
- """
-
- return self._locale
-
- @property
- def raw_locale(self) -> Union[str, None]:
- """
- Returns the raw locale string from the `Request` object.
- If no "lang" query parameter or `Accept-Language` header was found,
- `None` is returned.
- Pass this value to the :func:`l10n.get_plugin_locale` function to let
- the provider determine a best match for the locale, which may be
- different from the locale used by pygeoapi's UI.
-
- :returns: a locale string or None
- """
-
- return self._raw_locale
-
- @property
- def format(self) -> Union[str, None]:
- """
- Returns the content type format from the
- request query parameters or headers.
-
- :returns: Format name or None
- """
-
- return self._format
-
- @property
- def headers(self) -> dict:
- """
- Returns the dictionary of the headers from
- the request.
-
- :returns: Request headers dictionary
- """
-
- return self._headers
-
- def get_linkrel(self, format_: str) -> str:
- """
- Returns the hyperlink relationship (rel) attribute value for
- the given API format string.
-
- The string is compared against the request format and if it matches,
- the value 'self' is returned. Otherwise, 'alternate' is returned.
- However, if `format_` is 'json' and *no* request format was found,
- the relationship 'self' is returned as well (JSON is the default).
-
- :param format_: The format to compare the request format against.
- :returns: A string 'self' or 'alternate'.
- """
-
- fmt = format_.lower()
- if fmt == self._format or (fmt == F_JSON and not self._format):
- return 'self'
- return 'alternate'
-
- def is_valid(self, additional_formats=None) -> bool:
- """
- Returns True if:
- - the format is not set (None)
- - the requested format is supported
- - the requested format exists in a list if additional formats
-
- .. note:: Format names are matched in a case-insensitive manner.
-
- :param additional_formats: Optional additional supported formats list
-
- :returns: bool
- """
-
- if not self._format:
- return True
- if self._format in FORMAT_TYPES.keys():
- return True
- if self._format in (f.lower() for f in (additional_formats or ())):
- return True
- return False
-
- def get_response_headers(self, force_lang: l10n.Locale = None,
- force_type: str = None,
- force_encoding: str = None,
- **custom_headers) -> dict:
- """
- Prepares and returns a dictionary with Response object headers.
-
- This method always adds a 'Content-Language' header, where the value
- is determined by the 'lang' query parameter or 'Accept-Language'
- header from the request.
- If no language was requested, the default pygeoapi language is used,
- unless a `force_lang` override was specified (see notes below).
-
- A 'Content-Type' header is also always added to the response.
- If the user does not specify `force_type`, the header is based on
- the `format` APIRequest property. If that is invalid, the default MIME
- type `application/json` is used.
-
- ..note:: If a `force_lang` override is applied, that language
- is always set as the 'Content-Language', regardless of
- a 'lang' query parameter or 'Accept-Language' header.
- If an API response always needs to be in the same
- language, 'force_lang' should be set to that language.
-
- :param force_lang: An optional Content-Language header override.
- :param force_type: An optional Content-Type header override.
- :param force_encoding: An optional Content-Encoding header override.
- :returns: A header dict
- """
-
- headers = HEADERS.copy()
- headers.update(**custom_headers)
- l10n.set_response_language(headers, force_lang or self._locale)
- if force_type:
- # Set custom MIME type if specified
- headers['Content-Type'] = force_type
- elif self.is_valid() and self._format:
- # Set MIME type for valid formats
- headers['Content-Type'] = FORMAT_TYPES[self._format]
-
- if F_GZIP in FORMAT_TYPES:
- if force_encoding:
- headers['Content-Encoding'] = force_encoding
- elif F_GZIP in self._headers.get('Accept-Encoding', ''):
- headers['Content-Encoding'] = F_GZIP
-
- return headers
-
- def get_request_headers(self, headers) -> dict:
- """
- Obtains and returns a dictionary with Request object headers.
-
- This method adds the headers of the original request and
- makes them available to the API object.
-
- :returns: A header dict
- """
-
- headers_ = {item[0]: item[1] for item in headers.items()}
- return headers_
-
-
-class API:
- """API object"""
-
- def __init__(self, config, openapi):
- """
- constructor
-
- :param config: configuration dict
- :param openapi: openapi dict
-
- :returns: `pygeoapi.API` instance
- """
-
- self.config = config
- self.openapi = openapi
- self.api_headers = get_api_rules(self.config).response_headers
- self.base_url = get_base_url(self.config)
- self.prefetcher = UrlPrefetcher()
-
- CHARSET[0] = config['server'].get('encoding', 'utf-8')
- if config['server'].get('gzip'):
- FORMAT_TYPES[F_GZIP] = 'application/gzip'
- FORMAT_TYPES.move_to_end(F_JSON)
-
- # Process language settings (first locale is default!)
- self.locales = l10n.get_locales(config)
- self.default_locale = self.locales[0]
-
- if 'templates' not in self.config['server']:
- self.config['server']['templates'] = {'path': TEMPLATES}
-
- if 'pretty_print' not in self.config['server']:
- self.config['server']['pretty_print'] = False
-
- self.pretty_print = self.config['server']['pretty_print']
-
- setup_logger(self.config['logging'])
-
- # Create config clone for HTML templating with modified base URL
- self.tpl_config = deepcopy(self.config)
- self.tpl_config['server']['url'] = self.base_url
-
- self.manager = get_manager(self.config)
- LOGGER.info('Process manager plugin loaded')
-
- @gzip
- @pre_process
- @jsonldify
- def landing_page(self,
- request: Union[APIRequest, Any]) -> Tuple[dict, int, str]:
- """
- Provide API landing page
-
- :param request: A request object
-
- :returns: tuple of headers, status code, content
- """
-
- if not request.is_valid():
- return self.get_format_exception(request)
-
- fcm = {
- 'links': [],
- 'title': l10n.translate(
- self.config['metadata']['identification']['title'],
- request.locale),
- 'description':
- l10n.translate(
- self.config['metadata']['identification']['description'],
- request.locale)
- }
-
- LOGGER.debug('Creating links')
- # TODO: put title text in config or translatable files?
- fcm['links'] = [{
- 'rel': request.get_linkrel(F_JSON),
- 'type': FORMAT_TYPES[F_JSON],
- 'title': 'This document as JSON',
- 'href': f"{self.base_url}?f={F_JSON}"
- }, {
- 'rel': request.get_linkrel(F_JSONLD),
- 'type': FORMAT_TYPES[F_JSONLD],
- 'title': 'This document as RDF (JSON-LD)',
- 'href': f"{self.base_url}?f={F_JSONLD}"
- }, {
- 'rel': request.get_linkrel(F_HTML),
- 'type': FORMAT_TYPES[F_HTML],
- 'title': 'This document as HTML',
- 'href': f"{self.base_url}?f={F_HTML}",
- 'hreflang': self.default_locale
- }, {
- 'rel': 'service-desc',
- 'type': 'application/vnd.oai.openapi+json;version=3.0',
- 'title': 'The OpenAPI definition as JSON',
- 'href': f"{self.base_url}/openapi"
- }, {
- 'rel': 'service-doc',
- 'type': FORMAT_TYPES[F_HTML],
- 'title': 'The OpenAPI definition as HTML',
- 'href': f"{self.base_url}/openapi?f={F_HTML}",
- 'hreflang': self.default_locale
- }, {
- 'rel': 'conformance',
- 'type': FORMAT_TYPES[F_JSON],
- 'title': 'Conformance',
- 'href': f"{self.base_url}/conformance"
- }, {
- 'rel': 'data',
- 'type': FORMAT_TYPES[F_JSON],
- 'title': 'Collections',
- 'href': self.get_collections_url()
- }, {
- 'rel': 'http://www.opengis.net/def/rel/ogc/1.0/processes',
- 'type': FORMAT_TYPES[F_JSON],
- 'title': 'Processes',
- 'href': f"{self.base_url}/processes"
- }, {
- 'rel': 'http://www.opengis.net/def/rel/ogc/1.0/job-list',
- 'type': FORMAT_TYPES[F_JSON],
- 'title': 'Jobs',
- 'href': f"{self.base_url}/jobs"
- }, {
- 'rel': 'http://www.opengis.net/def/rel/ogc/1.0/tiling-schemes',
- 'type': FORMAT_TYPES[F_JSON],
- 'title': 'The list of supported tiling schemes (as JSON)',
- 'href': f"{self.base_url}/TileMatrixSets?f=json"
- }, {
- 'rel': 'http://www.opengis.net/def/rel/ogc/1.0/tiling-schemes',
- 'type': FORMAT_TYPES[F_HTML],
- 'title': 'The list of supported tiling schemes (as HTML)',
- 'href': f"{self.base_url}/TileMatrixSets?f=html"
- }]
-
- headers = request.get_response_headers(**self.api_headers)
- if request.format == F_HTML: # render
-
- fcm['processes'] = False
- fcm['stac'] = False
- fcm['collection'] = False
-
- if filter_dict_by_key_value(self.config['resources'],
- 'type', 'process'):
- fcm['processes'] = True
-
- if filter_dict_by_key_value(self.config['resources'],
- 'type', 'stac-collection'):
- fcm['stac'] = True
-
- if filter_dict_by_key_value(self.config['resources'],
- 'type', 'collection'):
- fcm['collection'] = True
-
- content = render_j2_template(self.tpl_config, 'landing_page.html',
- fcm, request.locale)
- return headers, HTTPStatus.OK, content
-
- if request.format == F_JSONLD:
- return headers, HTTPStatus.OK, to_json(
- self.fcmld, self.pretty_print)
-
- return headers, HTTPStatus.OK, to_json(fcm, self.pretty_print)
-
- @gzip
- @pre_process
- def openapi_(self, request: Union[APIRequest, Any]) -> Tuple[
- dict, int, str]:
- """
- Provide OpenAPI document
-
- :param request: A request object
- :param openapi: dict of OpenAPI definition
-
- :returns: tuple of headers, status code, content
- """
-
- if not request.is_valid():
- return self.get_format_exception(request)
-
- headers = request.get_response_headers(**self.api_headers)
-
- if request.format == F_HTML:
- template = 'openapi/swagger.html'
- if request._args.get('ui') == 'redoc':
- template = 'openapi/redoc.html'
-
- path = f'{self.base_url}/openapi'
- data = {
- 'openapi-document-path': path
- }
- content = render_j2_template(self.tpl_config, template, data,
- request.locale)
- return headers, HTTPStatus.OK, content
-
- headers['Content-Type'] = 'application/vnd.oai.openapi+json;version=3.0' # noqa
-
- if isinstance(self.openapi, dict):
- return headers, HTTPStatus.OK, to_json(self.openapi,
- self.pretty_print)
- else:
- return headers, HTTPStatus.OK, self.openapi
-
- @gzip
- @pre_process
- def conformance(self,
- request: Union[APIRequest, Any]) -> Tuple[dict, int, str]:
- """
- Provide conformance definition
-
- :param request: A request object
-
- :returns: tuple of headers, status code, content
- """
-
- if not request.is_valid():
- return self.get_format_exception(request)
-
- conformance_list = CONFORMANCE['common']
-
- for key, value in self.config['resources'].items():
- if value['type'] == 'process':
- conformance_list.extend(CONFORMANCE[value['type']])
- else:
- for provider in value['providers']:
- if provider['type'] in CONFORMANCE:
- conformance_list.extend(CONFORMANCE[provider['type']])
-
- conformance = {
- 'conformsTo': list(set(conformance_list))
- }
-
- headers = request.get_response_headers(**self.api_headers)
- if request.format == F_HTML: # render
- content = render_j2_template(self.tpl_config, 'conformance.html',
- conformance, request.locale)
- return headers, HTTPStatus.OK, content
-
- return headers, HTTPStatus.OK, to_json(conformance, self.pretty_print)
-
- @gzip
- @pre_process
- def tilematrixsets(self,
- request: Union[APIRequest, Any]) -> Tuple[dict, int,
- str]:
- """
- Provide tileMatrixSets definition
-
- :param request: A request object
-
- :returns: tuple of headers, status code, content
- """
-
- if not request.is_valid():
- return self.get_format_exception(request)
-
- headers = request.get_response_headers(**self.api_headers)
-
- # Retrieve available TileMatrixSets
- enums = [e.value for e in TileMatrixSetEnum]
-
- tms = {"tileMatrixSets": []}
-
- for e in enums:
- tms['tileMatrixSets'].append({
- "title": e.title,
- "id": e.tileMatrixSet,
- "uri": e.tileMatrixSetURI,
- "links": [
- {
- "rel": "self",
- "type": "text/html",
- "title": f"The HTML representation of the {e.tileMatrixSet} tile matrix set", # noqa
- "href": f"{self.base_url}/TileMatrixSets/{e.tileMatrixSet}?f=html" # noqa
- },
- {
- "rel": "self",
- "type": "application/json",
- "title": f"The JSON representation of the {e.tileMatrixSet} tile matrix set", # noqa
- "href": f"{self.base_url}/TileMatrixSets/{e.tileMatrixSet}?f=json" # noqa
- }
- ]
- })
-
- tms['links'] = [{
- "rel": "alternate",
- "type": "text/html",
- "title": "This document as HTML",
- "href": f"{self.base_url}/tileMatrixSets?f=html"
- }, {
- "rel": "self",
- "type": "application/json",
- "title": "This document",
- "href": f"{self.base_url}/tileMatrixSets?f=json"
- }]
-
- if request.format == F_HTML: # render
- content = render_j2_template(self.tpl_config,
- 'tilematrixsets/index.html',
- tms, request.locale)
- return headers, HTTPStatus.OK, content
-
- return headers, HTTPStatus.OK, to_json(tms, self.pretty_print)
-
- @gzip
- @pre_process
- def tilematrixset(self,
- request: Union[APIRequest, Any],
- tileMatrixSetId) -> Tuple[dict,
- int, str]:
- """
- Provide tile matrix definition
-
- :param request: A request object
-
- :returns: tuple of headers, status code, content
- """
-
- if not request.is_valid():
- return self.get_format_exception(request)
-
- headers = request.get_response_headers(**self.api_headers)
-
- # Retrieve relevant TileMatrixSet
- enums = [e.value for e in TileMatrixSetEnum]
- enum = None
-
- try:
- for e in enums:
- if tileMatrixSetId == e.tileMatrixSet:
- enum = e
- if not enum:
- raise ValueError('could not find this tilematrixset')
- except ValueError as err:
- return self.get_exception(
- HTTPStatus.BAD_REQUEST, headers, request.format,
- 'InvalidParameterValue', str(err))
-
- tms = {
- "title": enum.tileMatrixSet,
- "crs": enum.crs,
- "id": enum.tileMatrixSet,
- "uri": enum.tileMatrixSetURI,
- "orderedAxes": enum.orderedAxes,
- "wellKnownScaleSet": enum.wellKnownScaleSet,
- "tileMatrices": enum.tileMatrices
- }
-
- if request.format == F_HTML: # render
- content = render_j2_template(self.tpl_config,
- 'tilematrixsets/tilematrixset.html',
- tms, request.locale)
- return headers, HTTPStatus.OK, content
-
- return headers, HTTPStatus.OK, to_json(tms, self.pretty_print)
-
- @gzip
- @pre_process
- @jsonldify
- def describe_collections(self, request: Union[APIRequest, Any],
- dataset=None) -> Tuple[dict, int, str]:
- """
- Provide collection metadata
-
- :param request: A request object
- :param dataset: name of collection
-
- :returns: tuple of headers, status code, content
- """
-
- if not request.is_valid():
- return self.get_format_exception(request)
- headers = request.get_response_headers(**self.api_headers)
-
- fcm = {
- 'collections': [],
- 'links': []
- }
-
- collections = filter_dict_by_key_value(self.config['resources'],
- 'type', 'collection')
-
- if all([dataset is not None, dataset not in collections.keys()]):
- msg = 'Collection not found'
- return self.get_exception(
- HTTPStatus.NOT_FOUND, headers, request.format, 'NotFound', msg)
-
- if dataset is not None:
- collections_dict = {
- k: v for k, v in collections.items() if k == dataset
- }
- else:
- collections_dict = collections
-
- LOGGER.debug('Creating collections')
- for k, v in collections_dict.items():
- if v.get('visibility', 'default') == 'hidden':
- LOGGER.debug(f'Skipping hidden layer: {k}')
- continue
- collection_data = get_provider_default(v['providers'])
- collection_data_type = collection_data['type']
-
- collection_data_format = None
-
- if 'format' in collection_data:
- collection_data_format = collection_data['format']
-
- collection = {
- 'id': k,
- 'title': l10n.translate(v['title'], request.locale),
- 'description': l10n.translate(v['description'], request.locale), # noqa
- 'keywords': l10n.translate(v['keywords'], request.locale),
- 'links': []
- }
-
- bbox = v['extents']['spatial']['bbox']
- # The output should be an array of bbox, so if the user only
- # provided a single bbox, wrap it in a array.
- if not isinstance(bbox[0], list):
- bbox = [bbox]
- collection['extent'] = {
- 'spatial': {
- 'bbox': bbox
- }
- }
- if 'crs' in v['extents']['spatial']:
- collection['extent']['spatial']['crs'] = \
- v['extents']['spatial']['crs']
-
- t_ext = v.get('extents', {}).get('temporal', {})
- if t_ext:
- begins = dategetter('begin', t_ext)
- ends = dategetter('end', t_ext)
- collection['extent']['temporal'] = {
- 'interval': [[begins, ends]]
- }
- if 'trs' in t_ext:
- collection['extent']['temporal']['trs'] = t_ext['trs']
-
- LOGGER.debug('Processing configured collection links')
- for link in l10n.translate(v.get('links', []), request.locale):
- lnk = {
- 'type': link['type'],
- 'rel': link['rel'],
- 'title': l10n.translate(link['title'], request.locale),
- 'href': l10n.translate(link['href'], request.locale),
- }
- if 'hreflang' in link:
- lnk['hreflang'] = l10n.translate(
- link['hreflang'], request.locale)
- content_length = link.get('length', 0)
-
- if lnk['rel'] == 'enclosure' and content_length == 0:
- # Issue HEAD request for enclosure links without length
- lnk_headers = self.prefetcher.get_headers(lnk['href'])
- content_length = int(lnk_headers.get('content-length', 0))
- content_type = lnk_headers.get('content-type', lnk['type'])
- if content_length == 0:
- # Skip this (broken) link
- LOGGER.debug(f"Enclosure {lnk['href']} is invalid")
- continue
- if content_type != lnk['type']:
- # Update content type if different from specified
- lnk['type'] = content_type
- LOGGER.debug(
- f"Fixed media type for enclosure {lnk['href']}")
-
- if content_length > 0:
- lnk['length'] = content_length
-
- collection['links'].append(lnk)
-
- # TODO: provide translations
- LOGGER.debug('Adding JSON and HTML link relations')
- collection['links'].append({
- 'type': FORMAT_TYPES[F_JSON],
- 'rel': 'root',
- 'title': 'The landing page of this server as JSON',
- 'href': f"{self.base_url}?f={F_JSON}"
- })
- collection['links'].append({
- 'type': FORMAT_TYPES[F_HTML],
- 'rel': 'root',
- 'title': 'The landing page of this server as HTML',
- 'href': f"{self.base_url}?f={F_HTML}"
- })
- collection['links'].append({
- 'type': FORMAT_TYPES[F_JSON],
- 'rel': request.get_linkrel(F_JSON),
- 'title': 'This document as JSON',
- 'href': f'{self.get_collections_url()}/{k}?f={F_JSON}'
- })
- collection['links'].append({
- 'type': FORMAT_TYPES[F_JSONLD],
- 'rel': request.get_linkrel(F_JSONLD),
- 'title': 'This document as RDF (JSON-LD)',
- 'href': f'{self.get_collections_url()}/{k}?f={F_JSONLD}'
- })
- collection['links'].append({
- 'type': FORMAT_TYPES[F_HTML],
- 'rel': request.get_linkrel(F_HTML),
- 'title': 'This document as HTML',
- 'href': f'{self.get_collections_url()}/{k}?f={F_HTML}'
- })
-
- if collection_data_type in ['feature', 'record', 'tile']:
- # TODO: translate
- collection['itemType'] = collection_data_type
- LOGGER.debug('Adding feature/record based links')
- collection['links'].append({
- 'type': 'application/schema+json',
- 'rel': 'http://www.opengis.net/def/rel/ogc/1.0/queryables',
- 'title': 'Queryables for this collection as JSON',
- 'href': f'{self.get_collections_url()}/{k}/queryables?f={F_JSON}' # noqa
- })
- collection['links'].append({
- 'type': FORMAT_TYPES[F_HTML],
- 'rel': 'http://www.opengis.net/def/rel/ogc/1.0/queryables',
- 'title': 'Queryables for this collection as HTML',
- 'href': f'{self.get_collections_url()}/{k}/queryables?f={F_HTML}' # noqa
- })
- collection['links'].append({
- 'type': 'application/geo+json',
- 'rel': 'items',
- 'title': 'items as GeoJSON',
- 'href': f'{self.get_collections_url()}/{k}/items?f={F_JSON}' # noqa
- })
- collection['links'].append({
- 'type': FORMAT_TYPES[F_JSONLD],
- 'rel': 'items',
- 'title': 'items as RDF (GeoJSON-LD)',
- 'href': f'{self.get_collections_url()}/{k}/items?f={F_JSONLD}' # noqa
- })
- collection['links'].append({
- 'type': FORMAT_TYPES[F_HTML],
- 'rel': 'items',
- 'title': 'Items as HTML',
- 'href': f'{self.get_collections_url()}/{k}/items?f={F_HTML}' # noqa
- })
-
- # OAPIF Part 2 - list supported CRSs and StorageCRS
- if collection_data_type == 'feature':
- collection['crs'] = get_supported_crs_list(collection_data, DEFAULT_CRS_LIST) # noqa
- collection['storageCRS'] = collection_data.get('storage_crs', DEFAULT_STORAGE_CRS) # noqa
- if 'storage_crs_coordinate_epoch' in collection_data:
- collection['storageCrsCoordinateEpoch'] = collection_data.get('storage_crs_coordinate_epoch') # noqa
-
- elif collection_data_type == 'coverage':
- # TODO: translate
- LOGGER.debug('Adding coverage based links')
- collection['links'].append({
- 'type': FORMAT_TYPES[F_JSON],
- 'rel': 'collection',
- 'title': 'Detailed Coverage metadata in JSON',
- 'href': f'{self.get_collections_url()}/{k}?f={F_JSON}'
- })
- collection['links'].append({
- 'type': FORMAT_TYPES[F_HTML],
- 'rel': 'collection',
- 'title': 'Detailed Coverage metadata in HTML',
- 'href': f'{self.get_collections_url()}/{k}?f={F_HTML}'
- })
- coverage_url = f'{self.get_collections_url()}/{k}/coverage'
-
- collection['links'].append({
- 'type': FORMAT_TYPES[F_JSON],
- 'rel': f'{OGC_RELTYPES_BASE}/coverage-domainset',
- 'title': 'Coverage domain set of collection in JSON',
- 'href': f'{coverage_url}/domainset?f={F_JSON}'
- })
- collection['links'].append({
- 'type': FORMAT_TYPES[F_HTML],
- 'rel': f'{OGC_RELTYPES_BASE}/coverage-domainset',
- 'title': 'Coverage domain set of collection in HTML',
- 'href': f'{coverage_url}/domainset?f={F_HTML}'
- })
- collection['links'].append({
- 'type': FORMAT_TYPES[F_JSON],
- 'rel': f'{OGC_RELTYPES_BASE}/coverage-rangetype',
- 'title': 'Coverage range type of collection in JSON',
- 'href': f'{coverage_url}/rangetype?f={F_JSON}'
- })
- collection['links'].append({
- 'type': FORMAT_TYPES[F_HTML],
- 'rel': f'{OGC_RELTYPES_BASE}/coverage-rangetype',
- 'title': 'Coverage range type of collection in HTML',
- 'href': f'{coverage_url}/rangetype?f={F_HTML}'
- })
- collection['links'].append({
- 'type': 'application/prs.coverage+json',
- 'rel': f'{OGC_RELTYPES_BASE}/coverage',
- 'title': 'Coverage data',
- 'href': f'{self.get_collections_url()}/{k}/coverage?f={F_JSON}' # noqa
- })
- if collection_data_format is not None:
- collection['links'].append({
- 'type': collection_data_format['mimetype'],
- 'rel': f'{OGC_RELTYPES_BASE}/coverage',
- 'title': f"Coverage data as {collection_data_format['name']}", # noqa
- 'href': f"{self.get_collections_url()}/{k}/coverage?f={collection_data_format['name']}" # noqa
- })
- if dataset is not None:
- LOGGER.debug('Creating extended coverage metadata')
- try:
- provider_def = get_provider_by_type(
- self.config['resources'][k]['providers'],
- 'coverage')
- p = load_plugin('provider', provider_def)
- except ProviderConnectionError:
- msg = 'connection error (check logs)'
- return self.get_exception(
- HTTPStatus.INTERNAL_SERVER_ERROR,
- headers, request.format,
- 'NoApplicableCode', msg)
- except ProviderTypeError:
- pass
- else:
- collection['crs'] = [p.crs]
- collection['domainset'] = p.get_coverage_domainset()
- collection['rangetype'] = p.get_coverage_rangetype()
-
- try:
- tile = get_provider_by_type(v['providers'], 'tile')
- p = load_plugin('provider', tile)
- except ProviderConnectionError:
- msg = 'connection error (check logs)'
- return self.get_exception(
- HTTPStatus.INTERNAL_SERVER_ERROR,
- headers, request.format,
- 'NoApplicableCode', msg)
- except ProviderTypeError:
- tile = None
-
- if tile:
- # TODO: translate
-
- LOGGER.debug('Adding tile links')
- collection['links'].append({
- 'type': FORMAT_TYPES[F_JSON],
- 'rel': f'http://www.opengis.net/def/rel/ogc/1.0/tilesets-{p.tile_type}', # noqa
- 'title': 'Tiles as JSON',
- 'href': f'{self.get_collections_url()}/{k}/tiles?f={F_JSON}' # noqa
- })
- collection['links'].append({
- 'type': FORMAT_TYPES[F_HTML],
- 'rel': f'http://www.opengis.net/def/rel/ogc/1.0/tilesets-{p.tile_type}', # noqa
- 'title': 'Tiles as HTML',
- 'href': f'{self.get_collections_url()}/{k}/tiles?f={F_HTML}' # noqa
- })
-
- try:
- map_ = get_provider_by_type(v['providers'], 'map')
- except ProviderTypeError:
- map_ = None
-
- if map_:
- LOGGER.debug('Adding map links')
-
- map_mimetype = map_['format']['mimetype']
- map_format = map_['format']['name']
-
- collection['links'].append({
- 'type': map_mimetype,
- 'rel': 'http://www.opengis.net/def/rel/ogc/1.0/map',
- 'title': f'Map as {map_format}',
- 'href': f"{self.get_collections_url()}/{k}/map?f={map_format}" # noqa
- })
-
- try:
- edr = get_provider_by_type(v['providers'], 'edr')
- p = load_plugin('provider', edr)
- except ProviderConnectionError:
- msg = 'connection error (check logs)'
- return self.get_exception(
- HTTPStatus.INTERNAL_SERVER_ERROR, headers,
- request.format, 'NoApplicableCode', msg)
- except ProviderTypeError:
- edr = None
-
- if edr:
- # TODO: translate
- LOGGER.debug('Adding EDR links')
- parameters = p.get_fields()
- if parameters:
- collection['parameter_names'] = {}
- for f in parameters['field']:
- collection['parameter_names'][f['id']] = f
-
- for qt in p.get_query_types():
- collection['links'].append({
- 'type': 'application/json',
- 'rel': 'data',
- 'title': f'{qt} query for this collection as JSON',
- 'href': f'{self.get_collections_url()}/{k}/{qt}?f={F_JSON}' # noqa
- })
- collection['links'].append({
- 'type': FORMAT_TYPES[F_HTML],
- 'rel': 'data',
- 'title': f'{qt} query for this collection as HTML',
- 'href': f'{self.get_collections_url()}/{k}/{qt}?f={F_HTML}' # noqa
- })
-
- if dataset is not None and k == dataset:
- fcm = collection
- break
-
- fcm['collections'].append(collection)
-
- if dataset is None:
- # TODO: translate
- fcm['links'].append({
- 'type': FORMAT_TYPES[F_JSON],
- 'rel': request.get_linkrel(F_JSON),
- 'title': 'This document as JSON',
- 'href': f'{self.get_collections_url()}?f={F_JSON}'
- })
- fcm['links'].append({
- 'type': FORMAT_TYPES[F_JSONLD],
- 'rel': request.get_linkrel(F_JSONLD),
- 'title': 'This document as RDF (JSON-LD)',
- 'href': f'{self.get_collections_url()}?f={F_JSONLD}'
- })
- fcm['links'].append({
- 'type': FORMAT_TYPES[F_HTML],
- 'rel': request.get_linkrel(F_HTML),
- 'title': 'This document as HTML',
- 'href': f'{self.get_collections_url()}?f={F_HTML}'
- })
-
- if request.format == F_HTML: # render
- fcm['collections_path'] = self.get_collections_url()
- if dataset is not None:
- content = render_j2_template(self.tpl_config,
- 'collections/collection.html',
- fcm, request.locale)
- else:
- content = render_j2_template(self.tpl_config,
- 'collections/index.html', fcm,
- request.locale)
-
- return headers, HTTPStatus.OK, content
-
- if request.format == F_JSONLD:
- jsonld = self.fcmld.copy()
- if dataset is not None:
- jsonld['dataset'] = jsonldify_collection(self, fcm,
- request.locale)
- else:
- jsonld['dataset'] = [
- jsonldify_collection(self, c, request.locale)
- for c in fcm.get('collections', [])
- ]
- return headers, HTTPStatus.OK, to_json(jsonld, self.pretty_print)
-
- return headers, HTTPStatus.OK, to_json(fcm, self.pretty_print)
-
- @gzip
- @pre_process
- @jsonldify
- def get_collection_queryables(self, request: Union[APIRequest, Any],
- dataset=None) -> Tuple[dict, int, str]:
- """
- Provide collection queryables
-
- :param request: A request object
- :param dataset: name of collection
-
- :returns: tuple of headers, status code, content
- """
-
- if not request.is_valid():
- return self.get_format_exception(request)
- headers = request.get_response_headers(**self.api_headers)
-
- if any([dataset is None,
- dataset not in self.config['resources'].keys()]):
-
- msg = 'Collection not found'
- return self.get_exception(
- 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(
- self.config['resources'][dataset]['providers'], 'feature'))
- except ProviderTypeError:
- LOGGER.debug('Loading record provider')
- p = load_plugin('provider', get_provider_by_type(
- self.config['resources'][dataset]['providers'], 'record'))
- except ProviderGenericError as err:
- LOGGER.error(err)
- return self.get_exception(
- err.http_status_code, headers, request.format,
- err.ogc_exception_code, err.message)
-
- queryables = {
- 'type': 'object',
- 'title': l10n.translate(
- self.config['resources'][dataset]['title'], request.locale),
- 'properties': {},
- '$schema': 'http://json-schema.org/draft/2019-09/schema',
- '$id': f'{self.get_collections_url()}/{dataset}/queryables'
- }
-
- if p.fields:
- queryables['properties']['geometry'] = {
- '$ref': 'https://geojson.org/schema/Geometry.json'
- }
-
- for k, v in p.fields.items():
- show_field = False
- if p.properties:
- if k in p.properties:
- show_field = True
- else:
- show_field = True
-
- if show_field:
- queryables['properties'][k] = {
- 'title': k,
- 'type': v['type']
- }
- if 'values' in v:
- queryables['properties'][k]['enum'] = v['values']
-
- if request.format == F_HTML: # render
- queryables['title'] = l10n.translate(
- self.config['resources'][dataset]['title'], request.locale)
-
- queryables['collections_path'] = self.get_collections_url()
-
- content = render_j2_template(self.tpl_config,
- 'collections/queryables.html',
- queryables, request.locale)
-
- return headers, HTTPStatus.OK, content
-
- headers['Content-Type'] = 'application/schema+json'
-
- return headers, HTTPStatus.OK, to_json(queryables, self.pretty_print)
-
- @gzip
- @pre_process
- def get_collection_items(
- self, request: Union[APIRequest, Any],
- dataset) -> Tuple[dict, int, str]:
- """
- Queries collection
-
- :param request: A request object
- :param dataset: dataset name
-
- :returns: tuple of headers, status code, content
- """
-
- if not request.is_valid(PLUGINS['formatter'].keys()):
- return self.get_format_exception(request)
-
- # Set Content-Language to system locale until provider locale
- # has been determined
- headers = request.get_response_headers(SYSTEM_LOCALE,
- **self.api_headers)
-
- properties = []
- reserved_fieldnames = ['bbox', 'bbox-crs', 'crs', 'f', 'lang', 'limit',
- 'offset', 'resulttype', 'datetime', 'sortby',
- 'properties', 'skipGeometry', 'q',
- 'filter', 'filter-lang', 'filter-crs']
-
- collections = filter_dict_by_key_value(self.config['resources'],
- 'type', 'collection')
-
- if dataset not in collections.keys():
- msg = 'Collection not found'
- return self.get_exception(
- HTTPStatus.NOT_FOUND, headers, request.format, 'NotFound', msg)
-
- LOGGER.debug('Processing query parameters')
-
- LOGGER.debug('Processing offset parameter')
- try:
- offset = int(request.params.get('offset'))
- if offset < 0:
- msg = 'offset value should be positive or zero'
- return self.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 self.get_exception(
- HTTPStatus.BAD_REQUEST, headers, request.format,
- 'InvalidParameterValue', msg)
-
- LOGGER.debug('Processing limit parameter')
- try:
- limit = int(request.params.get('limit'))
- # TODO: We should do more validation, against the min and max
- # allowed by the server configuration
- if limit <= 0:
- msg = 'limit value should be strictly positive'
- return self.get_exception(
- HTTPStatus.BAD_REQUEST, headers, request.format,
- 'InvalidParameterValue', msg)
- except TypeError as err:
- LOGGER.warning(err)
- limit = int(self.config['server']['limit'])
- except ValueError:
- msg = 'limit value should be an integer'
- return self.get_exception(
- HTTPStatus.BAD_REQUEST, headers, request.format,
- 'InvalidParameterValue', msg)
-
- resulttype = request.params.get('resulttype') or 'results'
-
- LOGGER.debug('Processing bbox parameter')
-
- bbox = request.params.get('bbox')
-
- if bbox is None:
- bbox = []
- else:
- try:
- bbox = validate_bbox(bbox)
- except ValueError as err:
- msg = str(err)
- return self.get_exception(
- HTTPStatus.BAD_REQUEST, headers, request.format,
- 'InvalidParameterValue', msg)
-
- LOGGER.debug('Processing datetime parameter')
- datetime_ = request.params.get('datetime')
- try:
- datetime_ = validate_datetime(collections[dataset]['extents'],
- datetime_)
- except ValueError as err:
- msg = str(err)
- return self.get_exception(
- HTTPStatus.BAD_REQUEST, headers, request.format,
- 'InvalidParameterValue', msg)
-
- LOGGER.debug('processing q parameter')
- q = request.params.get('q') or None
-
- LOGGER.debug('Loading provider')
-
- provider_def = None
- try:
- provider_type = 'feature'
- provider_def = get_provider_by_type(
- collections[dataset]['providers'], provider_type)
- p = load_plugin('provider', provider_def)
- except ProviderTypeError:
- try:
- provider_type = 'record'
- provider_def = get_provider_by_type(
- collections[dataset]['providers'], provider_type)
- p = load_plugin('provider', provider_def)
- except ProviderTypeError:
- msg = 'Invalid provider type'
- return self.get_exception(
- HTTPStatus.BAD_REQUEST, headers, request.format,
- 'NoApplicableCode', msg)
- except ProviderGenericError as err:
- LOGGER.error(err)
- return self.get_exception(
- err.http_status_code, headers, request.format,
- err.ogc_exception_code, err.message)
-
- crs_transform_spec = None
- if provider_type == 'feature':
- # crs query parameter is only available for OGC API - Features
- # right now, not for OGC API - Records.
- LOGGER.debug('Processing crs parameter')
- query_crs_uri = request.params.get('crs')
- try:
- crs_transform_spec = self._create_crs_transform_spec(
- provider_def, query_crs_uri,
- )
- except (ValueError, CRSError) as err:
- msg = str(err)
- return self.get_exception(
- HTTPStatus.BAD_REQUEST, headers, request.format,
- 'InvalidParameterValue', msg)
- self._set_content_crs_header(headers, provider_def, query_crs_uri)
-
- LOGGER.debug('Processing bbox-crs parameter')
- bbox_crs = request.params.get('bbox-crs')
- if bbox_crs is not None:
- # Validate bbox-crs parameter
- if len(bbox) == 0:
- msg = 'bbox-crs specified without bbox parameter'
- return self.get_exception(
- HTTPStatus.BAD_REQUEST, headers, request.format,
- 'NoApplicableCode', msg)
-
- if len(bbox_crs) == 0:
- msg = 'bbox-crs specified but is empty'
- return self.get_exception(
- HTTPStatus.BAD_REQUEST, headers, request.format,
- 'NoApplicableCode', msg)
-
- supported_crs_list = get_supported_crs_list(provider_def, DEFAULT_CRS_LIST) # noqa
- if bbox_crs not in supported_crs_list:
- msg = f'bbox-crs {bbox_crs} not supported for this collection'
- return self.get_exception(
- HTTPStatus.BAD_REQUEST, headers, request.format,
- 'NoApplicableCode', msg)
- elif len(bbox) > 0:
- # bbox but no bbox-crs parm: assume bbox is in default CRS
- bbox_crs = DEFAULT_CRS
-
- # Transform bbox to storageCRS
- # when bbox-crs different from storageCRS.
- if len(bbox) > 0:
- try:
- # Get a pyproj CRS instance for the Collection's Storage CRS
- storage_crs = provider_def.get('storage_crs', DEFAULT_STORAGE_CRS) # noqa
-
- # Do the (optional) Transform to the Storage CRS
- bbox = transform_bbox(bbox, bbox_crs, storage_crs)
- except CRSError as e:
- return self.get_exception(
- HTTPStatus.BAD_REQUEST, headers, request.format,
- 'NoApplicableCode', str(e))
-
- LOGGER.debug('processing property parameters')
- for k, v in request.params.items():
- if k not in reserved_fieldnames and k in list(p.fields.keys()):
- LOGGER.debug(f'Adding property filter {k}={v}')
- properties.append((k, v))
-
- LOGGER.debug('processing sort parameter')
- val = request.params.get('sortby')
-
- if val is not None:
- sortby = []
- sorts = val.split(',')
- for s in sorts:
- prop = s
- order = '+'
- if s[0] in ['+', '-']:
- order = s[0]
- prop = s[1:]
-
- if prop not in p.fields.keys():
- msg = 'bad sort property'
- return self.get_exception(
- HTTPStatus.BAD_REQUEST, headers, request.format,
- 'InvalidParameterValue', msg)
-
- sortby.append({'property': prop, 'order': order})
- else:
- sortby = []
-
- LOGGER.debug('processing properties parameter')
- val = request.params.get('properties')
-
- if val is not None:
- select_properties = val.split(',')
- properties_to_check = set(p.properties) | set(p.fields.keys())
-
- if (len(list(set(select_properties) -
- set(properties_to_check))) > 0):
- msg = 'unknown properties specified'
- return self.get_exception(
- HTTPStatus.BAD_REQUEST, headers, request.format,
- 'InvalidParameterValue', msg)
- else:
- select_properties = []
-
- LOGGER.debug('processing skipGeometry parameter')
- val = request.params.get('skipGeometry')
- if val is not None:
- skip_geometry = str2bool(val)
- else:
- skip_geometry = False
-
- LOGGER.debug('Processing filter-crs parameter')
- filter_crs_uri = request.params.get('filter-crs', DEFAULT_CRS)
- LOGGER.debug('processing filter parameter')
- cql_text = request.params.get('filter')
- if cql_text is not None:
- try:
- filter_ = parse_ecql_text(cql_text)
- filter_ = modify_pygeofilter(
- filter_,
- filter_crs_uri=filter_crs_uri,
- storage_crs_uri=provider_def.get('storage_crs'),
- geometry_column_name=provider_def.get('geom_field'),
- )
- except Exception as err:
- LOGGER.error(err)
- msg = f'Bad CQL string : {cql_text}'
- return self.get_exception(
- HTTPStatus.BAD_REQUEST, headers, request.format,
- 'InvalidParameterValue', msg)
- else:
- filter_ = None
-
- LOGGER.debug('Processing filter-lang parameter')
- filter_lang = request.params.get('filter-lang')
- # Currently only cql-text is handled, but it is optional
- if filter_lang not in [None, 'cql-text']:
- msg = 'Invalid filter language'
- return self.get_exception(
- HTTPStatus.BAD_REQUEST, headers, request.format,
- 'InvalidParameterValue', msg)
- # Get provider locale (if any)
- prv_locale = l10n.get_plugin_locale(provider_def, request.raw_locale)
-
- LOGGER.debug('Querying provider')
- LOGGER.debug(f'offset: {offset}')
- LOGGER.debug(f'limit: {limit}')
- LOGGER.debug(f'resulttype: {resulttype}')
- LOGGER.debug(f'sortby: {sortby}')
- LOGGER.debug(f'bbox: {bbox}')
- if provider_type == 'feature':
- LOGGER.debug(f'crs: {query_crs_uri}')
- LOGGER.debug(f'datetime: {datetime_}')
- LOGGER.debug(f'properties: {properties}')
- LOGGER.debug(f'select properties: {select_properties}')
- LOGGER.debug(f'skipGeometry: {skip_geometry}')
- LOGGER.debug(f'language: {prv_locale}')
- LOGGER.debug(f'q: {q}')
- LOGGER.debug(f'cql_text: {cql_text}')
- LOGGER.debug(f'filter_: {filter_}')
- LOGGER.debug(f'filter-lang: {filter_lang}')
- LOGGER.debug(f'filter-crs: {filter_crs_uri}')
-
- try:
- content = p.query(offset=offset, limit=limit,
- resulttype=resulttype, bbox=bbox,
- datetime_=datetime_, properties=properties,
- sortby=sortby, skip_geometry=skip_geometry,
- select_properties=select_properties,
- crs_transform_spec=crs_transform_spec,
- q=q, language=prv_locale, filterq=filter_)
- except ProviderGenericError as err:
- LOGGER.error(err)
- return self.get_exception(
- err.http_status_code, headers, request.format,
- err.ogc_exception_code, err.message)
-
- 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=',')
-
- # TODO: translate titles
- uri = f'{self.get_collections_url()}/{dataset}/items'
- content['links'] = [{
- 'type': 'application/geo+json',
- 'rel': request.get_linkrel(F_JSON),
- 'title': 'This document as GeoJSON',
- 'href': f'{uri}?f={F_JSON}{serialized_query_params}'
- }, {
- 'rel': request.get_linkrel(F_JSONLD),
- 'type': FORMAT_TYPES[F_JSONLD],
- 'title': 'This document as RDF (JSON-LD)',
- 'href': f'{uri}?f={F_JSONLD}{serialized_query_params}'
- }, {
- 'type': FORMAT_TYPES[F_HTML],
- 'rel': request.get_linkrel(F_HTML),
- 'title': 'This document as HTML',
- 'href': f'{uri}?f={F_HTML}{serialized_query_params}'
- }]
-
- if offset > 0:
- prev = max(0, offset - limit)
- content['links'].append(
- {
- 'type': 'application/geo+json',
- 'rel': 'prev',
- 'title': 'items (prev)',
- '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': 'items (next)',
- 'href': next_href
- })
-
- content['links'].append(
- {
- 'type': FORMAT_TYPES[F_JSON],
- 'title': l10n.translate(
- collections[dataset]['title'], request.locale),
- 'rel': 'collection',
- 'href': uri
- })
-
- content['timeStamp'] = datetime.utcnow().strftime(
- '%Y-%m-%dT%H:%M:%S.%fZ')
-
- # Set response language to requested provider locale
- # (if it supports language) and/or otherwise the requested pygeoapi
- # locale (or fallback default locale)
- l10n.set_response_language(headers, prv_locale, request.locale)
-
- if request.format == F_HTML: # render
- # For constructing proper URIs to items
-
- content['items_path'] = uri
- content['dataset_path'] = '/'.join(uri.split('/')[:-1])
- content['collections_path'] = self.get_collections_url()
-
- content['offset'] = offset
-
- content['id_field'] = p.id_field
- if p.uri_field is not None:
- content['uri_field'] = p.uri_field
- if p.title_field is not None:
- content['title_field'] = l10n.translate(p.title_field,
- request.locale)
- # If title exists, use it as id in html templates
- content['id_field'] = content['title_field']
- content = render_j2_template(self.tpl_config,
- 'collections/items/index.html',
- content, request.locale)
- return headers, HTTPStatus.OK, content
- elif request.format == 'csv': # render
- formatter = load_plugin('formatter',
- {'name': 'CSV', 'geom': True})
-
- try:
- content = formatter.write(
- data=content,
- options={
- 'provider_def': get_provider_by_type(
- collections[dataset]['providers'],
- 'feature')
- }
- )
- except FormatterSerializationError as err:
- LOGGER.error(err)
- msg = 'Error serializing output'
- return self.get_exception(
- HTTPStatus.INTERNAL_SERVER_ERROR, headers, request.format,
- 'NoApplicableCode', msg)
-
- headers['Content-Type'] = formatter.mimetype
-
- if p.filename is None:
- filename = f'{dataset}.csv'
- else:
- filename = f'{p.filename}'
-
- cd = f'attachment; filename="{filename}"'
- headers['Content-Disposition'] = cd
-
- return headers, HTTPStatus.OK, content
-
- elif request.format == F_JSONLD:
- content = geojson2jsonld(
- self, content, dataset, id_field=(p.uri_field or 'id')
- )
-
- return headers, HTTPStatus.OK, to_json(content, self.pretty_print)
-
- @gzip
- @pre_process
- def post_collection_items(
- self, request: Union[APIRequest, Any],
- dataset) -> Tuple[dict, int, str]:
- """
- Queries collection or filter an item
-
- :param request: A request object
- :param dataset: dataset name
-
- :returns: tuple of headers, status code, content
- """
-
- request_headers = request.headers
-
- if not request.is_valid(PLUGINS['formatter'].keys()):
- return self.get_format_exception(request)
-
- # Set Content-Language to system locale until provider locale
- # has been determined
- headers = request.get_response_headers(SYSTEM_LOCALE,
- **self.api_headers)
-
- properties = []
- reserved_fieldnames = ['bbox', 'f', 'limit', 'offset',
- 'resulttype', 'datetime', 'sortby',
- 'properties', 'skipGeometry', 'q',
- 'filter-lang', 'filter-crs']
-
- collections = filter_dict_by_key_value(self.config['resources'],
- 'type', 'collection')
-
- if dataset not in collections.keys():
- msg = 'Invalid collection'
- return self.get_exception(
- HTTPStatus.BAD_REQUEST, headers, request.format,
- 'InvalidParameterValue', msg)
-
- LOGGER.debug('Processing query parameters')
-
- LOGGER.debug('Processing offset parameter')
- try:
- offset = int(request.params.get('offset'))
- if offset < 0:
- msg = 'offset value should be positive or zero'
- return self.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 self.get_exception(
- HTTPStatus.BAD_REQUEST, headers, request.format,
- 'InvalidParameterValue', msg)
-
- LOGGER.debug('Processing limit parameter')
- try:
- limit = int(request.params.get('limit'))
- # TODO: We should do more validation, against the min and max
- # allowed by the server configuration
- if limit <= 0:
- msg = 'limit value should be strictly positive'
- return self.get_exception(
- HTTPStatus.BAD_REQUEST, headers, request.format,
- 'InvalidParameterValue', msg)
- except TypeError as err:
- LOGGER.warning(err)
- limit = int(self.config['server']['limit'])
- except ValueError:
- msg = 'limit value should be an integer'
- return self.get_exception(
- HTTPStatus.BAD_REQUEST, headers, request.format,
- 'InvalidParameterValue', msg)
-
- resulttype = request.params.get('resulttype') or 'results'
-
- LOGGER.debug('Processing bbox parameter')
-
- bbox = request.params.get('bbox')
-
- if bbox is None:
- bbox = []
- else:
- try:
- bbox = validate_bbox(bbox)
- except ValueError as err:
- msg = str(err)
- return self.get_exception(
- HTTPStatus.BAD_REQUEST, headers, request.format,
- 'InvalidParameterValue', msg)
-
- LOGGER.debug('Processing datetime parameter')
- datetime_ = request.params.get('datetime')
- try:
- datetime_ = validate_datetime(collections[dataset]['extents'],
- datetime_)
- except ValueError as err:
- msg = str(err)
- return self.get_exception(
- HTTPStatus.BAD_REQUEST, headers, request.format,
- 'InvalidParameterValue', msg)
-
- LOGGER.debug('processing q parameter')
- val = request.params.get('q')
-
- q = None
- if val is not None:
- q = val
-
- LOGGER.debug('Loading provider')
-
- try:
- provider_def = get_provider_by_type(
- collections[dataset]['providers'], 'feature')
- except ProviderTypeError:
- try:
- provider_def = get_provider_by_type(
- collections[dataset]['providers'], 'record')
- except ProviderTypeError:
- msg = 'Invalid provider type'
- return self.get_exception(
- HTTPStatus.BAD_REQUEST, headers, request.format,
- 'NoApplicableCode', msg)
-
- try:
- p = load_plugin('provider', provider_def)
- except ProviderGenericError as err:
- return self.get_exception(
- err.http_status_code, headers, request.format,
- err.ogc_exception_code, err.message)
-
- LOGGER.debug('processing property parameters')
- for k, v in request.params.items():
- if k not in reserved_fieldnames and k not in p.fields.keys():
- msg = f'unknown query parameter: {k}'
- return self.get_exception(
- HTTPStatus.BAD_REQUEST, headers, request.format,
- 'InvalidParameterValue', msg)
- elif k not in reserved_fieldnames and k in p.fields.keys():
- LOGGER.debug(f'Add property filter {k}={v}')
- properties.append((k, v))
-
- LOGGER.debug('processing sort parameter')
- val = request.params.get('sortby')
-
- if val is not None:
- sortby = []
- sorts = val.split(',')
- for s in sorts:
- prop = s
- order = '+'
- if s[0] in ['+', '-']:
- order = s[0]
- prop = s[1:]
-
- if prop not in p.fields.keys():
- msg = 'bad sort property'
- return self.get_exception(
- HTTPStatus.BAD_REQUEST, headers, request.format,
- 'InvalidParameterValue', msg)
-
- sortby.append({'property': prop, 'order': order})
- else:
- sortby = []
-
- LOGGER.debug('processing properties parameter')
- val = request.params.get('properties')
-
- if val is not None:
- select_properties = val.split(',')
- properties_to_check = set(p.properties) | set(p.fields.keys())
-
- if (len(list(set(select_properties) -
- set(properties_to_check))) > 0):
- msg = 'unknown properties specified'
- return self.get_exception(
- HTTPStatus.BAD_REQUEST, headers, request.format,
- 'InvalidParameterValue', msg)
- else:
- select_properties = []
-
- LOGGER.debug('processing skipGeometry parameter')
- val = request.params.get('skipGeometry')
- if val is not None:
- skip_geometry = str2bool(val)
- else:
- skip_geometry = False
-
- LOGGER.debug('Processing filter-crs parameter')
- filter_crs = request.params.get('filter-crs', DEFAULT_CRS)
- LOGGER.debug('Processing filter-lang parameter')
- filter_lang = request.params.get('filter-lang')
- if filter_lang != 'cql-json': # @TODO add check from the configuration
- msg = 'Invalid filter language'
- return self.get_exception(
- HTTPStatus.BAD_REQUEST, headers, request.format,
- 'InvalidParameterValue', msg)
-
- LOGGER.debug('Querying provider')
- LOGGER.debug(f'offset: {offset}')
- LOGGER.debug(f'limit: {limit}')
- LOGGER.debug(f'resulttype: {resulttype}')
- LOGGER.debug(f'sortby: {sortby}')
- LOGGER.debug(f'bbox: {bbox}')
- LOGGER.debug(f'datetime: {datetime_}')
- LOGGER.debug(f'properties: {select_properties}')
- LOGGER.debug(f'skipGeometry: {skip_geometry}')
- LOGGER.debug(f'q: {q}')
- LOGGER.debug(f'filter-lang: {filter_lang}')
- LOGGER.debug(f'filter-crs: {filter_crs}')
-
- LOGGER.debug('Processing headers')
-
- LOGGER.debug('Processing request content-type header')
- if (request_headers.get(
- 'Content-Type') or request_headers.get(
- 'content-type')) != 'application/query-cql-json':
- msg = ('Invalid body content-type')
- return self.get_exception(
- HTTPStatus.BAD_REQUEST, headers, request.format,
- 'InvalidHeaderValue', msg)
-
- LOGGER.debug('Processing body')
-
- if not request.data:
- msg = 'missing request data'
- return self.get_exception(
- HTTPStatus.BAD_REQUEST, headers, request.format,
- 'MissingParameterValue', msg)
-
- filter_ = None
- try:
- # Parse bytes data, if applicable
- data = request.data.decode()
- LOGGER.debug(data)
- except UnicodeDecodeError as err:
- LOGGER.error(err)
- msg = 'Unicode error in data'
- return self.get_exception(
- HTTPStatus.BAD_REQUEST, headers, request.format,
- 'InvalidParameterValue', msg)
-
- # FIXME: remove testing backend in use once CQL support is normalized
- if p.name == 'PostgreSQL':
- LOGGER.debug('processing PostgreSQL CQL_JSON data')
- try:
- filter_ = parse_cql_json(data)
- filter_ = modify_pygeofilter(
- filter_,
- filter_crs_uri=filter_crs,
- storage_crs_uri=provider_def.get('storage_crs'),
- geometry_column_name=provider_def.get('geom_field')
- )
- except Exception as err:
- LOGGER.error(err)
- msg = f'Bad CQL string : {data}'
- return self.get_exception(
- HTTPStatus.BAD_REQUEST, headers, request.format,
- 'InvalidParameterValue', msg)
- else:
- LOGGER.debug('processing Elasticsearch CQL_JSON data')
- try:
- filter_ = CQLModel.model_validate_json(data)
- except Exception as err:
- LOGGER.error(err)
- msg = f'Bad CQL string : {data}'
- return self.get_exception(
- HTTPStatus.BAD_REQUEST, headers, request.format,
- 'InvalidParameterValue', msg)
-
- try:
- content = p.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,
- filterq=filter_)
- except ProviderGenericError as err:
- LOGGER.error(err)
- return self.get_exception(
- err.http_status_code, headers, request.format,
- err.ogc_exception_code, err.message)
-
- return headers, HTTPStatus.OK, to_json(content, self.pretty_print)
-
- @gzip
- @pre_process
- def manage_collection_item(
- self, request: Union[APIRequest, Any],
- action, dataset, identifier=None) -> Tuple[dict, int, str]:
- """
- Adds an item to a collection
-
- :param request: A request object
- :param action: an action among 'create', 'update', 'delete', 'options'
- :param dataset: dataset name
-
- :returns: tuple of headers, status code, content
- """
-
- if not request.is_valid(PLUGINS['formatter'].keys()):
- return self.get_format_exception(request)
-
- # Set Content-Language to system locale until provider locale
- # has been determined
- headers = request.get_response_headers(SYSTEM_LOCALE,
- **self.api_headers)
-
- collections = filter_dict_by_key_value(self.config['resources'],
- 'type', 'collection')
-
- if dataset not in collections.keys():
- msg = 'Collection not found'
- LOGGER.error(msg)
- return self.get_exception(
- HTTPStatus.NOT_FOUND, headers, request.format, 'NotFound', msg)
-
- LOGGER.debug('Loading provider')
- try:
- provider_def = get_provider_by_type(
- collections[dataset]['providers'], 'feature')
- p = load_plugin('provider', provider_def)
- except ProviderTypeError:
- try:
- provider_def = get_provider_by_type(
- collections[dataset]['providers'], 'record')
- p = load_plugin('provider', provider_def)
- except ProviderTypeError:
- msg = 'Invalid provider type'
- LOGGER.error(msg)
- return self.get_exception(
- HTTPStatus.BAD_REQUEST, headers, request.format,
- 'InvalidParameterValue', msg)
-
- if action == 'options':
- headers['Allow'] = 'HEAD, GET'
- if p.editable:
- if identifier is None:
- headers['Allow'] += ', POST'
- else:
- headers['Allow'] += ', PUT, DELETE'
- return headers, HTTPStatus.OK, ''
-
- if not p.editable:
- msg = 'Collection is not editable'
- LOGGER.error(msg)
- return self.get_exception(
- HTTPStatus.BAD_REQUEST, headers, request.format,
- 'InvalidParameterValue', msg)
-
- if action in ['create', 'update'] and not request.data:
- msg = 'No data found'
- LOGGER.error(msg)
- return self.get_exception(
- HTTPStatus.BAD_REQUEST, headers, request.format,
- 'InvalidParameterValue', msg)
-
- if action == 'create':
- LOGGER.debug('Creating item')
- try:
- identifier = p.create(request.data)
- except TypeError as err:
- msg = str(err)
- return self.get_exception(
- HTTPStatus.BAD_REQUEST, headers, request.format,
- 'InvalidParameterValue', msg)
- except ProviderGenericError as err:
- LOGGER.error(err)
- return self.get_exception(
- err.http_status_code, headers, request.format,
- err.ogc_exception_code, err.message)
-
- headers['Location'] = f'{self.get_collections_url()}/{dataset}/items/{identifier}' # noqa
-
- return headers, HTTPStatus.CREATED, ''
-
- if action == 'update':
- LOGGER.debug('Updating item')
- try:
- _ = p.update(identifier, request.data)
- except TypeError as err:
- msg = str(err)
- return self.get_exception(
- HTTPStatus.BAD_REQUEST, headers, request.format,
- 'InvalidParameterValue', msg)
- except ProviderGenericError as err:
- LOGGER.error(err)
- return self.get_exception(
- err.http_status_code, headers, request.format,
- err.ogc_exception_code, err.message)
-
- return headers, HTTPStatus.NO_CONTENT, ''
-
- if action == 'delete':
- LOGGER.debug('Deleting item')
- try:
- _ = p.delete(identifier)
- except ProviderGenericError as err:
- LOGGER.error(err)
- return self.get_exception(
- err.http_status_code, headers, request.format,
- err.ogc_exception_code, err.message)
-
- return headers, HTTPStatus.OK, ''
-
- @gzip
- @pre_process
- def get_collection_item(self, request: Union[APIRequest, Any],
- dataset, identifier) -> Tuple[dict, int, str]:
- """
- Get a single collection item
-
- :param request: A request object
- :param dataset: dataset name
- :param identifier: item identifier
-
- :returns: tuple of headers, status code, content
- """
-
- if not request.is_valid():
- return self.get_format_exception(request)
-
- # Set Content-Language to system locale until provider locale
- # has been determined
- headers = request.get_response_headers(SYSTEM_LOCALE,
- **self.api_headers)
-
- LOGGER.debug('Processing query parameters')
-
- collections = filter_dict_by_key_value(self.config['resources'],
- 'type', 'collection')
-
- if dataset not in collections.keys():
- msg = 'Collection not found'
- return self.get_exception(
- HTTPStatus.NOT_FOUND, headers, request.format, 'NotFound', msg)
-
- LOGGER.debug('Loading provider')
-
- try:
- provider_type = 'feature'
- provider_def = get_provider_by_type(
- collections[dataset]['providers'], provider_type)
- p = load_plugin('provider', provider_def)
- except ProviderTypeError:
- try:
- provider_type = 'record'
- provider_def = get_provider_by_type(
- collections[dataset]['providers'], provider_type)
- p = load_plugin('provider', provider_def)
- except ProviderTypeError:
- msg = 'Invalid provider type'
- return self.get_exception(
- HTTPStatus.BAD_REQUEST, headers, request.format,
- 'InvalidParameterValue', msg)
- except ProviderGenericError as err:
- LOGGER.error(err)
- return self.get_exception(
- err.http_status_code, headers, request.format,
- err.ogc_exception_code, err.message)
-
- crs_transform_spec = None
- if provider_type == 'feature':
- # crs query parameter is only available for OGC API - Features
- # right now, not for OGC API - Records.
- LOGGER.debug('Processing crs parameter')
- query_crs_uri = request.params.get('crs')
- try:
- crs_transform_spec = self._create_crs_transform_spec(
- provider_def, query_crs_uri,
- )
- except (ValueError, CRSError) as err:
- msg = str(err)
- return self.get_exception(
- HTTPStatus.BAD_REQUEST, headers, request.format,
- 'InvalidParameterValue', msg)
- self._set_content_crs_header(headers, provider_def, query_crs_uri)
-
- # Get provider language (if any)
- prv_locale = l10n.get_plugin_locale(provider_def, request.raw_locale)
-
- try:
- LOGGER.debug(f'Fetching id {identifier}')
- content = p.get(
- identifier,
- language=prv_locale,
- crs_transform_spec=crs_transform_spec,
- )
- except ProviderGenericError as err:
- LOGGER.error(err)
- return self.get_exception(
- err.http_status_code, headers, request.format,
- err.ogc_exception_code, err.message)
-
- if content is None:
- msg = 'identifier not found'
- return self.get_exception(HTTPStatus.BAD_REQUEST, headers,
- request.format, 'NotFound', msg)
-
- uri = content['properties'].get(p.uri_field) if p.uri_field else \
- f'{self.get_collections_url()}/{dataset}/items/{identifier}'
-
- if 'links' not in content:
- content['links'] = []
-
- content['links'].extend([{
- 'type': FORMAT_TYPES[F_JSON],
- 'rel': 'root',
- 'title': 'The landing page of this server as JSON',
- 'href': f"{self.base_url}?f={F_JSON}"
- }, {
- 'type': FORMAT_TYPES[F_HTML],
- 'rel': 'root',
- 'title': 'The landing page of this server as HTML',
- 'href': f"{self.base_url}?f={F_HTML}"
- }, {
- 'rel': request.get_linkrel(F_JSON),
- 'type': 'application/geo+json',
- 'title': 'This document as GeoJSON',
- 'href': f'{uri}?f={F_JSON}'
- }, {
- 'rel': request.get_linkrel(F_JSONLD),
- 'type': FORMAT_TYPES[F_JSONLD],
- 'title': 'This document as RDF (JSON-LD)',
- 'href': f'{uri}?f={F_JSONLD}'
- }, {
- 'rel': request.get_linkrel(F_HTML),
- 'type': FORMAT_TYPES[F_HTML],
- 'title': 'This document as HTML',
- 'href': f'{uri}?f={F_HTML}'
- }, {
- 'rel': 'collection',
- 'type': FORMAT_TYPES[F_JSON],
- 'title': l10n.translate(collections[dataset]['title'],
- request.locale),
- 'href': f'{self.get_collections_url()}/{dataset}'
- }])
-
- link_request_format = (
- request.format if request.format is not None else F_JSON
- )
- if 'prev' in content:
- content['links'].append({
- 'rel': 'prev',
- 'type': FORMAT_TYPES[link_request_format],
- 'href': f"{self.get_collections_url()}/{dataset}/items/{content['prev']}?f={link_request_format}" # noqa
- })
- if 'next' in content:
- content['links'].append({
- 'rel': 'next',
- 'type': FORMAT_TYPES[link_request_format],
- 'href': f"{self.get_collections_url()}/{dataset}/items/{content['next']}?f={link_request_format}" # noqa
- })
-
- # Set response language to requested provider locale
- # (if it supports language) and/or otherwise the requested pygeoapi
- # locale (or fallback default locale)
- l10n.set_response_language(headers, prv_locale, request.locale)
-
- # GA customisation - Add PID Link
- if collections[dataset]['other']:
- content['pid'] = f'{collections[dataset]["other"]["href"]}{identifier}'
- # End GA customisation
-
- if request.format == F_HTML: # render
- content['title'] = l10n.translate(collections[dataset]['title'],
- request.locale)
- content['id_field'] = p.id_field
- if p.uri_field is not None:
- content['uri_field'] = p.uri_field
- if p.title_field is not None:
- content['title_field'] = l10n.translate(p.title_field,
- request.locale)
- content['collections_path'] = self.get_collections_url()
-
- content = render_j2_template(self.tpl_config,
- 'collections/items/item.html',
- content, request.locale)
- return headers, HTTPStatus.OK, content
-
- elif request.format == F_JSONLD:
- content = geojson2jsonld(
- self, content, dataset, uri, (p.uri_field or 'id')
- )
-
- return headers, HTTPStatus.OK, to_json(content, self.pretty_print)
-
- @pre_process
- @jsonldify
- def get_collection_coverage(self, request: Union[APIRequest, Any],
- dataset) -> Tuple[dict, int, str]:
- """
- Returns a subset of a collection coverage
-
- :param request: A request object
- :param dataset: dataset name
-
- :returns: tuple of headers, status code, content
- """
-
- query_args = {}
- format_ = request.format or F_JSON
-
- # Force response content type and language (en-US only) headers
- headers = request.get_response_headers(SYSTEM_LOCALE,
- **self.api_headers)
-
- LOGGER.debug('Loading provider')
- try:
- collection_def = get_provider_by_type(
- self.config['resources'][dataset]['providers'], 'coverage')
-
- p = load_plugin('provider', collection_def)
- except KeyError:
- msg = 'collection does not exist'
- return self.get_exception(
- HTTPStatus.NOT_FOUND, headers, format_,
- 'InvalidParameterValue', msg)
- except ProviderGenericError as err:
- LOGGER.error(err)
- return self.get_exception(
- err.http_status_code, headers, request.format,
- err.ogc_exception_code, err.message)
-
- LOGGER.debug('Processing bbox parameter')
-
- bbox = request.params.get('bbox')
-
- if bbox is None:
- bbox = []
- else:
- try:
- bbox = validate_bbox(bbox)
- except ValueError as err:
- msg = str(err)
- return self.get_exception(
- HTTPStatus.INTERNAL_SERVER_ERROR, headers, format_,
- 'InvalidParameterValue', msg)
-
- query_args['bbox'] = bbox
-
- LOGGER.debug('Processing bbox-crs parameter')
-
- bbox_crs = request.params.get('bbox-crs')
- if bbox_crs is not None:
- query_args['bbox_crs'] = bbox_crs
-
- LOGGER.debug('Processing datetime parameter')
-
- datetime_ = request.params.get('datetime')
-
- try:
- datetime_ = validate_datetime(
- self.config['resources'][dataset]['extents'], datetime_)
- except ValueError as err:
- msg = str(err)
- return self.get_exception(
- HTTPStatus.BAD_REQUEST, headers, format_,
- 'InvalidParameterValue', msg)
-
- query_args['datetime_'] = datetime_
- query_args['format_'] = format_
-
- properties = request.params.get('properties')
- if properties:
- LOGGER.debug('Processing properties parameter')
- query_args['properties'] = [rs for
- rs in properties.split(',') if rs]
- LOGGER.debug(f"Fields: {query_args['properties']}")
-
- for a in query_args['properties']:
- if a not in p.fields:
- msg = 'Invalid field specified'
- return self.get_exception(
- HTTPStatus.BAD_REQUEST, headers, format_,
- 'InvalidParameterValue', msg)
-
- if 'subset' in request.params:
- LOGGER.debug('Processing subset parameter')
- try:
- subsets = validate_subset(request.params['subset'] or '')
- except (AttributeError, ValueError) as err:
- msg = f'Invalid subset: {err}'
- LOGGER.error(msg)
- return self.get_exception(
- HTTPStatus.BAD_REQUEST, headers, format_,
- 'InvalidParameterValue', msg)
-
- if not set(subsets.keys()).issubset(p.axes):
- msg = 'Invalid axis name'
- LOGGER.error(msg)
- return self.get_exception(
- HTTPStatus.BAD_REQUEST, headers, format_,
- 'InvalidParameterValue', msg)
-
- query_args['subsets'] = subsets
- LOGGER.debug(f"Subsets: {query_args['subsets']}")
-
- LOGGER.debug('Querying coverage')
- try:
- data = p.query(**query_args)
- except ProviderGenericError as err:
- LOGGER.error(err)
- return self.get_exception(
- err.http_status_code, headers, request.format,
- err.ogc_exception_code, err.message)
-
- mt = collection_def['format']['name']
- if format_ == mt: # native format
- if p.filename is not None:
- cd = f'attachment; filename="{p.filename}"'
- headers['Content-Disposition'] = cd
-
- headers['Content-Type'] = collection_def['format']['mimetype']
- return headers, HTTPStatus.OK, data
- elif format_ == F_JSON:
- headers['Content-Type'] = 'application/prs.coverage+json'
- return headers, HTTPStatus.OK, to_json(data, self.pretty_print)
- else:
- return self.get_format_exception(request)
-
- @gzip
- @pre_process
- @jsonldify
- def get_collection_coverage_domainset(
- self, request: Union[APIRequest, Any],
- dataset) -> Tuple[dict, int, str]:
- """
- Returns a collection coverage domainset
-
- :param request: A request object
- :param dataset: dataset name
-
- :returns: tuple of headers, status code, content
- """
-
- format_ = request.format or F_JSON
- headers = request.get_response_headers(**self.api_headers)
-
- LOGGER.debug('Loading provider')
- try:
- collection_def = get_provider_by_type(
- self.config['resources'][dataset]['providers'], 'coverage')
-
- p = load_plugin('provider', collection_def)
-
- data = p.get_coverage_domainset()
- except KeyError:
- msg = 'collection does not exist'
- return self.get_exception(
- HTTPStatus.NOT_FOUND, headers, format_,
- 'InvalidParameterValue', msg)
- except ProviderGenericError as err:
- LOGGER.error(err)
- return self.get_exception(
- err.http_status_code, headers, request.format,
- err.ogc_exception_code, err.message)
-
- if format_ == F_JSON:
- return headers, HTTPStatus.OK, to_json(data, self.pretty_print)
-
- elif format_ == F_HTML:
- data['id'] = dataset
- data['title'] = l10n.translate(
- self.config['resources'][dataset]['title'],
- self.default_locale)
- data['collections_path'] = self.get_collections_url()
- content = render_j2_template(self.tpl_config,
- 'collections/coverage/domainset.html',
- data, self.default_locale)
- return headers, HTTPStatus.OK, content
- else:
- return self.get_format_exception(request)
-
- @gzip
- @pre_process
- @jsonldify
- def get_collection_coverage_rangetype(
- self, request: Union[APIRequest, Any],
- dataset) -> Tuple[dict, int, str]:
- """
- Returns a collection coverage rangetype
-
- :param request: A request object
- :param dataset: dataset name
-
- :returns: tuple of headers, status code, content
- """
- format_ = request.format or F_JSON
- headers = request.get_response_headers(self.default_locale,
- **self.api_headers)
- LOGGER.debug('Loading provider')
- try:
- collection_def = get_provider_by_type(
- self.config['resources'][dataset]['providers'], 'coverage')
-
- p = load_plugin('provider', collection_def)
-
- data = p.get_coverage_rangetype()
- except KeyError:
- msg = 'collection does not exist'
- return self.get_exception(
- HTTPStatus.NOT_FOUND, headers, format_,
- 'InvalidParameterValue', msg)
- except ProviderGenericError as err:
- LOGGER.error(err)
- return self.get_exception(
- err.http_status_code, headers, request.format,
- err.ogc_exception_code, err.message)
-
- if format_ == F_JSON:
- return headers, HTTPStatus.OK, to_json(data, self.pretty_print)
-
- elif format_ == F_HTML:
- data['id'] = dataset
- data['title'] = l10n.translate(
- self.config['resources'][dataset]['title'],
- self.default_locale)
- data['collections_path'] = self.get_collections_url()
- content = render_j2_template(self.tpl_config,
- 'collections/coverage/rangetype.html',
- data, self.default_locale)
- return headers, HTTPStatus.OK, content
- else:
- return self.get_format_exception(request)
-
- @gzip
- @pre_process
- @jsonldify
- def get_collection_tiles(self, request: Union[APIRequest, Any],
- dataset=None) -> Tuple[dict, int, str]:
- """
- Provide collection tiles
-
- :param request: A request object
- :param dataset: name of collection
-
- :returns: tuple of headers, status code, content
- """
-
- if not request.is_valid():
- return self.get_format_exception(request)
- headers = request.get_response_headers(SYSTEM_LOCALE,
- **self.api_headers)
- if any([dataset is None,
- dataset not in self.config['resources'].keys()]):
-
- msg = 'Collection not found'
- return self.get_exception(
- HTTPStatus.NOT_FOUND, headers, request.format, 'NotFound', msg)
-
- LOGGER.debug('Creating collection tiles')
- LOGGER.debug('Loading provider')
- try:
- t = get_provider_by_type(
- self.config['resources'][dataset]['providers'], 'tile')
- p = load_plugin('provider', t)
- except (KeyError, ProviderTypeError):
- msg = 'Invalid collection tiles'
- return self.get_exception(
- HTTPStatus.BAD_REQUEST, headers, request.format,
- 'InvalidParameterValue', msg)
- except ProviderGenericError as err:
- LOGGER.error(err)
- return self.get_exception(
- err.http_status_code, headers, request.format,
- err.ogc_exception_code, err.message)
-
- tiles = {
- 'links': [],
- 'tilesets': []
- }
-
- tiles['links'].append({
- 'type': FORMAT_TYPES[F_JSON],
- 'rel': request.get_linkrel(F_JSON),
- 'title': 'This document as JSON',
- 'href': f'{self.get_collections_url()}/{dataset}/tiles?f={F_JSON}'
- })
- tiles['links'].append({
- 'type': FORMAT_TYPES[F_JSONLD],
- 'rel': request.get_linkrel(F_JSONLD),
- 'title': 'This document as RDF (JSON-LD)',
- 'href': f'{self.get_collections_url()}/{dataset}/tiles?f={F_JSONLD}' # noqa
- })
- tiles['links'].append({
- 'type': FORMAT_TYPES[F_HTML],
- 'rel': request.get_linkrel(F_HTML),
- 'title': 'This document as HTML',
- 'href': f'{self.get_collections_url()}/{dataset}/tiles?f={F_HTML}'
- })
-
- tile_services = p.get_tiles_service(
- baseurl=self.base_url,
- servicepath=f'{self.get_collections_url()}/{dataset}/tiles/{{tileMatrixSetId}}/{{tileMatrix}}/{{tileRow}}/{{tileCol}}?f=mvt' # noqa
- )
-
- for service in tile_services['links']:
- tiles['links'].append(service)
-
- tiling_schemes = p.get_tiling_schemes()
-
- for matrix in tiling_schemes:
- tile_matrix = {
- 'title': dataset,
- 'tileMatrixSetURI': matrix.tileMatrixSetURI,
- 'crs': matrix.crs,
- 'dataType': 'vector',
- 'links': []
- }
- tile_matrix['links'].append({
- 'type': FORMAT_TYPES[F_JSON],
- 'rel': 'http://www.opengis.net/def/rel/ogc/1.0/tiling-scheme',
- 'title': f'{matrix.tileMatrixSet} TileMatrixSet definition (as {F_JSON})', # noqa
- 'href': f'{self.base_url}/TileMatrixSets/{matrix.tileMatrixSet}?f={F_JSON}' # noqa
- })
- tile_matrix['links'].append({
- 'type': FORMAT_TYPES[F_JSON],
- 'rel': request.get_linkrel(F_JSON),
- 'title': f'{dataset} - {matrix.tileMatrixSet} - {F_JSON}',
- 'href': f'{self.get_collections_url()}/{dataset}/tiles/{matrix.tileMatrixSet}?f={F_JSON}' # noqa
- })
- tile_matrix['links'].append({
- 'type': FORMAT_TYPES[F_HTML],
- 'rel': request.get_linkrel(F_HTML),
- 'title': f'{dataset} - {matrix.tileMatrixSet} - {F_HTML}',
- 'href': f'{self.get_collections_url()}/{dataset}/tiles/{matrix.tileMatrixSet}?f={F_HTML}' # noqa
- })
-
- tiles['tilesets'].append(tile_matrix)
-
- if request.format == F_HTML: # render
- tiles['id'] = dataset
- tiles['title'] = l10n.translate(
- self.config['resources'][dataset]['title'], SYSTEM_LOCALE)
- tiles['tilesets'] = [
- scheme.tileMatrixSet for scheme in p.get_tiling_schemes()]
- tiles['bounds'] = \
- self.config['resources'][dataset]['extents']['spatial']['bbox']
- tiles['minzoom'] = p.options['zoom']['min']
- tiles['maxzoom'] = p.options['zoom']['max']
- tiles['collections_path'] = self.get_collections_url()
-
- content = render_j2_template(self.tpl_config,
- 'collections/tiles/index.html', tiles,
- request.locale)
-
- return headers, HTTPStatus.OK, content
-
- return headers, HTTPStatus.OK, to_json(tiles, self.pretty_print)
-
- @pre_process
- def get_collection_tiles_data(
- self, request: Union[APIRequest, Any],
- dataset=None, matrix_id=None,
- z_idx=None, y_idx=None, x_idx=None) -> Tuple[dict, int, str]:
- """
- Get collection items tiles
-
- :param request: A request object
- :param dataset: dataset name
- :param matrix_id: matrix identifier
- :param z_idx: z index
- :param y_idx: y index
- :param x_idx: x index
-
- :returns: tuple of headers, status code, content
- """
-
- format_ = request.format
- if not format_:
- return self.get_format_exception(request)
- headers = request.get_response_headers(SYSTEM_LOCALE,
- **self.api_headers)
- LOGGER.debug('Processing tiles')
-
- collections = filter_dict_by_key_value(self.config['resources'],
- 'type', 'collection')
-
- if dataset not in collections.keys():
- msg = 'Collection not found'
- return self.get_exception(
- HTTPStatus.NOT_FOUND, headers, request.format, 'NotFound', msg)
-
- LOGGER.debug('Loading tile provider')
- try:
- t = get_provider_by_type(
- self.config['resources'][dataset]['providers'], 'tile')
- p = load_plugin('provider', t)
-
- format_ = p.format_type
- headers['Content-Type'] = format_
-
- LOGGER.debug(f'Fetching tileset id {matrix_id} and tile {z_idx}/{y_idx}/{x_idx}') # noqa
- content = p.get_tiles(layer=p.get_layer(), tileset=matrix_id,
- z=z_idx, y=y_idx, x=x_idx, format_=format_)
- if content is None:
- msg = 'identifier not found'
- return self.get_exception(
- HTTPStatus.NOT_FOUND, headers, format_, 'NotFound', msg)
- else:
- return headers, HTTPStatus.OK, content
-
- # @TODO: figure out if the spec requires to return json errors
- except KeyError:
- msg = 'Invalid collection tiles'
- return self.get_exception(
- HTTPStatus.BAD_REQUEST, headers, format_,
- 'InvalidParameterValue', msg)
- except ProviderGenericError as err:
- LOGGER.error(err)
- return self.get_exception(
- err.http_status_code, headers, request.format,
- err.ogc_exception_code, err.message)
-
- @gzip
- @pre_process
- @jsonldify
- def get_collection_tiles_metadata(
- self, request: Union[APIRequest, Any],
- dataset=None, matrix_id=None) -> Tuple[dict, int, str]:
- """
- Get collection items tiles
-
- :param request: A request object
- :param dataset: dataset name
- :param matrix_id: matrix identifier
-
- :returns: tuple of headers, status code, content
- """
-
- if not request.is_valid([TilesMetadataFormat.TILEJSON]):
- return self.get_format_exception(request)
- headers = request.get_response_headers(**self.api_headers)
-
- if any([dataset is None,
- dataset not in self.config['resources'].keys()]):
-
- msg = 'Collection not found'
- return self.get_exception(
- HTTPStatus.NOT_FOUND, headers, request.format, 'NotFound', msg)
-
- LOGGER.debug('Creating collection tiles')
- LOGGER.debug('Loading provider')
- try:
- t = get_provider_by_type(
- self.config['resources'][dataset]['providers'], 'tile')
- p = load_plugin('provider', t)
- except KeyError:
- msg = 'Invalid collection tiles'
- return self.get_exception(
- HTTPStatus.BAD_REQUEST, headers, request.format,
- 'InvalidParameterValue', msg)
- except ProviderGenericError as err:
- LOGGER.error(err)
- return self.get_exception(
- err.http_status_code, headers, request.format,
- err.ogc_exception_code, err.message)
-
- # Get provider language (if any)
- prv_locale = l10n.get_plugin_locale(t, request.raw_locale)
-
- if matrix_id not in p.options['schemes']:
- msg = 'tileset not found'
- return self.get_exception(HTTPStatus.NOT_FOUND, headers,
- request.format, 'NotFound', msg)
-
- # Set response language to requested provider locale
- # (if it supports language) and/or otherwise the requested pygeoapi
- # locale (or fallback default locale)
- l10n.set_response_language(headers, prv_locale, request.locale)
-
- tiles_metadata = p.get_metadata(
- dataset=dataset, server_url=self.base_url,
- layer=p.get_layer(), tileset=matrix_id,
- metadata_format=request._format, title=l10n.translate(
- self.config['resources'][dataset]['title'],
- request.locale),
- description=l10n.translate(
- self.config['resources'][dataset]['description'],
- request.locale),
- language=prv_locale)
-
- if request.format == F_HTML: # render
- content = render_j2_template(self.tpl_config,
- 'collections/tiles/metadata.html',
- tiles_metadata, request.locale)
-
- return headers, HTTPStatus.OK, content
- else:
- return headers, HTTPStatus.OK, tiles_metadata
-
- @gzip
- @pre_process
- def get_collection_map(self, request: Union[APIRequest, Any],
- dataset, style=None) -> Tuple[dict, int, str]:
- """
- Returns a subset of a collection map
-
- :param request: A request object
- :param dataset: dataset name
- :param style: style name
-
- :returns: tuple of headers, status code, content
- """
-
- if not request.is_valid():
- return self.get_format_exception(request)
-
- query_args = {
- 'crs': 'CRS84'
- }
-
- format_ = request.format or 'png'
- headers = request.get_response_headers(**self.api_headers)
- LOGGER.debug('Processing query parameters')
-
- LOGGER.debug('Loading provider')
- try:
- collection_def = get_provider_by_type(
- self.config['resources'][dataset]['providers'], 'map')
-
- p = load_plugin('provider', collection_def)
- except KeyError:
- exception = {
- 'code': 'InvalidParameterValue',
- 'description': 'collection does not exist'
- }
- headers['Content-type'] = 'application/json'
- LOGGER.error(exception)
- return headers, HTTPStatus.NOT_FOUND, to_json(
- exception, self.pretty_print)
- except ProviderGenericError as err:
- LOGGER.error(err)
- return self.get_exception(
- err.http_status_code, headers, request.format,
- err.ogc_exception_code, err.message)
-
- query_args['format_'] = request.params.get('f', 'png')
- query_args['style'] = style
- query_args['crs'] = request.params.get('bbox-crs', 4326)
- query_args['transparent'] = request.params.get('transparent', True)
-
- try:
- query_args['width'] = int(request.params.get('width', 500))
- query_args['height'] = int(request.params.get('height', 300))
- except ValueError:
- exception = {
- 'code': 'InvalidParameterValue',
- 'description': 'invalid width/height'
- }
- headers['Content-type'] = 'application/json'
- LOGGER.error(exception)
- return headers, HTTPStatus.BAD_REQUEST, to_json(
- exception, self.pretty_print)
-
- LOGGER.debug('Processing bbox parameter')
- try:
- bbox = request.params.get('bbox').split(',')
- if len(bbox) != 4:
- exception = {
- 'code': 'InvalidParameterValue',
- 'description': 'bbox values should be minx,miny,maxx,maxy'
- }
- headers['Content-type'] = 'application/json'
- LOGGER.error(exception)
- return headers, HTTPStatus.BAD_REQUEST, to_json(
- exception, self.pretty_print)
- except AttributeError:
- bbox = self.config['resources'][dataset]['extents']['spatial']['bbox'] # noqa
- try:
- query_args['bbox'] = [float(c) for c in bbox]
- except ValueError:
- exception = {
- 'code': 'InvalidParameterValue',
- 'description': 'bbox values must be numbers'
- }
- headers['Content-type'] = 'application/json'
- LOGGER.error(exception)
- return headers, HTTPStatus.BAD_REQUEST, to_json(
- exception, self.pretty_print)
-
- LOGGER.debug('Processing datetime parameter')
- datetime_ = request.params.get('datetime')
- try:
- query_args['datetime_'] = validate_datetime(
- self.config['resources'][dataset]['extents'], datetime_)
- except ValueError as err:
- msg = str(err)
- return self.get_exception(
- HTTPStatus.BAD_REQUEST, headers, request.format,
- 'InvalidParameterValue', msg)
-
- LOGGER.debug('Generating map')
- try:
- data = p.query(**query_args)
- except ProviderGenericError as err:
- LOGGER.error(err)
- return self.get_exception(
- err.http_status_code, headers, request.format,
- err.ogc_exception_code, err.message)
-
- mt = collection_def['format']['name']
-
- if format_ == mt:
- headers['Content-Type'] = collection_def['format']['mimetype']
- return headers, HTTPStatus.OK, data
- elif format_ in [None, 'html']:
- headers['Content-Type'] = collection_def['format']['mimetype']
- return headers, HTTPStatus.OK, data
- else:
- exception = {
- 'code': 'InvalidParameterValue',
- 'description': 'invalid format parameter'
- }
- LOGGER.error(exception)
- return headers, HTTPStatus.BAD_REQUEST, to_json(
- data, self.pretty_print)
-
- @gzip
- def get_collection_map_legend(
- self, request: Union[APIRequest, Any],
- dataset, style=None) -> Tuple[dict, int, str]:
- """
- Returns a subset of a collection map legend
-
- :param request: A request object
- :param dataset: dataset name
- :param style: style name
-
- :returns: tuple of headers, status code, content
- """
-
- format_ = 'png'
- headers = request.get_response_headers(**self.api_headers)
- LOGGER.debug('Processing query parameters')
-
- LOGGER.debug('Loading provider')
- try:
- collection_def = get_provider_by_type(
- self.config['resources'][dataset]['providers'], 'map')
-
- p = load_plugin('provider', collection_def)
- except KeyError:
- exception = {
- 'code': 'InvalidParameterValue',
- 'description': 'collection does not exist'
- }
- LOGGER.error(exception)
- return headers, HTTPStatus.NOT_FOUND, to_json(
- exception, self.pretty_print)
- except ProviderGenericError as err:
- LOGGER.error(err)
- return self.get_exception(
- err.http_status_code, headers, request.format,
- err.ogc_exception_code, err.message)
-
- LOGGER.debug('Generating legend')
- try:
- data = p.get_legend(style, request.params.get('f', 'png'))
- except ProviderGenericError as err:
- LOGGER.error(err)
- return self.get_exception(
- err.http_status_code, headers, request.format,
- err.ogc_exception_code, err.message)
-
- mt = collection_def['format']['name']
-
- if format_ == mt:
- headers['Content-Type'] = collection_def['format']['mimetype']
- return headers, HTTPStatus.OK, data
- else:
- exception = {
- 'code': 'InvalidParameterValue',
- 'description': 'invalid format parameter'
- }
- LOGGER.error(exception)
- return headers, HTTPStatus.BAD_REQUEST, to_json(
- data, self.pretty_print)
-
- @gzip
- @pre_process
- @jsonldify
- def describe_processes(self, request: Union[APIRequest, Any],
- process=None) -> Tuple[dict, int, str]:
- """
- Provide processes metadata
-
- :param request: A request object
- :param process: process identifier, defaults to None to obtain
- information about all processes
-
- :returns: tuple of headers, status code, content
- """
-
- processes = []
-
- if not request.is_valid():
- return self.get_format_exception(request)
- headers = request.get_response_headers(**self.api_headers)
-
- if process is not None:
- if process not in self.manager.processes.keys():
- msg = 'Identifier not found'
- return self.get_exception(
- HTTPStatus.NOT_FOUND, headers,
- request.format, 'NoSuchProcess', msg)
-
- if len(self.manager.processes) > 0:
- if process is not None:
- relevant_processes = [process]
- else:
- LOGGER.debug('Processing limit parameter')
- try:
- limit = int(request.params.get('limit'))
-
- if limit <= 0:
- msg = 'limit value should be strictly positive'
- return self.get_exception(
- HTTPStatus.BAD_REQUEST, headers, request.format,
- 'InvalidParameterValue', msg)
-
- relevant_processes = list(self.manager.processes)[:limit]
- except TypeError:
- LOGGER.debug('returning all processes')
- relevant_processes = self.manager.processes.keys()
- except ValueError:
- msg = 'limit value should be an integer'
- return self.get_exception(
- HTTPStatus.BAD_REQUEST, headers, request.format,
- 'InvalidParameterValue', msg)
-
- for key in relevant_processes:
- p = self.manager.get_processor(key)
- p2 = l10n.translate_struct(deepcopy(p.metadata),
- request.locale)
- p2['id'] = key
-
- if process is None:
- p2.pop('inputs')
- p2.pop('outputs')
- p2.pop('example', None)
-
- p2['jobControlOptions'] = ['sync-execute']
- if self.manager.is_async:
- p2['jobControlOptions'].append('async-execute')
-
- p2['outputTransmission'] = ['value']
- p2['links'] = p2.get('links', [])
-
- jobs_url = f"{self.base_url}/jobs"
- process_url = f"{self.base_url}/processes/{key}"
-
- # TODO translation support
- link = {
- 'type': FORMAT_TYPES[F_JSON],
- 'rel': request.get_linkrel(F_JSON),
- 'href': f'{process_url}?f={F_JSON}',
- 'title': 'Process description as JSON',
- 'hreflang': self.default_locale
- }
- p2['links'].append(link)
-
- link = {
- 'type': FORMAT_TYPES[F_HTML],
- 'rel': request.get_linkrel(F_HTML),
- 'href': f'{process_url}?f={F_HTML}',
- 'title': 'Process description as HTML',
- 'hreflang': self.default_locale
- }
- p2['links'].append(link)
-
- link = {
- 'type': FORMAT_TYPES[F_HTML],
- 'rel': 'http://www.opengis.net/def/rel/ogc/1.0/job-list',
- 'href': f'{jobs_url}?f={F_HTML}',
- 'title': 'jobs for this process as HTML',
- 'hreflang': self.default_locale
- }
- p2['links'].append(link)
-
- link = {
- 'type': FORMAT_TYPES[F_JSON],
- 'rel': 'http://www.opengis.net/def/rel/ogc/1.0/job-list',
- 'href': f'{jobs_url}?f={F_JSON}',
- 'title': 'jobs for this process as JSON',
- 'hreflang': self.default_locale
- }
- p2['links'].append(link)
-
- link = {
- 'type': FORMAT_TYPES[F_JSON],
- 'rel': 'http://www.opengis.net/def/rel/ogc/1.0/execute',
- 'href': f'{process_url}/execution?f={F_JSON}',
- 'title': 'Execution for this process as JSON',
- 'hreflang': self.default_locale
- }
- p2['links'].append(link)
-
- processes.append(p2)
-
- if process is not None:
- response = processes[0]
- else:
- process_url = f"{self.base_url}/processes"
- response = {
- 'processes': processes,
- 'links': [{
- 'type': FORMAT_TYPES[F_JSON],
- 'rel': request.get_linkrel(F_JSON),
- 'title': 'This document as JSON',
- 'href': f'{process_url}?f={F_JSON}'
- }, {
- 'type': FORMAT_TYPES[F_JSONLD],
- 'rel': request.get_linkrel(F_JSONLD),
- 'title': 'This document as RDF (JSON-LD)',
- 'href': f'{process_url}?f={F_JSONLD}'
- }, {
- 'type': FORMAT_TYPES[F_HTML],
- 'rel': request.get_linkrel(F_HTML),
- 'title': 'This document as HTML',
- 'href': f'{process_url}?f={F_HTML}'
- }]
- }
-
- if request.format == F_HTML: # render
- if process is not None:
- response = render_j2_template(self.tpl_config,
- 'processes/process.html',
- response, request.locale)
- else:
- response = render_j2_template(self.tpl_config,
- 'processes/index.html', response,
- request.locale)
-
- return headers, HTTPStatus.OK, response
-
- return headers, HTTPStatus.OK, to_json(response, self.pretty_print)
-
- @gzip
- @pre_process
- def get_jobs(self, request: Union[APIRequest, Any],
- job_id=None) -> Tuple[dict, int, str]:
- """
- Get process jobs
-
- :param request: A request object
- :param job_id: id of job
-
- :returns: tuple of headers, status code, content
- """
-
- if not request.is_valid():
- return self.get_format_exception(request)
- headers = request.get_response_headers(SYSTEM_LOCALE,
- **self.api_headers)
- if job_id is None:
- jobs = sorted(self.manager.get_jobs(),
- key=lambda k: k['job_start_datetime'],
- reverse=True)
- else:
- try:
- jobs = [self.manager.get_job(job_id)]
- except JobNotFoundError:
- return self.get_exception(
- HTTPStatus.NOT_FOUND, headers, request.format,
- 'InvalidParameterValue', job_id)
-
- serialized_jobs = {
- 'jobs': [],
- 'links': [{
- 'href': f"{self.base_url}/jobs?f={F_HTML}",
- 'rel': request.get_linkrel(F_HTML),
- 'type': FORMAT_TYPES[F_HTML],
- 'title': 'Jobs list as HTML'
- }, {
- 'href': f"{self.base_url}/jobs?f={F_JSON}",
- 'rel': request.get_linkrel(F_JSON),
- 'type': FORMAT_TYPES[F_JSON],
- 'title': 'Jobs list as JSON'
- }]
- }
- for job_ in jobs:
- job2 = {
- 'processID': job_['process_id'],
- 'jobID': job_['identifier'],
- 'status': job_['status'],
- 'message': job_['message'],
- 'progress': job_['progress'],
- 'parameters': job_.get('parameters'),
- 'job_start_datetime': job_['job_start_datetime'],
- 'job_end_datetime': job_['job_end_datetime']
- }
-
- # TODO: translate
- if JobStatus[job_['status']] in (
- JobStatus.successful, JobStatus.running, JobStatus.accepted):
-
- job_result_url = f"{self.base_url}/jobs/{job_['identifier']}/results" # noqa
-
- job2['links'] = [{
- 'href': f'{job_result_url}?f={F_HTML}',
- 'rel': 'about',
- 'type': FORMAT_TYPES[F_HTML],
- 'title': f'results of job {job_id} as HTML'
- }, {
- 'href': f'{job_result_url}?f={F_JSON}',
- 'rel': 'about',
- 'type': FORMAT_TYPES[F_JSON],
- 'title': f'results of job {job_id} as JSON'
- }]
-
- if job_['mimetype'] not in (FORMAT_TYPES[F_JSON],
- FORMAT_TYPES[F_HTML]):
- job2['links'].append({
- 'href': job_result_url,
- 'rel': 'about',
- 'type': job_['mimetype'],
- 'title': f"results of job {job_id} as {job_['mimetype']}" # noqa
- })
-
- serialized_jobs['jobs'].append(job2)
-
- if job_id is None:
- j2_template = 'jobs/index.html'
- else:
- serialized_jobs = serialized_jobs['jobs'][0]
- j2_template = 'jobs/job.html'
-
- if request.format == F_HTML:
- data = {
- 'jobs': serialized_jobs,
- 'now': datetime.now(timezone.utc).strftime(DATETIME_FORMAT)
- }
- response = render_j2_template(self.tpl_config, j2_template, data,
- request.locale)
- return headers, HTTPStatus.OK, response
-
- return headers, HTTPStatus.OK, to_json(serialized_jobs,
- self.pretty_print)
-
- @gzip
- @pre_process
- def execute_process(self, request: Union[APIRequest, Any],
- process_id) -> Tuple[dict, int, str]:
- """
- Execute process
-
- :param request: A request object
- :param process_id: id of process
-
- :returns: tuple of headers, status code, content
- """
-
- if not request.is_valid():
- return self.get_format_exception(request)
-
- # Responses are always in US English only
- headers = request.get_response_headers(SYSTEM_LOCALE,
- **self.api_headers)
- if process_id not in self.manager.processes:
- msg = 'identifier not found'
- return self.get_exception(
- HTTPStatus.NOT_FOUND, headers,
- request.format, 'NoSuchProcess', msg)
-
- data = request.data
- if not data:
- # TODO not all processes require input, e.g. time-dependent or
- # random value generators
- msg = 'missing request data'
- return self.get_exception(
- HTTPStatus.BAD_REQUEST, headers, request.format,
- 'MissingParameterValue', msg)
-
- try:
- # Parse bytes data, if applicable
- data = data.decode()
- LOGGER.debug(data)
- except (UnicodeDecodeError, AttributeError):
- pass
-
- try:
- data = json.loads(data)
- except (json.decoder.JSONDecodeError, TypeError) as err:
- # Input does not appear to be valid JSON
- LOGGER.error(err)
- msg = 'invalid request data'
- return self.get_exception(
- HTTPStatus.BAD_REQUEST, headers, request.format,
- 'InvalidParameterValue', msg)
-
- data_dict = data.get('inputs', {})
- LOGGER.debug(data_dict)
-
- try:
- execution_mode = RequestedProcessExecutionMode(
- request.headers.get('Prefer', request.headers.get('prefer'))
- )
- except ValueError:
- execution_mode = None
- try:
- LOGGER.debug('Executing process')
- result = self.manager.execute_process(
- process_id, data_dict, execution_mode=execution_mode)
- job_id, mime_type, outputs, status, additional_headers = result
- headers.update(additional_headers or {})
- headers['Location'] = f'{self.base_url}/jobs/{job_id}'
- except ProcessorExecuteError as err:
- LOGGER.error(err)
- return self.get_exception(
- err.http_status_code, headers,
- request.format, err.ogc_exception_code, err.message)
-
- response = {}
- if status == JobStatus.failed:
- response = outputs
-
- if data.get('response', 'raw') == 'raw':
- headers['Content-Type'] = mime_type
- response = outputs
- elif status not in (JobStatus.failed, JobStatus.accepted):
- response['outputs'] = [outputs]
-
- if status == JobStatus.accepted:
- http_status = HTTPStatus.CREATED
- else:
- http_status = HTTPStatus.OK
-
- if mime_type == 'application/json':
- response2 = to_json(response, self.pretty_print)
- else:
- response2 = response
-
- return headers, http_status, response2
-
- @gzip
- @pre_process
- def get_job_result(self, request: Union[APIRequest, Any],
- job_id) -> Tuple[dict, int, str]:
- """
- Get result of job (instance of a process)
-
- :param request: A request object
- :param job_id: ID of job
-
- :returns: tuple of headers, status code, content
- """
-
- if not request.is_valid():
- return self.get_format_exception(request)
- headers = request.get_response_headers(SYSTEM_LOCALE,
- **self.api_headers)
- try:
- job = self.manager.get_job(job_id)
- except JobNotFoundError:
- return self.get_exception(
- HTTPStatus.NOT_FOUND, headers,
- request.format, 'NoSuchJob', job_id
- )
-
- status = JobStatus[job['status']]
-
- if status == JobStatus.running:
- msg = 'job still running'
- return self.get_exception(
- HTTPStatus.NOT_FOUND, headers,
- request.format, 'ResultNotReady', msg)
-
- elif status == JobStatus.accepted:
- # NOTE: this case is not mentioned in the specification
- msg = 'job accepted but not yet running'
- return self.get_exception(
- HTTPStatus.NOT_FOUND, headers,
- request.format, 'ResultNotReady', msg)
-
- elif status == JobStatus.failed:
- msg = 'job failed'
- return self.get_exception(
- HTTPStatus.BAD_REQUEST, headers, request.format,
- 'InvalidParameterValue', msg)
-
- try:
- mimetype, job_output = self.manager.get_job_result(job_id)
- except JobResultNotFoundError:
- return self.get_exception(
- HTTPStatus.INTERNAL_SERVER_ERROR, headers,
- request.format, 'JobResultNotFound', job_id
- )
-
- if mimetype not in (None, FORMAT_TYPES[F_JSON]):
- headers['Content-Type'] = mimetype
- content = job_output
- else:
- if request.format == F_JSON:
- content = json.dumps(job_output, sort_keys=True, indent=4,
- default=json_serial)
- else:
- # HTML
- headers['Content-Type'] = "text/html"
- data = {
- 'job': {'id': job_id},
- 'result': job_output
- }
- content = render_j2_template(
- self.config, 'jobs/results/index.html',
- data, request.locale)
-
- return headers, HTTPStatus.OK, content
-
- @pre_process
- def delete_job(
- self, request: Union[APIRequest, Any], job_id
- ) -> Tuple[dict, int, str]:
- """
- Delete a process job
-
- :param job_id: job identifier
-
- :returns: tuple of headers, status code, content
- """
- response_headers = request.get_response_headers(
- SYSTEM_LOCALE, **self.api_headers)
- try:
- success = self.manager.delete_job(job_id)
- except JobNotFoundError:
- return self.get_exception(
- HTTPStatus.NOT_FOUND, response_headers, request.format,
- 'NoSuchJob', job_id
- )
- else:
- if success:
- http_status = HTTPStatus.OK
- jobs_url = f"{self.base_url}/jobs"
-
- response = {
- 'jobID': job_id,
- 'status': JobStatus.dismissed.value,
- 'message': 'Job dismissed',
- 'progress': 100,
- 'links': [{
- 'href': jobs_url,
- 'rel': 'up',
- 'type': FORMAT_TYPES[F_JSON],
- 'title': 'The job list for the current process'
- }]
- }
- else:
- return self.get_exception(
- HTTPStatus.INTERNAL_SERVER_ERROR, response_headers,
- request.format, 'InternalError', job_id
- )
- LOGGER.info(response)
- # TODO: this response does not have any headers
- return {}, http_status, response
-
- @gzip
- @pre_process
- def get_collection_edr_query(
- self, request: Union[APIRequest, Any],
- dataset, instance, query_type) -> Tuple[dict, int, str]:
- """
- Queries collection EDR
-
- :param request: APIRequest instance with query params
- :param dataset: dataset name
- :param instance: instance name
- :param query_type: EDR query type
-
- :returns: tuple of headers, status code, content
- """
-
- if not request.is_valid(PLUGINS['formatter'].keys()):
- return self.get_format_exception(request)
- headers = request.get_response_headers(self.default_locale,
- **self.api_headers)
- collections = filter_dict_by_key_value(self.config['resources'],
- 'type', 'collection')
-
- if dataset not in collections.keys():
- msg = 'Collection not found'
- return self.get_exception(
- HTTPStatus.NOT_FOUND, headers, request.format, 'NotFound', msg)
-
- LOGGER.debug('Processing query parameters')
-
- LOGGER.debug('Processing datetime parameter')
- datetime_ = request.params.get('datetime')
- try:
- datetime_ = validate_datetime(collections[dataset]['extents'],
- datetime_)
- except ValueError as err:
- msg = str(err)
- return self.get_exception(
- HTTPStatus.BAD_REQUEST, headers, request.format,
- 'InvalidParameterValue', msg)
-
- LOGGER.debug('Processing parameter_names parameter')
- parameternames = request.params.get('parameter_names') or []
- if isinstance(parameternames, str):
- parameternames = parameternames.split(',')
-
- bbox = None
- if query_type == 'cube':
- LOGGER.debug('Processing cube bbox')
- try:
- bbox = validate_bbox(request.params.get('bbox'))
- if not bbox:
- raise ValueError('bbox parameter required by cube queries')
- except ValueError as err:
- return self.get_exception(
- HTTPStatus.BAD_REQUEST, headers, request.format,
- 'InvalidParameterValue', str(err))
-
- LOGGER.debug('Processing coords parameter')
- wkt = request.params.get('coords')
-
- if wkt:
- try:
- wkt = shapely_loads(wkt)
- except WKTReadingError:
- msg = 'invalid coords parameter'
- return self.get_exception(
- HTTPStatus.BAD_REQUEST, headers, request.format,
- 'InvalidParameterValue', msg)
- elif query_type != 'cube':
- msg = 'missing coords parameter'
- return self.get_exception(
- HTTPStatus.BAD_REQUEST, headers, request.format,
- 'InvalidParameterValue', msg)
-
- within = within_units = None
- if query_type == 'radius':
- LOGGER.debug('Processing within / within-units parameters')
- within = request.params.get('within')
- within_units = request.params.get('within-units')
-
- 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:
- LOGGER.error(err)
- return self.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 self.get_exception(
- HTTPStatus.BAD_REQUEST, headers,
- request.format, 'InvalidParameterValue', msg)
-
- if query_type not in p.get_query_types():
- msg = 'Unsupported query type'
- return self.get_exception(
- HTTPStatus.BAD_REQUEST, headers, request.format,
- 'InvalidParameterValue', msg)
-
- if parameternames and not any((fld['id'] in parameternames)
- for fld in p.get_fields()['field']):
- msg = 'Invalid parameter_names'
- return self.get_exception(
- HTTPStatus.BAD_REQUEST, headers, request.format,
- 'InvalidParameterValue', msg)
-
- query_args = dict(
- query_type=query_type,
- instance=instance,
- format_=request.format,
- datetime_=datetime_,
- select_properties=parameternames,
- wkt=wkt,
- z=z,
- bbox=bbox,
- within=within,
- within_units=within_units,
- limit=int(self.config['server']['limit'])
- )
-
- try:
- data = p.query(**query_args)
- except ProviderGenericError as err:
- LOGGER.error(err)
- return self.get_exception(
- err.http_status_code, headers, request.format,
- err.ogc_exception_code, err.message)
-
- if request.format == F_HTML: # render
- content = render_j2_template(self.tpl_config,
- 'collections/edr/query.html', data,
- self.default_locale)
- else:
- content = to_json(data, self.pretty_print)
-
- return headers, HTTPStatus.OK, content
-
- @gzip
- @pre_process
- @jsonldify
- def get_stac_root(
- self, request: Union[APIRequest, Any]) -> Tuple[dict, int, str]:
- """
- Provide STAC root page
-
- :param request: APIRequest instance with query params
-
- :returns: tuple of headers, status code, content
- """
-
- if not request.is_valid():
- return self.get_format_exception(request)
- headers = request.get_response_headers(**self.api_headers)
-
- id_ = 'pygeoapi-stac'
- stac_version = '1.0.0-rc.2'
- stac_url = f'{self.base_url}/stac'
-
- content = {
- 'id': id_,
- 'type': 'Catalog',
- 'stac_version': stac_version,
- 'title': l10n.translate(
- self.config['metadata']['identification']['title'],
- request.locale),
- 'description': l10n.translate(
- self.config['metadata']['identification']['description'],
- request.locale),
- 'links': []
- }
-
- stac_collections = filter_dict_by_key_value(self.config['resources'],
- 'type', 'stac-collection')
-
- for key, value in stac_collections.items():
- content['links'].append({
- 'rel': 'child',
- 'href': f'{stac_url}/{key}?f={F_JSON}',
- 'type': FORMAT_TYPES[F_JSON]
- })
- content['links'].append({
- 'rel': 'child',
- 'href': f'{stac_url}/{key}',
- 'type': FORMAT_TYPES[F_HTML]
- })
-
- if request.format == F_HTML: # render
- content = render_j2_template(self.tpl_config,
- 'stac/collection.html',
- content, request.locale)
- return headers, HTTPStatus.OK, content
-
- return headers, HTTPStatus.OK, to_json(content, self.pretty_print)
-
- @gzip
- @pre_process
- @jsonldify
- def get_stac_path(self, request: Union[APIRequest, Any],
- path) -> Tuple[dict, int, str]:
- """
- Provide STAC resource path
-
- :param request: APIRequest instance with query params
-
- :returns: tuple of headers, status code, content
- """
-
- if not request.is_valid():
- return self.get_format_exception(request)
- headers = request.get_response_headers(**self.api_headers)
-
- dataset = None
- LOGGER.debug(f'Path: {path}')
- dir_tokens = path.split('/')
- if dir_tokens:
- dataset = dir_tokens[0]
-
- stac_collections = filter_dict_by_key_value(self.config['resources'],
- 'type', 'stac-collection')
-
- if dataset not in stac_collections:
- msg = 'Collection not found'
- return self.get_exception(HTTPStatus.NOT_FOUND, headers,
- request.format, 'NotFound', msg)
-
- LOGGER.debug('Loading provider')
- try:
- p = load_plugin('provider', get_provider_by_type(
- stac_collections[dataset]['providers'], 'stac'))
- except ProviderConnectionError as err:
- LOGGER.error(err)
- msg = 'connection error (check logs)'
- return self.get_exception(
- HTTPStatus.INTERNAL_SERVER_ERROR, headers,
- request.format, 'NoApplicableCode', msg)
-
- id_ = f'{dataset}-stac'
- stac_version = '1.0.0-rc.2'
-
- content = {
- 'id': id_,
- 'type': 'Catalog',
- 'stac_version': stac_version,
- 'description': l10n.translate(
- stac_collections[dataset]['description'], request.locale),
- 'links': []
- }
- try:
- stac_data = p.get_data_path(
- f'{self.base_url}/stac',
- path,
- path.replace(dataset, '', 1)
- )
- except ProviderNotFoundError as err:
- LOGGER.error(err)
- msg = 'resource not found'
- return self.get_exception(HTTPStatus.NOT_FOUND, headers,
- request.format, 'NotFound', msg)
- except Exception as err:
- LOGGER.error(err)
- msg = 'data query error'
- return self.get_exception(
- HTTPStatus.INTERNAL_SERVER_ERROR, headers,
- request.format, 'NoApplicableCode', msg)
-
- if isinstance(stac_data, dict):
- content.update(stac_data)
- content['links'].extend(stac_collections[dataset]['links'])
-
- if request.format == F_HTML: # render
- content['path'] = path
- if 'assets' in content: # item view
- if content['type'] == 'Collection':
- content = render_j2_template(
- self.tpl_config,
- 'stac/collection_base.html',
- content,
- request.locale
- )
- elif content['type'] == 'Feature':
- content = render_j2_template(
- self.tpl_config,
- 'stac/item.html',
- content,
- request.locale
- )
- else:
- msg = f'Unknown STAC type {content.type}'
- LOGGER.error(msg)
- return self.get_exception(
- HTTPStatus.INTERNAL_SERVER_ERROR,
- headers,
- request.format,
- 'NoApplicableCode',
- msg)
- else:
- content = render_j2_template(self.tpl_config,
- 'stac/catalog.html',
- content, request.locale)
-
- return headers, HTTPStatus.OK, content
-
- return headers, HTTPStatus.OK, to_json(content, self.pretty_print)
-
- else: # send back file
- headers.pop('Content-Type', None)
- return headers, HTTPStatus.OK, stac_data
-
- def get_exception(self, status, headers, format_, code,
- description) -> Tuple[dict, int, str]:
- """
- Exception handler
-
- :param status: HTTP status code
- :param headers: dict of HTTP response headers
- :param format_: format string
- :param code: OGC API exception code
- :param description: OGC API exception code
-
- :returns: tuple of headers, status, and message
- """
-
- LOGGER.error(description)
- exception = {
- 'code': code,
- 'description': description
- }
-
- if format_ == F_HTML:
- headers['Content-Type'] = FORMAT_TYPES[F_HTML]
- content = render_j2_template(
- self.config, 'exception.html', exception, SYSTEM_LOCALE)
- else:
- content = to_json(exception, self.pretty_print)
-
- return headers, status, content
-
- def get_format_exception(self, request) -> Tuple[dict, int, str]:
- """
- Returns a format exception.
-
- :param request: An APIRequest instance.
-
- :returns: tuple of (headers, status, message)
- """
-
- # 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}'
- return self.get_exception(
- HTTPStatus.BAD_REQUEST, headers,
- request.format, 'InvalidParameterValue', msg)
-
- def get_collections_url(self):
- return f"{self.base_url}/collections"
-
- @staticmethod
- def _create_crs_transform_spec(
- config: dict,
- query_crs_uri: Optional[str] = None,
- ) -> Union[None, CrsTransformSpec]:
- """Create a `CrsTransformSpec` instance based on provider config and
- *crs* query parameter.
-
- :param config: Provider config dictionary.
- :type config: dict
- :param query_crs_uri: Uniform resource identifier of the coordinate
- reference system (CRS) specified in query parameter (if specified).
- :type query_crs_uri: str, optional
-
- :raises ValueError: Error raised if the CRS specified in the query
- parameter is not in the list of supported CRSs of the provider.
- :raises `CRSError`: Error raised if no CRS could be identified from the
- query *crs* parameter (URI).
-
- :returns: `CrsTransformSpec` instance if the CRS specified in query
- parameter differs from the storage CRS, else `None`.
- :rtype: Union[None, CrsTransformSpec]
- """
- # Get storage/default CRS for Collection.
- storage_crs_uri = config.get('storage_crs', DEFAULT_STORAGE_CRS)
-
- if not query_crs_uri:
- if storage_crs_uri in DEFAULT_CRS_LIST:
- # Could be that storageCRS is
- # http://www.opengis.net/def/crs/OGC/1.3/CRS84h
- query_crs_uri = storage_crs_uri
- else:
- query_crs_uri = DEFAULT_CRS
- LOGGER.debug(f'no crs parameter, using default: {query_crs_uri}')
-
- supported_crs_list = get_supported_crs_list(config, DEFAULT_CRS_LIST)
- # Check that the crs specified by the query parameter is supported.
- if query_crs_uri not in supported_crs_list:
- raise ValueError(
- f'CRS {query_crs_uri!r} not supported for this '
- 'collection. List of supported CRSs: '
- f'{", ".join(supported_crs_list)}.'
- )
- crs_out = get_crs_from_uri(query_crs_uri)
-
- storage_crs = get_crs_from_uri(storage_crs_uri)
- # Check if the crs specified in query parameter differs from the
- # storage crs.
- if str(storage_crs) != str(crs_out):
- LOGGER.debug(
- f'CRS transformation: {storage_crs} -> {crs_out}'
- )
- return CrsTransformSpec(
- source_crs_uri=storage_crs_uri,
- source_crs_wkt=storage_crs.to_wkt(),
- target_crs_uri=query_crs_uri,
- target_crs_wkt=crs_out.to_wkt(),
- )
- else:
- LOGGER.debug('No CRS transformation')
- return None
-
- @staticmethod
- def _set_content_crs_header(
- headers: dict,
- config: dict,
- query_crs_uri: Optional[str] = None,
- ):
- """Set the *Content-Crs* header in responses from providers of Feature
- type.
-
- :param headers: Response headers dictionary.
- :type headers: dict
- :param config: Provider config dictionary.
- :type config: dict
- :param query_crs_uri: Uniform resource identifier of the coordinate
- reference system specified in query parameter (if specified).
- :type query_crs_uri: str, optional
- """
- if query_crs_uri:
- content_crs_uri = query_crs_uri
- else:
- # If empty use default CRS
- storage_crs_uri = config.get('storage_crs', DEFAULT_STORAGE_CRS)
- if storage_crs_uri in DEFAULT_CRS_LIST:
- # Could be that storageCRS is one of the defaults like
- # http://www.opengis.net/def/crs/OGC/1.3/CRS84h
- content_crs_uri = storage_crs_uri
- else:
- content_crs_uri = DEFAULT_CRS
-
- headers['Content-Crs'] = f'<{content_crs_uri}>'
-
-
-def validate_bbox(value=None) -> list:
- """
- Helper function to validate bbox parameter
-
- :param value: `list` of minx, miny, maxx, maxy
-
- :returns: bbox as `list` of `float` values
- """
-
- if value is None:
- LOGGER.debug('bbox is empty')
- return []
-
- bbox = value.split(',')
-
- if len(bbox) not in [4, 6]:
- msg = 'bbox should be either 4 values (minx,miny,maxx,maxy) ' \
- 'or 6 values (minx,miny,minz,maxx,maxy,maxz)'
- LOGGER.debug(msg)
- raise ValueError(msg)
-
- try:
- bbox = [float(c) for c in bbox]
- except ValueError as err:
- msg = 'bbox values must be numbers'
- err.args = (msg,)
- LOGGER.debug(msg)
- raise
-
- if (len(bbox) == 4 and bbox[1] > bbox[3]) \
- or (len(bbox) == 6 and bbox[1] > bbox[4]):
- msg = 'miny should be less than maxy'
- LOGGER.debug(msg)
- raise ValueError(msg)
-
- if (len(bbox) == 4 and bbox[0] > bbox[2]) \
- or (len(bbox) == 6 and bbox[0] > bbox[3]):
- msg = 'minx is greater than maxx (possibly antimeridian bbox)'
- LOGGER.debug(msg)
-
- if len(bbox) == 6 and bbox[2] > bbox[5]:
- msg = 'minz should be less than maxz'
- LOGGER.debug(msg)
- raise ValueError(msg)
-
- return bbox
-
-
-def validate_datetime(resource_def, datetime_=None) -> str:
- """
- Helper function to validate temporal parameter
-
- :param resource_def: `dict` of configuration resource definition
- :param datetime_: `str` of datetime parameter
-
- :returns: `str` of datetime input, if valid
- """
-
- # TODO: pass datetime to query as a `datetime` object
- # we would need to ensure partial dates work accordingly
- # as well as setting '..' values to `None` so that underlying
- # providers can just assume a `datetime.datetime` object
- #
- # NOTE: needs testing when passing partials from API to backend
-
- datetime_invalid = False
-
- if datetime_ is not None and 'temporal' in resource_def:
-
- dateparse_begin = partial(dateparse, default=datetime.min)
- dateparse_end = partial(dateparse, default=datetime.max)
- unix_epoch = datetime(1970, 1, 1, 0, 0, 0)
- dateparse_ = partial(dateparse, default=unix_epoch)
-
- te = resource_def['temporal']
-
- try:
- if te['begin'] is not None and te['begin'].tzinfo is None:
- te['begin'] = te['begin'].replace(tzinfo=pytz.UTC)
- if te['end'] is not None and te['end'].tzinfo is None:
- te['end'] = te['end'].replace(tzinfo=pytz.UTC)
- except AttributeError:
- msg = 'Configured times should be RFC3339'
- LOGGER.error(msg)
- raise ValueError(msg)
-
- if '/' in datetime_: # envelope
- LOGGER.debug('detected time range')
- LOGGER.debug('Validating time windows')
-
- # normalize "" to ".." (actually changes datetime_)
- datetime_ = re.sub(r'^/', '../', datetime_)
- datetime_ = re.sub(r'/$', '/..', datetime_)
-
- datetime_begin, datetime_end = datetime_.split('/')
- if datetime_begin != '..':
- datetime_begin = dateparse_begin(datetime_begin)
- if datetime_begin.tzinfo is None:
- datetime_begin = datetime_begin.replace(
- tzinfo=pytz.UTC)
-
- if datetime_end != '..':
- datetime_end = dateparse_end(datetime_end)
- if datetime_end.tzinfo is None:
- datetime_end = datetime_end.replace(tzinfo=pytz.UTC)
-
- datetime_invalid = any([
- (te['end'] is not None and datetime_begin != '..' and
- datetime_begin > te['end']),
- (te['begin'] is not None and datetime_end != '..' and
- datetime_end < te['begin'])
- ])
-
- else: # time instant
- LOGGER.debug('detected time instant')
- datetime__ = dateparse_(datetime_)
- if datetime__ != '..':
- if datetime__.tzinfo is None:
- datetime__ = datetime__.replace(tzinfo=pytz.UTC)
- datetime_invalid = any([
- (te['begin'] is not None and datetime__ != '..' and
- datetime__ < te['begin']),
- (te['end'] is not None and datetime__ != '..' and
- datetime__ > te['end'])
- ])
-
- if datetime_invalid:
- msg = 'datetime parameter out of range'
- LOGGER.debug(msg)
- raise ValueError(msg)
-
- return datetime_
-
-
-def validate_subset(value: str) -> dict:
- """
- Helper function to validate subset parameter
-
- :param value: `subset` parameter
-
- :returns: dict of axis/values
- """
-
- subsets = {}
-
- for s in value.split(','):
- LOGGER.debug(f'Processing subset {s}')
- m = re.search(r'(.*)\((.*)\)', s)
- subset_name, values = m.group(1, 2)
-
- if '"' in values:
- LOGGER.debug('Values are strings')
- if values.count('"') % 2 != 0:
- msg = 'Invalid format: subset should be like axis("min"[:"max"])' # noqa
- LOGGER.error(msg)
- raise ValueError(msg)
- try:
- LOGGER.debug('Value is an interval')
- m = re.search(r'"(\S+)":"(\S+)"', values)
- values = list(m.group(1, 2))
- except AttributeError:
- LOGGER.debug('Value is point')
- m = re.search(r'"(.*)"', values)
- values = [m.group(1)]
- else:
- LOGGER.debug('Values are numbers')
- try:
- LOGGER.debug('Value is an interval')
- m = re.search(r'(\S+):(\S+)', values)
- values = list(m.group(1, 2))
- except AttributeError:
- LOGGER.debug('Value is point')
- values = [values]
-
- subsets[subset_name] = list(map(get_typed_value, values))
-
- return subsets
diff --git a/pygeoapi/api/__init__.py b/pygeoapi/api/__init__.py
new file mode 100644
index 000000000..c10ba82b7
--- /dev/null
+++ b/pygeoapi/api/__init__.py
@@ -0,0 +1,1673 @@
+# =================================================================
+#
+# Authors: Tom Kralidis
+# Francesco Bartoli
+# Sander Schaminee
+# John A Stevenson
+# Colin Blackburn
+# Ricardo Garcia Silva
+#
+# Copyright (c) 2025 Tom Kralidis
+# Copyright (c) 2022 Francesco Bartoli
+# Copyright (c) 2022 John A Stevenson and Colin Blackburn
+# Copyright (c) 2023 Ricardo Garcia Silva
+#
+# 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.
+#
+# =================================================================
+
+"""
+Root level code of pygeoapi, parsing content provided by web framework.
+Returns content from plugins and sets responses.
+"""
+
+from collections import ChainMap, OrderedDict
+from copy import deepcopy
+from datetime import datetime
+from functools import partial
+from gzip import compress
+from http import HTTPStatus
+import logging
+import re
+import sys
+from typing import Any, Tuple, Union, Optional
+
+from dateutil.parser import parse as dateparse
+import pytz
+
+from pygeoapi import __version__, l10n
+from pygeoapi.linked_data import jsonldify, jsonldify_collection
+from pygeoapi.log import setup_logger
+from pygeoapi.plugin import load_plugin
+from pygeoapi.process.manager.base import get_manager
+from pygeoapi.provider.base import (
+ ProviderConnectionError, ProviderGenericError, ProviderTypeError)
+
+from pygeoapi.util import (
+ CrsTransformSpec, TEMPLATES, UrlPrefetcher, dategetter,
+ filter_dict_by_key_value, filter_providers_by_type, get_api_rules,
+ get_base_url, get_provider_by_type, get_provider_default, get_typed_value,
+ get_crs_from_uri, get_supported_crs_list, render_j2_template, to_json
+)
+
+LOGGER = logging.getLogger(__name__)
+
+#: Return headers for requests (e.g:X-Powered-By)
+HEADERS = {
+ 'Content-Type': 'application/json',
+ 'X-Powered-By': f'pygeoapi {__version__}'
+}
+
+CHARSET = ['utf-8']
+F_JSON = 'json'
+F_COVERAGEJSON = 'json'
+F_HTML = 'html'
+F_JSONLD = 'jsonld'
+F_GZIP = 'gzip'
+F_PNG = 'png'
+F_JPEG = 'jpeg'
+F_MVT = 'mvt'
+F_NETCDF = 'NetCDF'
+
+#: Formats allowed for ?f= requests (order matters for complex MIME types)
+FORMAT_TYPES = OrderedDict((
+ (F_HTML, 'text/html'),
+ (F_JSONLD, 'application/ld+json'),
+ (F_JSON, 'application/json'),
+ (F_PNG, 'image/png'),
+ (F_JPEG, 'image/jpeg'),
+ (F_MVT, 'application/vnd.mapbox-vector-tile'),
+ (F_NETCDF, 'application/x-netcdf'),
+))
+
+#: Locale used for system responses (e.g. exceptions)
+SYSTEM_LOCALE = l10n.Locale('en', 'US')
+
+CONFORMANCE_CLASSES = [
+ 'http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/core',
+ 'http://www.opengis.net/spec/ogcapi-common-2/1.0/conf/collections',
+ 'http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/landing-page',
+ 'http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/json',
+ 'http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/html',
+ 'http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/oas30'
+]
+
+OGC_RELTYPES_BASE = 'http://www.opengis.net/def/rel/ogc/1.0'
+
+DEFAULT_CRS_LIST = [
+ 'http://www.opengis.net/def/crs/OGC/1.3/CRS84',
+ 'http://www.opengis.net/def/crs/OGC/1.3/CRS84h',
+]
+
+DEFAULT_CRS = 'http://www.opengis.net/def/crs/OGC/1.3/CRS84'
+DEFAULT_STORAGE_CRS = DEFAULT_CRS
+
+
+def all_apis() -> dict:
+ """
+ Return all supported API modules
+
+ NOTE: this is a function and not a constant to avoid import loops
+
+ :returns: `dict` of API provider type, API module
+ """
+
+ from . import (coverages, environmental_data_retrieval, itemtypes, maps,
+ processes, tiles, stac)
+
+ return {
+ 'coverage': coverages,
+ 'edr': environmental_data_retrieval,
+ 'itemtypes': itemtypes,
+ 'map': maps,
+ 'process': processes,
+ 'tile': tiles,
+ 'stac': stac
+ }
+
+
+def apply_gzip(headers: dict, content: Union[str, bytes]) -> Union[str, bytes]:
+ """
+ Compress content if requested in header.
+ """
+ charset = CHARSET[0]
+ if F_GZIP in headers.get('Content-Encoding', []):
+ try:
+ if isinstance(content, bytes):
+ # bytes means Content-Type needs to be set upstream
+ content = compress(content)
+ else:
+ headers['Content-Type'] = \
+ f"{headers['Content-Type']}; charset={charset}"
+ content = compress(content.encode(charset))
+ except TypeError as err:
+ headers.pop('Content-Encoding')
+ LOGGER.error(f'Error in compression: {err}')
+ return content
+
+
+class APIRequest:
+ """
+ Transforms an incoming server-specific Request into an object
+ with some generic helper methods and properties.
+
+ This allows writing straightforward API functions supporting all
+ web platforms such as with this example:
+
+ .. code-block:: python
+
+ def example_method(api: API, request: APIRequest, custom_arg):
+ headers = request.get_response_headers()
+
+ # generate response_body here
+
+ return headers, HTTPStatus.OK, response_body
+
+
+ Basic request validation is done automatically by web platform specific
+ adapters such as ``execute_from_flask`` in flask_app.py . If you want to
+ support custom formats (e.g. ``f=xml``), it needs to be registered with
+ `skip_valid_check=True` and you can use the following code for custom
+ validation. If `xml` was requested, we set the `Content-Type` ourselves.
+ For the standard formats, the `APIRequest` object sets the `Content-Type`.
+
+ .. code-block:: python
+
+ def example_method(api: API, request: APIRequest, custom_arg):
+ if not request.is_valid(['xml']):
+ return api.get_format_exception(request)
+
+ content_type = 'application/xml' if request.format == 'xml' else None
+ headers = request.get_response_headers(content_type)
+
+ # generate response_body here
+
+ return headers, HTTPStatus.OK, response_body
+
+ Note that you don't *have* to call :meth:`is_valid`, but that you can also
+ perform a custom check on the requested output format by looking at the
+ :attr:`format` property.
+ Other query parameters are available through the :attr:`params` property as
+ a `dict`. The request body is available through the :attr:`data` property.
+
+ :param request: The web platform specific Request instance.
+ :param supported_locales: List or set of supported Locale instances.
+ """
+ def __init__(self, request, supported_locales):
+ # Set default request data
+ self._data = b''
+
+ # Copy request query parameters
+ self._args = self._get_params(request)
+
+ # Get path info
+ if hasattr(request, 'scope'):
+ self._path_info = request.scope['path'].strip('/')
+ elif hasattr(request.headers, 'environ'):
+ self._path_info = request.headers.environ['PATH_INFO'].strip('/')
+ elif hasattr(request, 'path_info'):
+ self._path_info = request.path_info
+
+ # Extract locale from params or headers
+ self._raw_locale, self._locale = self._get_locale(request.headers,
+ supported_locales)
+
+ # Determine format
+ self._format = self._get_format(request.headers)
+
+ # Get received headers
+ self._headers = self.get_request_headers(request.headers)
+
+ @classmethod
+ def from_flask(cls, request, supported_locales) -> 'APIRequest':
+ """Factory class similar to with_data, but only for flask requests"""
+ api_req = cls(request, supported_locales)
+ api_req._data = request.data
+ return api_req
+
+ @classmethod
+ async def from_starlette(cls, request, supported_locales) -> 'APIRequest':
+ """Factory class similar to with_data, but only for starlette requests
+ """
+ api_req = cls(request, supported_locales)
+ api_req._data = await request.body()
+ return api_req
+
+ @classmethod
+ def from_django(cls, request, supported_locales) -> 'APIRequest':
+ """Factory class similar to with_data, but only for django requests"""
+ api_req = cls(request, supported_locales)
+ api_req._data = request.body
+ return api_req
+
+ @staticmethod
+ def _get_params(request):
+ """
+ Extracts the query parameters from the `Request` object.
+
+ :param request: A Flask or Starlette Request instance
+ :returns: `ImmutableMultiDict` or empty `dict`
+ """
+
+ if hasattr(request, 'args'):
+ # Return ImmutableMultiDict from Flask request
+ return request.args
+ elif hasattr(request, 'query_params'):
+ # Return ImmutableMultiDict from Starlette request
+ return request.query_params
+ elif hasattr(request, 'GET'):
+ # Return QueryDict from Django GET request
+ return request.GET
+ elif hasattr(request, 'POST'):
+ # Return QueryDict from Django GET request
+ return request.POST
+ LOGGER.debug('No query parameters found')
+ return {}
+
+ def _get_locale(self, headers, supported_locales):
+ """
+ Detects locale from "lang=" param or `Accept-Language`
+ header. Returns a tuple of (raw, locale) if found in params or headers.
+ Returns a tuple of (raw default, default locale) if not found.
+
+ :param headers: A dict with Request headers
+ :param supported_locales: List or set of supported Locale instances
+ :returns: A tuple of (str, Locale)
+ """
+
+ raw = None
+ try:
+ default_locale = l10n.str2locale(supported_locales[0])
+ except (TypeError, IndexError, l10n.LocaleError) as err:
+ # This should normally not happen, since the API class already
+ # loads the supported languages from the config, which raises
+ # a LocaleError if any of these languages are invalid.
+ LOGGER.error(err)
+ raise ValueError(f"{self.__class__.__name__} must be initialized"
+ f"with a list of valid supported locales")
+
+ for func, mapping in ((l10n.locale_from_params, self._args),
+ (l10n.locale_from_headers, headers)):
+ loc_str = func(mapping)
+ if loc_str:
+ if not raw:
+ # This is the first-found locale string: set as raw
+ raw = loc_str
+ # Check if locale string is a good match for the UI
+ loc = l10n.best_match(loc_str, supported_locales)
+ is_override = func is l10n.locale_from_params
+ if loc != default_locale or is_override:
+ return raw, loc
+
+ return raw, default_locale
+
+ def _get_format(self, headers) -> Union[str, None]:
+ """
+ Get `Request` format type from query parameters or headers.
+
+ :param headers: Dict of Request headers
+ :returns: format value or None if not found/specified
+ """
+
+ # Optional f=html or f=json query param
+ # Overrides Accept header and might differ from FORMAT_TYPES
+ format_ = (self._args.get('f') or '').strip()
+ if format_:
+ return format_
+
+ # Format not specified: get from Accept headers (MIME types)
+ # e.g. format_ = 'text/html'
+ h = headers.get('accept', headers.get('Accept', '')).strip() # noqa
+ (fmts, mimes) = zip(*FORMAT_TYPES.items())
+ # basic support for complex types (i.e. with "q=0.x")
+ for type_ in (t.split(';')[0].strip() for t in h.split(',') if t):
+ if type_ in mimes:
+ idx_ = mimes.index(type_)
+ format_ = fmts[idx_]
+ break
+
+ return format_ or None
+
+ @property
+ def data(self) -> bytes:
+ """Returns the additional data send with the Request (bytes)"""
+ return self._data
+
+ @property
+ def params(self) -> dict:
+ """Returns the Request query parameters dict"""
+ return self._args
+
+ @property
+ def path_info(self) -> str:
+ """Returns the web server request path info part"""
+ return self._path_info
+
+ @property
+ def locale(self) -> l10n.Locale:
+ """
+ Returns the user-defined locale from the request object.
+ If no locale has been defined or if it is invalid,
+ the default server locale is returned.
+
+ .. note:: The locale here determines the language in which pygeoapi
+ should return its responses. This may not be the language
+ that the user requested. It may also not be the language
+ that is supported by a collection provider, for example.
+ For this reason, you should pass the `raw_locale` property
+ to the :func:`l10n.get_plugin_locale` function, so that
+ the best match for the provider can be determined.
+
+ :returns: babel.core.Locale
+ """
+
+ return self._locale
+
+ @property
+ def raw_locale(self) -> Union[str, None]:
+ """
+ Returns the raw locale string from the `Request` object.
+ If no "lang" query parameter or `Accept-Language` header was found,
+ `None` is returned.
+ Pass this value to the :func:`l10n.get_plugin_locale` function to let
+ the provider determine a best match for the locale, which may be
+ different from the locale used by pygeoapi's UI.
+
+ :returns: a locale string or None
+ """
+
+ return self._raw_locale
+
+ @property
+ def format(self) -> Union[str, None]:
+ """
+ Returns the content type format from the
+ request query parameters or headers.
+
+ :returns: Format name or None
+ """
+
+ return self._format
+
+ @property
+ def headers(self) -> dict:
+ """
+ Returns the dictionary of the headers from
+ the request.
+
+ :returns: Request headers dictionary
+ """
+
+ return self._headers
+
+ def get_linkrel(self, format_: str) -> str:
+ """
+ Returns the hyperlink relationship (rel) attribute value for
+ the given API format string.
+
+ The string is compared against the request format and if it matches,
+ the value 'self' is returned. Otherwise, 'alternate' is returned.
+ However, if `format_` is 'json' and *no* request format was found,
+ the relationship 'self' is returned as well (JSON is the default).
+
+ :param format_: The format to compare the request format against.
+ :returns: A string 'self' or 'alternate'.
+ """
+
+ fmt = format_.lower()
+ if fmt == self._format or (fmt == F_JSON and not self._format):
+ return 'self'
+ return 'alternate'
+
+ def is_valid(self, additional_formats=None) -> bool:
+ """
+ Returns True if:
+ - the format is not set (None)
+ - the requested format is supported
+ - the requested format exists in a list if additional formats
+
+ .. note:: Format names are matched in a case-insensitive manner.
+
+ :param additional_formats: Optional additional supported formats list
+
+ :returns: bool
+ """
+
+ if not self._format:
+ return True
+ if self._format in FORMAT_TYPES.keys():
+ return True
+ if self._format in (f.lower() for f in (additional_formats or ())):
+ return True
+ return False
+
+ def get_response_headers(self, force_lang: l10n.Locale = None,
+ force_type: str = None,
+ force_encoding: str = None,
+ **custom_headers) -> dict:
+ """
+ Prepares and returns a dictionary with Response object headers.
+
+ This method always adds a 'Content-Language' header, where the value
+ is determined by the 'lang' query parameter or 'Accept-Language'
+ header from the request.
+ If no language was requested, the default pygeoapi language is used,
+ unless a `force_lang` override was specified (see notes below).
+
+ A 'Content-Type' header is also always added to the response.
+ If the user does not specify `force_type`, the header is based on
+ the `format` APIRequest property. If that is invalid, the default MIME
+ type `application/json` is used.
+
+ ..note:: If a `force_lang` override is applied, that language
+ is always set as the 'Content-Language', regardless of
+ a 'lang' query parameter or 'Accept-Language' header.
+ If an API response always needs to be in the same
+ language, 'force_lang' should be set to that language.
+
+ :param force_lang: An optional Content-Language header override.
+ :param force_type: An optional Content-Type header override.
+ :param force_encoding: An optional Content-Encoding header override.
+ :returns: A header dict
+ """
+
+ headers = HEADERS.copy()
+ headers.update(**custom_headers)
+ l10n.set_response_language(headers, force_lang or self._locale)
+ if force_type:
+ # Set custom MIME type if specified
+ headers['Content-Type'] = force_type
+ elif self.is_valid() and self._format:
+ # Set MIME type for valid formats
+ headers['Content-Type'] = FORMAT_TYPES[self._format]
+
+ if F_GZIP in FORMAT_TYPES:
+ if force_encoding:
+ headers['Content-Encoding'] = force_encoding
+ elif F_GZIP in self._headers.get('Accept-Encoding', ''):
+ headers['Content-Encoding'] = F_GZIP
+
+ return headers
+
+ def get_request_headers(self, headers) -> dict:
+ """
+ Obtains and returns a dictionary with Request object headers.
+
+ This method adds the headers of the original request and
+ makes them available to the API object.
+
+ :returns: A header dict
+ """
+
+ headers_ = {item[0]: item[1] for item in headers.items()}
+ return headers_
+
+
+class API:
+ """API object"""
+
+ def __init__(self, config, openapi):
+ """
+ constructor
+
+ :param config: configuration dict
+ :param openapi: openapi dict
+
+ :returns: `pygeoapi.API` instance
+ """
+
+ self.config = config
+ self.openapi = openapi
+ self.api_headers = get_api_rules(self.config).response_headers
+ self.base_url = get_base_url(self.config)
+ self.prefetcher = UrlPrefetcher()
+
+ CHARSET[0] = config['server'].get('encoding', 'utf-8')
+ if config['server'].get('gzip'):
+ FORMAT_TYPES[F_GZIP] = 'application/gzip'
+ FORMAT_TYPES.move_to_end(F_JSON)
+
+ # Process language settings (first locale is default!)
+ self.locales = l10n.get_locales(config)
+ self.default_locale = self.locales[0]
+
+ if 'templates' not in self.config['server']:
+ self.config['server']['templates'] = {'path': TEMPLATES}
+
+ if 'pretty_print' not in self.config['server']:
+ self.config['server']['pretty_print'] = False
+
+ self.pretty_print = self.config['server']['pretty_print']
+
+ setup_logger(self.config['logging'])
+
+ # Create config clone for HTML templating with modified base URL
+ self.tpl_config = deepcopy(self.config)
+ self.tpl_config['server']['url'] = self.base_url
+
+ self.manager = get_manager(self.config)
+ LOGGER.info('Process manager plugin loaded')
+
+ def get_exception(self, status, headers, format_, code,
+ description) -> Tuple[dict, int, str]:
+ """
+ Exception handler
+
+ :param status: HTTP status code
+ :param headers: dict of HTTP response headers
+ :param format_: format string
+ :param code: OGC API exception code
+ :param description: OGC API exception code
+
+ :returns: tuple of headers, status, and message
+ """
+
+ exception_info = sys.exc_info()
+ LOGGER.error(
+ description,
+ exc_info=exception_info if exception_info[0] is not None else None
+ )
+ exception = {
+ 'code': code,
+ 'type': code,
+ 'description': description
+ }
+
+ if format_ == F_HTML:
+ headers['Content-Type'] = FORMAT_TYPES[F_HTML]
+ content = render_j2_template(
+ self.tpl_config, self.config['server']['templates'],
+ 'exception.html', exception, SYSTEM_LOCALE)
+ else:
+ content = to_json(exception, self.pretty_print)
+
+ return headers, status, content
+
+ def get_format_exception(self, request) -> Tuple[dict, int, str]:
+ """
+ Returns a format exception.
+
+ :param request: An APIRequest instance.
+
+ :returns: tuple of (headers, status, message)
+ """
+
+ # Content-Language is in the system locale (ignore language settings)
+ headers = request.get_response_headers(SYSTEM_LOCALE,
+ **self.api_headers)
+ msg = 'Invalid format requested'
+ LOGGER.error(f'{msg}: {request.format}')
+ return self.get_exception(
+ HTTPStatus.BAD_REQUEST, headers,
+ request.format, 'InvalidParameterValue', msg)
+
+ def get_collections_url(self) -> str:
+ return f"{self.base_url}/collections"
+
+ def get_dataset_templates(self, dataset) -> dict:
+ templates = self.config['resources'][dataset].get('templates')
+
+ return templates or self.tpl_config['server']['templates']
+
+ @staticmethod
+ def _create_crs_transform_spec(
+ config: dict,
+ query_crs_uri: Optional[str] = None,
+ ) -> Union[None, CrsTransformSpec]:
+ """Create a `CrsTransformSpec` instance based on provider config and
+ *crs* query parameter.
+
+ :param config: Provider config dictionary.
+ :type config: dict
+ :param query_crs_uri: Uniform resource identifier of the coordinate
+ reference system (CRS) specified in query parameter (if specified).
+ :type query_crs_uri: str, optional
+
+ :raises ValueError: Error raised if the CRS specified in the query
+ parameter is not in the list of supported CRSs of the provider.
+ :raises `CRSError`: Error raised if no CRS could be identified from the
+ query *crs* parameter (URI).
+
+ :returns: `CrsTransformSpec` instance if the CRS specified in query
+ parameter differs from the storage CRS, else `None`.
+ :rtype: Union[None, CrsTransformSpec]
+ """
+ # Get storage/default CRS for Collection.
+ storage_crs_uri = config.get('storage_crs', DEFAULT_STORAGE_CRS)
+
+ if not query_crs_uri:
+ if storage_crs_uri in DEFAULT_CRS_LIST:
+ # Could be that storageCRS is
+ # http://www.opengis.net/def/crs/OGC/1.3/CRS84h
+ query_crs_uri = storage_crs_uri
+ else:
+ query_crs_uri = DEFAULT_CRS
+ LOGGER.debug(f'no crs parameter, using default: {query_crs_uri}')
+
+ supported_crs_list = get_supported_crs_list(config, DEFAULT_CRS_LIST)
+ # Check that the crs specified by the query parameter is supported.
+ if query_crs_uri not in supported_crs_list:
+ raise ValueError(
+ f'CRS {query_crs_uri!r} not supported for this '
+ 'collection. List of supported CRSs: '
+ f'{", ".join(supported_crs_list)}.'
+ )
+ crs_out = get_crs_from_uri(query_crs_uri)
+
+ storage_crs = get_crs_from_uri(storage_crs_uri)
+ # Check if the crs specified in query parameter differs from the
+ # storage crs.
+ if str(storage_crs) != str(crs_out):
+ LOGGER.debug(
+ f'CRS transformation: {storage_crs} -> {crs_out}'
+ )
+ return CrsTransformSpec(
+ source_crs_uri=storage_crs_uri,
+ source_crs_wkt=storage_crs.to_wkt(),
+ target_crs_uri=query_crs_uri,
+ target_crs_wkt=crs_out.to_wkt(),
+ )
+ else:
+ LOGGER.debug('No CRS transformation')
+ return None
+
+ @staticmethod
+ def _set_content_crs_header(
+ headers: dict,
+ config: dict,
+ query_crs_uri: Optional[str] = None,
+ ):
+ """Set the *Content-Crs* header in responses from providers of Feature
+ type.
+
+ :param headers: Response headers dictionary.
+ :type headers: dict
+ :param config: Provider config dictionary.
+ :type config: dict
+ :param query_crs_uri: Uniform resource identifier of the coordinate
+ reference system specified in query parameter (if specified).
+ :type query_crs_uri: str, optional
+ """
+ if query_crs_uri:
+ content_crs_uri = query_crs_uri
+ else:
+ # If empty use default CRS
+ storage_crs_uri = config.get('storage_crs', DEFAULT_STORAGE_CRS)
+ if storage_crs_uri in DEFAULT_CRS_LIST:
+ # Could be that storageCRS is one of the defaults like
+ # http://www.opengis.net/def/crs/OGC/1.3/CRS84h
+ content_crs_uri = storage_crs_uri
+ else:
+ content_crs_uri = DEFAULT_CRS
+
+ headers['Content-Crs'] = f'<{content_crs_uri}>'
+
+
+@jsonldify
+def landing_page(api: API,
+ request: APIRequest) -> Tuple[dict, int, str]:
+ """
+ Provide API landing page
+
+ :param request: A request object
+
+ :returns: tuple of headers, status code, content
+ """
+
+ fcm = {
+ 'links': [],
+ 'title': l10n.translate(
+ api.config['metadata']['identification']['title'],
+ request.locale),
+ 'description':
+ l10n.translate(
+ api.config['metadata']['identification']['description'],
+ request.locale)
+ }
+
+ LOGGER.debug('Creating links')
+ # TODO: put title text in config or translatable files?
+ fcm['links'] = [{
+ 'rel': 'about',
+ 'type': 'text/html',
+ 'title': l10n.translate(
+ api.config['metadata']['identification']['title'],
+ request.locale),
+ 'href': api.config['metadata']['identification']['url']
+ }, {
+ 'rel': request.get_linkrel(F_JSON),
+ 'type': FORMAT_TYPES[F_JSON],
+ 'title': l10n.translate('This document as JSON', request.locale),
+ 'href': f"{api.base_url}?f={F_JSON}"
+ }, {
+ 'rel': request.get_linkrel(F_JSONLD),
+ 'type': FORMAT_TYPES[F_JSONLD],
+ 'title': l10n.translate('This document as RDF (JSON-LD)', request.locale), # noqa
+ 'href': f"{api.base_url}?f={F_JSONLD}"
+ }, {
+ 'rel': request.get_linkrel(F_HTML),
+ 'type': FORMAT_TYPES[F_HTML],
+ 'title': l10n.translate('This document as HTML', request.locale),
+ 'href': f"{api.base_url}?f={F_HTML}",
+ 'hreflang': api.default_locale
+ }, {
+ 'rel': 'service-desc',
+ 'type': 'application/vnd.oai.openapi+json;version=3.0',
+ 'title': l10n.translate('The OpenAPI definition as JSON', request.locale), # noqa
+ 'href': f"{api.base_url}/openapi"
+ }, {
+ 'rel': 'service-doc',
+ 'type': FORMAT_TYPES[F_HTML],
+ 'title': l10n.translate('The OpenAPI definition as HTML', request.locale), # noqa
+ 'href': f"{api.base_url}/openapi?f={F_HTML}",
+ 'hreflang': api.default_locale
+ }, {
+ 'rel': 'conformance',
+ 'type': FORMAT_TYPES[F_JSON],
+ 'title': l10n.translate('Conformance', request.locale),
+ 'href': f"{api.base_url}/conformance"
+ }, {
+ 'rel': 'data',
+ 'type': FORMAT_TYPES[F_JSON],
+ 'title': l10n.translate('Collections', request.locale),
+ 'href': api.get_collections_url()
+ }, {
+ 'rel': 'http://www.opengis.net/def/rel/ogc/1.0/processes',
+ 'type': FORMAT_TYPES[F_JSON],
+ 'title': l10n.translate('Processes', request.locale),
+ 'href': f"{api.base_url}/processes"
+ }, {
+ 'rel': 'http://www.opengis.net/def/rel/ogc/1.0/job-list',
+ 'type': FORMAT_TYPES[F_JSON],
+ 'title': l10n.translate('Jobs', request.locale),
+ 'href': f"{api.base_url}/jobs"
+ }, {
+ 'rel': 'http://www.opengis.net/def/rel/ogc/1.0/tiling-schemes',
+ 'type': FORMAT_TYPES[F_JSON],
+ 'title': l10n.translate('The list of supported tiling schemes as JSON', request.locale), # noqa
+ 'href': f"{api.base_url}/TileMatrixSets?f=json"
+ }, {
+ 'rel': 'http://www.opengis.net/def/rel/ogc/1.0/tiling-schemes',
+ 'type': FORMAT_TYPES[F_HTML],
+ 'title': l10n.translate('The list of supported tiling schemes as HTML', request.locale), # noqa
+ 'href': f"{api.base_url}/TileMatrixSets?f=html"
+ }]
+
+ headers = request.get_response_headers(**api.api_headers)
+ if request.format == F_HTML: # render
+
+ for resource_type in ['collection', 'process', 'stac-collection']:
+ fcm[resource_type] = False
+
+ found = filter_dict_by_key_value(api.config['resources'],
+ 'type', resource_type)
+ if found:
+ fcm[resource_type] = True
+ if resource_type == 'collection': # check for tiles
+ for key, value in found.items():
+ if filter_providers_by_type(value['providers'],
+ 'tile'):
+ fcm['tile'] = True
+
+ content = render_j2_template(
+ api.tpl_config, api.config['server']['templates'],
+ 'landing_page.html', fcm, request.locale)
+
+ return headers, HTTPStatus.OK, content
+
+ if request.format == F_JSONLD:
+ return headers, HTTPStatus.OK, to_json(
+ api.fcmld, api.pretty_print)
+
+ return headers, HTTPStatus.OK, to_json(fcm, api.pretty_print)
+
+
+def openapi_(api: API, request: APIRequest) -> Tuple[dict, int, str]:
+ """
+ Provide OpenAPI document
+
+ :param request: A request object
+ :param openapi: dict of OpenAPI definition
+
+ :returns: tuple of headers, status code, content
+ """
+ headers = request.get_response_headers(**api.api_headers)
+
+ if request.format == F_HTML:
+ template = 'openapi/swagger.html'
+ if request._args.get('ui') == 'redoc':
+ template = 'openapi/redoc.html'
+
+ path = f'{api.base_url}/openapi'
+ data = {
+ 'openapi-document-path': path
+ }
+ content = render_j2_template(
+ api.tpl_config, api.config['server']['templates'], template, data,
+ request.locale)
+
+ return headers, HTTPStatus.OK, content
+
+ headers['Content-Type'] = 'application/vnd.oai.openapi+json;version=3.0' # noqa
+
+ if isinstance(api.openapi, dict):
+ return headers, HTTPStatus.OK, to_json(api.openapi,
+ api.pretty_print)
+ else:
+ return headers, HTTPStatus.OK, api.openapi
+
+
+def conformance(api, request: APIRequest) -> Tuple[dict, int, str]:
+ """
+ Provide conformance definition
+
+ :param request: A request object
+
+ :returns: tuple of headers, status code, content
+ """
+
+ apis_dict = all_apis()
+
+ conformance_list = CONFORMANCE_CLASSES
+
+ for key, value in api.config['resources'].items():
+ if value['type'] == 'process':
+ conformance_list.extend(
+ apis_dict['process'].CONFORMANCE_CLASSES)
+ else:
+ for provider in value['providers']:
+ if provider['type'] in apis_dict:
+ conformance_list.extend(
+ apis_dict[provider['type']].CONFORMANCE_CLASSES)
+ if provider['type'] == 'feature':
+ conformance_list.extend(
+ apis_dict['itemtypes'].CONFORMANCE_CLASSES_FEATURES) # noqa
+ if provider['type'] == 'record':
+ conformance_list.extend(
+ apis_dict['itemtypes'].CONFORMANCE_CLASSES_RECORDS)
+
+ conformance = {
+ 'conformsTo': sorted(list(set(conformance_list)))
+ }
+
+ headers = request.get_response_headers(**api.api_headers)
+ if request.format == F_HTML: # render
+ content = render_j2_template(
+ api.tpl_config, api.config['server']['templates'],
+ 'conformance.html', conformance, request.locale)
+
+ return headers, HTTPStatus.OK, content
+
+ return headers, HTTPStatus.OK, to_json(conformance, api.pretty_print)
+
+
+@jsonldify
+def describe_collections(api: API, request: APIRequest,
+ dataset=None) -> Tuple[dict, int, str]:
+ """
+ Provide collection metadata
+
+ :param request: A request object
+ :param dataset: name of collection
+
+ :returns: tuple of headers, status code, content
+ """
+
+ headers = request.get_response_headers(**api.api_headers)
+
+ fcm = {
+ 'collections': [],
+ 'links': []
+ }
+
+ collections = filter_dict_by_key_value(api.config['resources'],
+ 'type', 'collection')
+
+ if all([dataset is not None, dataset not in collections.keys()]):
+ msg = 'Collection not found'
+ return api.get_exception(
+ HTTPStatus.NOT_FOUND, headers, request.format, 'NotFound', msg)
+
+ if dataset is not None:
+ collections_dict = {
+ k: v for k, v in collections.items() if k == dataset
+ }
+ else:
+ collections_dict = collections
+
+ LOGGER.debug('Creating collections')
+ for k, v in collections_dict.items():
+ if v.get('visibility', 'default') == 'hidden':
+ LOGGER.debug(f'Skipping hidden layer: {k}')
+ continue
+ collection_data = get_provider_default(v['providers'])
+ collection_data_type = collection_data['type']
+
+ collection_data_format = None
+
+ if 'format' in collection_data:
+ collection_data_format = collection_data['format']
+
+ is_vector_tile = (collection_data_type == 'tile' and
+ collection_data_format['name'] not
+ in [F_PNG, F_JPEG])
+
+ collection = {
+ 'id': k,
+ 'title': l10n.translate(v['title'], request.locale),
+ 'description': l10n.translate(v['description'], request.locale), # noqa
+ 'keywords': l10n.translate(v['keywords'], request.locale),
+ 'links': []
+ }
+
+ bbox = v['extents']['spatial']['bbox']
+ # The output should be an array of bbox, so if the user only
+ # provided a single bbox, wrap it in a array.
+ if not isinstance(bbox[0], list):
+ bbox = [bbox]
+ collection['extent'] = {
+ 'spatial': {
+ 'bbox': bbox
+ }
+ }
+ if 'crs' in v['extents']['spatial']:
+ collection['extent']['spatial']['crs'] = \
+ v['extents']['spatial']['crs']
+
+ t_ext = v.get('extents', {}).get('temporal', {})
+ if t_ext:
+ begins = dategetter('begin', t_ext)
+ ends = dategetter('end', t_ext)
+ collection['extent']['temporal'] = {
+ 'interval': [[begins, ends]]
+ }
+ if 'trs' in t_ext:
+ collection['extent']['temporal']['trs'] = t_ext['trs']
+
+ LOGGER.debug('Processing configured collection links')
+ for link in l10n.translate(v.get('links', []), request.locale):
+ lnk = {
+ 'type': link['type'],
+ 'rel': link['rel'],
+ 'title': l10n.translate(link['title'], request.locale),
+ 'href': l10n.translate(link['href'], request.locale),
+ }
+ if 'hreflang' in link:
+ lnk['hreflang'] = l10n.translate(
+ link['hreflang'], request.locale)
+ content_length = link.get('length', 0)
+
+ if lnk['rel'] == 'enclosure' and content_length == 0:
+ # Issue HEAD request for enclosure links without length
+ lnk_headers = api.prefetcher.get_headers(lnk['href'])
+ content_length = int(lnk_headers.get('content-length', 0))
+ content_type = lnk_headers.get('content-type', lnk['type'])
+ if content_length == 0:
+ # Skip this (broken) link
+ LOGGER.debug(f"Enclosure {lnk['href']} is invalid")
+ continue
+ if content_type != lnk['type']:
+ # Update content type if different from specified
+ lnk['type'] = content_type
+ LOGGER.debug(
+ f"Fixed media type for enclosure {lnk['href']}")
+
+ if content_length > 0:
+ lnk['length'] = content_length
+
+ collection['links'].append(lnk)
+
+ # TODO: provide translations
+ LOGGER.debug('Adding JSON and HTML link relations')
+ collection['links'].append({
+ 'type': FORMAT_TYPES[F_JSON],
+ 'rel': 'root',
+ 'title': l10n.translate('The landing page of this server as JSON', request.locale), # noqa
+ 'href': f"{api.base_url}?f={F_JSON}"
+ })
+ collection['links'].append({
+ 'type': FORMAT_TYPES[F_HTML],
+ 'rel': 'root',
+ 'title': l10n.translate('The landing page of this server as HTML', request.locale), # noqa
+ 'href': f"{api.base_url}?f={F_HTML}"
+ })
+ collection['links'].append({
+ 'type': FORMAT_TYPES[F_JSON],
+ 'rel': request.get_linkrel(F_JSON),
+ 'title': l10n.translate('This document as JSON', request.locale), # noqa
+ 'href': f'{api.get_collections_url()}/{k}?f={F_JSON}'
+ })
+ collection['links'].append({
+ 'type': FORMAT_TYPES[F_JSONLD],
+ 'rel': request.get_linkrel(F_JSONLD),
+ 'title': l10n.translate('This document as RDF (JSON-LD)', request.locale), # noqa
+ 'href': f'{api.get_collections_url()}/{k}?f={F_JSONLD}'
+ })
+ collection['links'].append({
+ 'type': FORMAT_TYPES[F_HTML],
+ 'rel': request.get_linkrel(F_HTML),
+ 'title': l10n.translate('This document as HTML', request.locale), # noqa
+ 'href': f'{api.get_collections_url()}/{k}?f={F_HTML}'
+ })
+
+ if collection_data_type == 'record':
+ collection['links'].append({
+ 'type': FORMAT_TYPES[F_JSON],
+ 'rel': 'http://www.opengis.net/def/rel/ogc/1.0/ogc-catalog',
+ 'title': l10n.translate('Record catalogue as JSON', request.locale), # noqa
+ 'href': f'{api.get_collections_url()}/{k}?f={F_JSON}'
+ })
+ collection['links'].append({
+ 'type': FORMAT_TYPES[F_HTML],
+ 'rel': 'http://www.opengis.net/def/rel/ogc/1.0/ogc-catalog',
+ 'title': l10n.translate('Record catalogue as HTML', request.locale), # noqa
+ 'href': f'{api.get_collections_url()}/{k}?f={F_HTML}'
+ })
+
+ if collection_data_type in ['feature', 'coverage', 'record']:
+ collection['links'].append({
+ 'type': 'application/schema+json',
+ 'rel': f'{OGC_RELTYPES_BASE}/schema',
+ 'title': l10n.translate('Schema of collection in JSON', request.locale), # noqa
+ 'href': f'{api.get_collections_url()}/{k}/schema?f={F_JSON}' # noqa
+ })
+ collection['links'].append({
+ 'type': FORMAT_TYPES[F_HTML],
+ 'rel': f'{OGC_RELTYPES_BASE}/schema',
+ 'title': l10n.translate('Schema of collection in HTML', request.locale), # noqa
+ 'href': f'{api.get_collections_url()}/{k}/schema?f={F_HTML}' # noqa
+ })
+
+ if is_vector_tile or collection_data_type in ['feature', 'record']:
+ # TODO: translate
+ collection['itemType'] = collection_data_type
+ LOGGER.debug('Adding feature/record based links')
+ collection['links'].append({
+ 'type': 'application/schema+json',
+ 'rel': 'http://www.opengis.net/def/rel/ogc/1.0/queryables',
+ 'title': l10n.translate('Queryables for this collection as JSON', request.locale), # noqa
+ 'href': f'{api.get_collections_url()}/{k}/queryables?f={F_JSON}' # noqa
+ })
+ collection['links'].append({
+ 'type': FORMAT_TYPES[F_HTML],
+ 'rel': 'http://www.opengis.net/def/rel/ogc/1.0/queryables',
+ 'title': l10n.translate('Queryables for this collection as HTML', request.locale), # noqa
+ 'href': f'{api.get_collections_url()}/{k}/queryables?f={F_HTML}' # noqa
+ })
+ collection['links'].append({
+ 'type': 'application/geo+json',
+ 'rel': 'items',
+ 'title': l10n.translate('Items as GeoJSON', request.locale), # noqa
+ 'href': f'{api.get_collections_url()}/{k}/items?f={F_JSON}' # noqa
+ })
+ collection['links'].append({
+ 'type': FORMAT_TYPES[F_JSONLD],
+ 'rel': 'items',
+ 'title': l10n.translate('Items as RDF (GeoJSON-LD)', request.locale), # noqa
+ 'href': f'{api.get_collections_url()}/{k}/items?f={F_JSONLD}' # noqa
+ })
+ collection['links'].append({
+ 'type': FORMAT_TYPES[F_HTML],
+ 'rel': 'items',
+ 'title': l10n.translate('Items as HTML', request.locale), # noqa
+ 'href': f'{api.get_collections_url()}/{k}/items?f={F_HTML}' # noqa
+ })
+
+ # OAPIF Part 2 - list supported CRSs and StorageCRS
+ if collection_data_type in ['edr', 'feature']:
+ collection['crs'] = get_supported_crs_list(collection_data, DEFAULT_CRS_LIST) # noqa
+ collection['storageCRS'] = collection_data.get('storage_crs', DEFAULT_STORAGE_CRS) # noqa
+ if 'storage_crs_coordinate_epoch' in collection_data:
+ collection['storageCrsCoordinateEpoch'] = collection_data.get('storage_crs_coordinate_epoch') # noqa
+
+ elif collection_data_type == 'coverage':
+ # TODO: translate
+ LOGGER.debug('Adding coverage based links')
+ collection['links'].append({
+ 'type': 'application/prs.coverage+json',
+ 'rel': f'{OGC_RELTYPES_BASE}/coverage',
+ 'title': l10n.translate('Coverage data', request.locale),
+ 'href': f'{api.get_collections_url()}/{k}/coverage?f={F_JSON}' # noqa
+ })
+ if collection_data_format is not None:
+ title_ = l10n.translate('Coverage data as', request.locale) # noqa
+ title_ = f"{title_} {collection_data_format['name']}"
+ collection['links'].append({
+ 'type': collection_data_format['mimetype'],
+ 'rel': f'{OGC_RELTYPES_BASE}/coverage',
+ 'title': title_,
+ 'href': f"{api.get_collections_url()}/{k}/coverage?f={collection_data_format['name']}" # noqa
+ })
+ if dataset is not None:
+ LOGGER.debug('Creating extended coverage metadata')
+ try:
+ provider_def = get_provider_by_type(
+ api.config['resources'][k]['providers'],
+ 'coverage')
+ p = load_plugin('provider', provider_def)
+ except ProviderConnectionError:
+ msg = 'connection error (check logs)'
+ return api.get_exception(
+ HTTPStatus.INTERNAL_SERVER_ERROR,
+ headers, request.format,
+ 'NoApplicableCode', msg)
+ except ProviderTypeError:
+ pass
+ else:
+ collection['extent']['spatial']['grid'] = [{
+ 'cellsCount': p._coverage_properties['width'],
+ 'resolution': p._coverage_properties['resx']
+ }, {
+ 'cellsCount': p._coverage_properties['height'],
+ 'resolution': p._coverage_properties['resy']
+ }]
+ if 'time_range' in p._coverage_properties:
+ collection['extent']['temporal'] = {
+ 'interval': [p._coverage_properties['time_range']]
+ }
+ if 'restime' in p._coverage_properties:
+ collection['extent']['temporal']['grid'] = {
+ 'resolution': p._coverage_properties['restime'] # noqa
+ }
+ if 'uad' in p._coverage_properties:
+ collection['extent'].update(p._coverage_properties['uad']) # noqa
+
+ try:
+ tile = get_provider_by_type(v['providers'], 'tile')
+ p = load_plugin('provider', tile)
+ except ProviderConnectionError:
+ msg = 'connection error (check logs)'
+ return api.get_exception(
+ HTTPStatus.INTERNAL_SERVER_ERROR,
+ headers, request.format,
+ 'NoApplicableCode', msg)
+ except ProviderTypeError:
+ tile = None
+
+ if tile:
+ # TODO: translate
+
+ LOGGER.debug('Adding tile links')
+ collection['links'].append({
+ 'type': FORMAT_TYPES[F_JSON],
+ 'rel': f'http://www.opengis.net/def/rel/ogc/1.0/tilesets-{p.tile_type}', # noqa
+ 'title': l10n.translate('Tiles as JSON', request.locale),
+ 'href': f'{api.get_collections_url()}/{k}/tiles?f={F_JSON}' # noqa
+ })
+ collection['links'].append({
+ 'type': FORMAT_TYPES[F_HTML],
+ 'rel': f'http://www.opengis.net/def/rel/ogc/1.0/tilesets-{p.tile_type}', # noqa
+ 'title': l10n.translate('Tiles as HTML', request.locale),
+ 'href': f'{api.get_collections_url()}/{k}/tiles?f={F_HTML}' # noqa
+ })
+
+ try:
+ map_ = get_provider_by_type(v['providers'], 'map')
+ except ProviderTypeError:
+ map_ = None
+
+ if map_:
+ LOGGER.debug('Adding map links')
+
+ map_mimetype = map_['format']['mimetype']
+ map_format = map_['format']['name']
+
+ title_ = l10n.translate('Map as', request.locale)
+ title_ = f"{title_} {map_format}"
+
+ collection['links'].append({
+ 'type': map_mimetype,
+ 'rel': 'http://www.opengis.net/def/rel/ogc/1.0/map',
+ 'title': title_,
+ 'href': f"{api.get_collections_url()}/{k}/map?f={map_format}" # noqa
+ })
+
+ try:
+ edr = get_provider_by_type(v['providers'], 'edr')
+ p = load_plugin('provider', edr)
+ except ProviderConnectionError:
+ msg = 'connection error (check logs)'
+ return api.get_exception(
+ HTTPStatus.INTERNAL_SERVER_ERROR, headers,
+ request.format, 'NoApplicableCode', msg)
+ except ProviderTypeError:
+ edr = None
+
+ if edr:
+ # TODO: translate
+ LOGGER.debug('Adding EDR links')
+ collection['data_queries'] = {}
+ parameters = p.get_fields()
+ if parameters:
+ collection['parameter_names'] = {}
+ for key, value in parameters.items():
+ collection['parameter_names'][key] = {
+ 'id': key,
+ 'type': 'Parameter',
+ 'name': value['title'],
+ 'observedProperty': {
+ 'label': {
+ 'id': key,
+ 'en': value['title']
+ },
+ },
+ 'unit': {
+ 'label': {
+ 'en': value['title']
+ },
+ 'symbol': {
+ 'value': value['x-ogc-unit'],
+ 'type': 'http://www.opengis.net/def/uom/UCUM/' # noqa
+ }
+ }
+ }
+
+ for qt in p.get_query_types():
+ data_query = {
+ 'link': {
+ 'href': f'{api.get_collections_url()}/{k}/{qt}',
+ 'rel': 'data',
+ 'variables': {
+ 'query_type': qt
+ }
+ }
+ }
+ 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
+ title2 = f'{qt} {title2}'
+
+ collection['links'].append({
+ 'type': 'application/json',
+ 'rel': 'data',
+ 'title': title1,
+ 'href': f'{api.get_collections_url()}/{k}/{qt}?f={F_JSON}'
+ })
+ collection['links'].append({
+ 'type': FORMAT_TYPES[F_HTML],
+ 'rel': 'data',
+ 'title': title2,
+ 'href': f'{api.get_collections_url()}/{k}/{qt}?f={F_HTML}'
+ })
+
+ if dataset is not None and k == dataset:
+ fcm = collection
+ break
+
+ fcm['collections'].append(collection)
+
+ if dataset is None:
+ # TODO: translate
+ fcm['links'].append({
+ 'type': FORMAT_TYPES[F_JSON],
+ 'rel': request.get_linkrel(F_JSON),
+ 'title': l10n.translate('This document as JSON', request.locale), # noqa
+ 'href': f'{api.get_collections_url()}?f={F_JSON}'
+ })
+ fcm['links'].append({
+ 'type': FORMAT_TYPES[F_JSONLD],
+ 'rel': request.get_linkrel(F_JSONLD),
+ 'title': l10n.translate('This document as RDF (JSON-LD)', request.locale), # noqa
+ 'href': f'{api.get_collections_url()}?f={F_JSONLD}'
+ })
+ fcm['links'].append({
+ 'type': FORMAT_TYPES[F_HTML],
+ 'rel': request.get_linkrel(F_HTML),
+ 'title': l10n.translate('This document as HTML', request.locale), # noqa
+ 'href': f'{api.get_collections_url()}?f={F_HTML}'
+ })
+
+ if request.format == F_HTML: # render
+ fcm['collections_path'] = api.get_collections_url()
+ if dataset is not None:
+ tpl_config = api.get_dataset_templates(dataset)
+ content = render_j2_template(api.tpl_config, tpl_config,
+ 'collections/collection.html',
+ fcm, request.locale)
+ else:
+ content = render_j2_template(
+ api.tpl_config, api.config['server']['templates'],
+ 'collections/index.html', fcm, request.locale)
+
+ return headers, HTTPStatus.OK, content
+
+ if request.format == F_JSONLD:
+ jsonld = api.fcmld.copy()
+ if dataset is not None:
+ jsonld['dataset'] = jsonldify_collection(api, fcm,
+ request.locale)
+ else:
+ jsonld['dataset'] = [
+ jsonldify_collection(api, c, request.locale)
+ for c in fcm.get('collections', [])
+ ]
+ return headers, HTTPStatus.OK, to_json(jsonld, api.pretty_print)
+
+ return headers, HTTPStatus.OK, to_json(fcm, api.pretty_print)
+
+
+def get_collection_schema(api: API, request: Union[APIRequest, Any],
+ dataset) -> Tuple[dict, int, str]:
+ """
+ Returns a collection schema
+
+ :param request: A request object
+ :param dataset: dataset name
+
+ :returns: tuple of headers, status code, content
+ """
+
+ headers = request.get_response_headers(**api.api_headers)
+
+ if any([dataset is None,
+ dataset not in api.config['resources'].keys()]):
+
+ msg = 'Collection not found'
+ return api.get_exception(
+ HTTPStatus.NOT_FOUND, headers, request.format, 'NotFound', msg)
+
+ LOGGER.debug('Creating collection schema')
+ try:
+ LOGGER.debug('Loading feature provider')
+ p = load_plugin('provider', get_provider_by_type(
+ api.config['resources'][dataset]['providers'], 'feature'))
+ except ProviderTypeError:
+ try:
+ LOGGER.debug('Loading coverage provider')
+ p = load_plugin('provider', get_provider_by_type(
+ api.config['resources'][dataset]['providers'], 'coverage')) # noqa
+ 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.error(err)
+ return api.get_exception(
+ err.http_status_code, headers, request.format,
+ err.ogc_exception_code, err.message)
+
+ schema = {
+ 'type': 'object',
+ 'title': l10n.translate(
+ api.config['resources'][dataset]['title'], request.locale),
+ 'properties': {},
+ '$schema': 'http://json-schema.org/draft/2019-09/schema',
+ '$id': f'{api.get_collections_url()}/{dataset}/schema'
+ }
+
+ if p.type != 'coverage':
+ schema['properties']['geometry'] = {
+ 'format': 'geometry-any',
+ 'x-ogc-role': 'primary-geometry',
+ #GA Customisation - Add $ref link to ensure geojson definition is accessible.
+ #To be reviewed when upgrading beyond version 0.20.0
+ '$ref': 'https://geojson.org/schema/Geometry.json'
+ }
+
+ for k, v in p.fields.items():
+ schema['properties'][k] = v
+ if v.get('format') is None:
+ schema['properties'][k].pop('format', None)
+
+ if k == p.id_field:
+ schema['properties'][k]['x-ogc-role'] = 'id'
+ if k == p.time_field:
+ schema['properties'][k]['x-ogc-role'] = 'primary-instant'
+
+ if request.format == F_HTML: # render
+ tpl_config = api.get_dataset_templates(dataset)
+ schema['title'] = l10n.translate(
+ api.config['resources'][dataset]['title'], request.locale)
+
+ schema['collections_path'] = api.get_collections_url()
+ schema['dataset_path'] = f'{api.get_collections_url()}/{dataset}'
+
+ content = render_j2_template(api.tpl_config, tpl_config,
+ 'collections/schema.html',
+ schema, request.locale)
+
+ return headers, HTTPStatus.OK, content
+
+ headers['Content-Type'] = 'application/schema+json'
+
+ return headers, HTTPStatus.OK, to_json(schema, api.pretty_print)
+
+
+def validate_bbox(value=None) -> list:
+ """
+ Helper function to validate bbox parameter
+
+ :param value: `list` of minx, miny, maxx, maxy
+
+ :returns: bbox as `list` of `float` values
+ """
+
+ if value is None:
+ LOGGER.debug('bbox is empty')
+ return []
+
+ bbox = value.split(',')
+
+ if len(bbox) not in [4, 6]:
+ msg = 'bbox should be either 4 values (minx,miny,maxx,maxy) ' \
+ 'or 6 values (minx,miny,minz,maxx,maxy,maxz)'
+ LOGGER.debug(msg)
+ raise ValueError(msg)
+
+ try:
+ bbox = [float(c) for c in bbox]
+ except ValueError as err:
+ msg = 'bbox values must be numbers'
+ err.args = (msg,)
+ LOGGER.debug(msg)
+ raise
+
+ if (len(bbox) == 4 and bbox[1] > bbox[3]) \
+ or (len(bbox) == 6 and bbox[1] > bbox[4]):
+ msg = 'miny should be less than maxy'
+ LOGGER.debug(msg)
+ raise ValueError(msg)
+
+ if (len(bbox) == 4 and bbox[0] > bbox[2]) \
+ or (len(bbox) == 6 and bbox[0] > bbox[3]):
+ msg = 'minx is greater than maxx (possibly antimeridian bbox)'
+ LOGGER.debug(msg)
+
+ if len(bbox) == 6 and bbox[2] > bbox[5]:
+ msg = 'minz should be less than maxz'
+ LOGGER.debug(msg)
+ raise ValueError(msg)
+
+ return bbox
+
+
+def validate_datetime(resource_def, datetime_=None) -> str:
+ """
+ Helper function to validate temporal parameter
+
+ :param resource_def: `dict` of configuration resource definition
+ :param datetime_: `str` of datetime parameter
+
+ :returns: `str` of datetime input, if valid
+ """
+
+ # TODO: pass datetime to query as a `datetime` object
+ # we would need to ensure partial dates work accordingly
+ # as well as setting '..' values to `None` so that underlying
+ # providers can just assume a `datetime.datetime` object
+ #
+ # NOTE: needs testing when passing partials from API to backend
+
+ datetime_invalid = False
+
+ if datetime_ is not None and 'temporal' in resource_def:
+
+ dateparse_begin = partial(dateparse, default=datetime.min)
+ dateparse_end = partial(dateparse, default=datetime.max)
+ unix_epoch = datetime(1970, 1, 1, 0, 0, 0)
+ dateparse_ = partial(dateparse, default=unix_epoch)
+
+ te = resource_def['temporal']
+
+ try:
+ if te['begin'] is not None and te['begin'].tzinfo is None:
+ te['begin'] = te['begin'].replace(tzinfo=pytz.UTC)
+ if te['end'] is not None and te['end'].tzinfo is None:
+ te['end'] = te['end'].replace(tzinfo=pytz.UTC)
+ except AttributeError:
+ msg = 'Configured times should be RFC3339'
+ LOGGER.error(msg)
+ raise ValueError(msg)
+
+ if '/' in datetime_: # envelope
+ LOGGER.debug('detected time range')
+ LOGGER.debug('Validating time windows')
+
+ # normalize "" to ".." (actually changes datetime_)
+ datetime_ = re.sub(r'^/', '../', datetime_)
+ datetime_ = re.sub(r'/$', '/..', datetime_)
+
+ datetime_begin, datetime_end = datetime_.split('/')
+ if datetime_begin != '..':
+ datetime_begin = dateparse_begin(datetime_begin)
+ if datetime_begin.tzinfo is None:
+ datetime_begin = datetime_begin.replace(
+ tzinfo=pytz.UTC)
+
+ if datetime_end != '..':
+ datetime_end = dateparse_end(datetime_end)
+ if datetime_end.tzinfo is None:
+ datetime_end = datetime_end.replace(tzinfo=pytz.UTC)
+
+ datetime_invalid = any([
+ (te['end'] is not None and datetime_begin != '..' and
+ datetime_begin > te['end']),
+ (te['begin'] is not None and datetime_end != '..' and
+ datetime_end < te['begin'])
+ ])
+
+ else: # time instant
+ LOGGER.debug('detected time instant')
+ datetime__ = dateparse_(datetime_)
+ if datetime__ != '..':
+ if datetime__.tzinfo is None:
+ datetime__ = datetime__.replace(tzinfo=pytz.UTC)
+ datetime_invalid = any([
+ (te['begin'] is not None and datetime__ != '..' and
+ datetime__ < te['begin']),
+ (te['end'] is not None and datetime__ != '..' and
+ datetime__ > te['end'])
+ ])
+
+ if datetime_invalid:
+ msg = 'datetime parameter out of range'
+ LOGGER.debug(msg)
+ raise ValueError(msg)
+
+ return datetime_
+
+
+def validate_subset(value: str) -> dict:
+ """
+ Helper function to validate subset parameter
+
+ :param value: `subset` parameter
+
+ :returns: dict of axis/values
+ """
+
+ subsets = {}
+
+ for s in value.split(','):
+ LOGGER.debug(f'Processing subset {s}')
+ m = re.search(r'(.*)\((.*)\)', s)
+ subset_name, values = m.group(1, 2)
+
+ if '"' in values:
+ LOGGER.debug('Values are strings')
+ if values.count('"') % 2 != 0:
+ msg = 'Invalid format: subset should be like axis("min"[:"max"])' # noqa
+ LOGGER.error(msg)
+ raise ValueError(msg)
+ try:
+ LOGGER.debug('Value is an interval')
+ m = re.search(r'"(\S+)":"(\S+)"', values)
+ values = list(m.group(1, 2))
+ except AttributeError:
+ LOGGER.debug('Value is point')
+ m = re.search(r'"(.*)"', values)
+ values = [m.group(1)]
+ else:
+ LOGGER.debug('Values are numbers')
+ try:
+ LOGGER.debug('Value is an interval')
+ m = re.search(r'(\S+):(\S+)', values)
+ values = list(m.group(1, 2))
+ except AttributeError:
+ LOGGER.debug('Value is point')
+ values = [values]
+
+ subsets[subset_name] = list(map(get_typed_value, values))
+
+ return subsets
+
+
+def evaluate_limit(requested: Union[None, int], server_limits: dict,
+ collection_limits: dict) -> int:
+ """
+ Helper function to evaluate limit parameter
+
+ :param requested: the limit requested by the client
+ :param server_limits: `dict` of server limits
+ :param collection_limits: `dict` of collection limits
+
+ :returns: `int` of evaluated limit
+ """
+
+ effective_limits = ChainMap(collection_limits, server_limits)
+
+ default = effective_limits.get('default_items', 10)
+ max_ = effective_limits.get('max_items', 10)
+ on_exceed = effective_limits.get('on_exceed', 'throttle')
+
+ LOGGER.debug(f'Requested limit: {requested}')
+ LOGGER.debug(f'Default limit: {default}')
+ LOGGER.debug(f'Maximum limit: {max_}')
+ LOGGER.debug(f'On exceed: {on_exceed}')
+
+ if requested is None:
+ LOGGER.debug('no limit requested; returning default')
+ return default
+
+ requested2 = get_typed_value(requested)
+ if not isinstance(requested2, int):
+ raise ValueError('limit value should be an integer')
+
+ if requested2 <= 0:
+ raise ValueError('limit value should be strictly positive')
+ elif requested2 > max_ and on_exceed == 'error':
+ raise RuntimeError('Limit exceeded; throwing errror')
+ else:
+ LOGGER.debug('limit requested')
+ return min(requested2, max_)
diff --git a/pygeoapi/api/coverages.py b/pygeoapi/api/coverages.py
new file mode 100644
index 000000000..c6687042c
--- /dev/null
+++ b/pygeoapi/api/coverages.py
@@ -0,0 +1,249 @@
+# =================================================================
+
+# Authors: Tom Kralidis
+# Francesco Bartoli
+# Sander Schaminee
+# John A Stevenson
+# Colin Blackburn
+# Ricardo Garcia Silva
+# Bernhard Mallinger
+#
+# Copyright (c) 2024 Tom Kralidis
+# Copyright (c) 2022 Francesco Bartoli
+# Copyright (c) 2022 John A Stevenson and Colin Blackburn
+# Copyright (c) 2023 Ricardo Garcia Silva
+# Copyright (c) 2024 Bernhard Mallinger
+#
+# 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 logging
+from http import HTTPStatus
+from typing import Tuple
+
+from pygeoapi import l10n
+from pygeoapi.plugin import load_plugin
+from pygeoapi.provider.base import ProviderGenericError, ProviderTypeError
+from pygeoapi.util import (
+ filter_dict_by_key_value, get_provider_by_type, to_json
+)
+
+from . import (
+ APIRequest, API, F_JSON, SYSTEM_LOCALE, validate_bbox, validate_datetime,
+ validate_subset
+)
+
+LOGGER = logging.getLogger(__name__)
+
+CONFORMANCE_CLASSES = [
+ 'http://www.opengis.net/spec/ogcapi-coverages-1/1.0/conf/core',
+ 'http://www.opengis.net/spec/ogcapi-coverages-1/1.0/conf/oas30',
+ 'http://www.opengis.net/spec/ogcapi-coverages-1/1.0/conf/html',
+ 'http://www.opengis.net/spec/ogcapi-coverages-1/1.0/conf/geodata-coverage',
+ 'http://www.opengis.net/spec/ogcapi-coverages-1/1.0/conf/coverage-subset',
+ 'http://www.opengis.net/spec/ogcapi-coverages-1/1.0/conf/coverage-rangesubset', # noqa
+ 'http://www.opengis.net/spec/ogcapi-coverages-1/1.0/conf/coverage-bbox',
+ 'http://www.opengis.net/spec/ogcapi-coverages-1/1.0/conf/coverage-datetime'
+]
+
+
+def get_collection_coverage(
+ api: API, request: APIRequest, dataset) -> Tuple[dict, int, str]:
+ """
+ Returns a subset of a collection coverage
+
+ :param request: A request object
+ :param dataset: dataset name
+
+ :returns: tuple of headers, status code, content
+ """
+
+ query_args = {}
+ format_ = request.format or F_JSON
+
+ # Force response content type and language (en-US only) headers
+ headers = request.get_response_headers(SYSTEM_LOCALE, **api.api_headers)
+
+ LOGGER.debug('Loading provider')
+ try:
+ collection_def = get_provider_by_type(
+ api.config['resources'][dataset]['providers'], 'coverage')
+
+ p = load_plugin('provider', collection_def)
+ except KeyError:
+ msg = 'collection does not exist'
+ return api.get_exception(
+ HTTPStatus.NOT_FOUND, headers, format_,
+ 'InvalidParameterValue', msg)
+ except ProviderGenericError as err:
+ return api.get_exception(
+ err.http_status_code, headers, request.format,
+ err.ogc_exception_code, err.message)
+
+ LOGGER.debug('Processing bbox parameter')
+
+ bbox = request.params.get('bbox')
+
+ if bbox is None:
+ bbox = []
+ else:
+ try:
+ bbox = validate_bbox(bbox)
+ except ValueError as err:
+ msg = str(err)
+ return api.get_exception(
+ HTTPStatus.INTERNAL_SERVER_ERROR, headers, format_,
+ 'InvalidParameterValue', msg)
+
+ query_args['bbox'] = bbox
+
+ LOGGER.debug('Processing bbox-crs parameter')
+
+ bbox_crs = request.params.get('bbox-crs')
+ if bbox_crs is not None:
+ query_args['bbox_crs'] = bbox_crs
+
+ LOGGER.debug('Processing datetime parameter')
+
+ datetime_ = request.params.get('datetime')
+
+ try:
+ datetime_ = validate_datetime(
+ api.config['resources'][dataset]['extents'], datetime_)
+ except ValueError as err:
+ msg = str(err)
+ return api.get_exception(
+ HTTPStatus.BAD_REQUEST, headers, format_,
+ 'InvalidParameterValue', msg)
+
+ query_args['datetime_'] = datetime_
+ query_args['format_'] = format_
+
+ properties = request.params.get('properties')
+ if properties:
+ LOGGER.debug('Processing properties parameter')
+ query_args['properties'] = [rs for
+ rs in properties.split(',') if rs]
+ LOGGER.debug(f"Fields: {query_args['properties']}")
+
+ for a in query_args['properties']:
+ if a not in p.fields:
+ msg = 'Invalid field specified'
+ return api.get_exception(
+ HTTPStatus.BAD_REQUEST, headers, format_,
+ 'InvalidParameterValue', msg)
+
+ if 'subset' in request.params:
+ LOGGER.debug('Processing subset parameter')
+ try:
+ subsets = validate_subset(request.params['subset'] or '')
+ except (AttributeError, ValueError) as err:
+ msg = f'Invalid subset: {err}'
+ return api.get_exception(
+ HTTPStatus.BAD_REQUEST, headers, format_,
+ 'InvalidParameterValue', msg)
+
+ if not set(subsets.keys()).issubset(p.axes):
+ msg = 'Invalid axis name'
+ return api.get_exception(
+ HTTPStatus.BAD_REQUEST, headers, format_,
+ 'InvalidParameterValue', msg)
+
+ query_args['subsets'] = subsets
+ LOGGER.debug(f"Subsets: {query_args['subsets']}")
+
+ LOGGER.debug('Querying coverage')
+ try:
+ data = p.query(**query_args)
+ except ProviderGenericError as err:
+ return api.get_exception(
+ err.http_status_code, headers, request.format,
+ err.ogc_exception_code, err.message)
+
+ mt = collection_def['format']['name']
+ if format_ == mt: # native format
+ if p.filename is not None:
+ cd = f'attachment; filename="{p.filename}"'
+ headers['Content-Disposition'] = cd
+
+ headers['Content-Type'] = collection_def['format']['mimetype']
+ return headers, HTTPStatus.OK, data
+ elif format_ == F_JSON:
+ headers['Content-Type'] = 'application/prs.coverage+json'
+ return headers, HTTPStatus.OK, to_json(data, api.pretty_print)
+ else:
+ return api.get_format_exception(request)
+
+
+def get_oas_30(cfg: dict, locale: str) -> tuple[list[dict[str, str]], dict[str, dict]]: # noqa
+ """
+ Get OpenAPI fragments
+
+ :param cfg: `dict` of configuration
+ :param locale: `str` of locale
+
+ :returns: `tuple` of `list` of tag objects, and `dict` of path objects
+ """
+
+ from pygeoapi.openapi import OPENAPI_YAML, get_visible_collections
+
+ paths = {}
+
+ collections = filter_dict_by_key_value(cfg['resources'],
+ 'type', 'collection')
+
+ for k, v in get_visible_collections(cfg).items():
+ try:
+ load_plugin('provider', get_provider_by_type(
+ collections[k]['providers'], 'coverage'))
+ except ProviderTypeError:
+ LOGGER.debug('collection is not coverage based')
+ continue
+
+ coverage_path = f'/collections/{k}/coverage'
+ title = l10n.translate(v['title'], locale)
+ description = l10n.translate(v['description'], locale)
+
+ paths[coverage_path] = {
+ 'get': {
+ 'summary': f'Get {title} coverage',
+ 'description': description,
+ 'tags': [k],
+ 'operationId': f'get{k.capitalize()}Coverage',
+ 'parameters': [
+ {'$ref': '#/components/parameters/lang'},
+ {'$ref': '#/components/parameters/f'},
+ {'$ref': '#/components/parameters/bbox'},
+ {'$ref': '#/components/parameters/bbox-crs'}
+ ],
+ 'responses': {
+ '200': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/Features"}, # noqa
+ '400': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/InvalidParameter"}, # noqa
+ '404': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/NotFound"}, # noqa
+ '500': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/ServerError"} # noqa
+ }
+ }
+ }
+
+ return [{'name': 'coverages'}], {'paths': paths}
diff --git a/pygeoapi/api/environmental_data_retrieval.py b/pygeoapi/api/environmental_data_retrieval.py
new file mode 100644
index 000000000..e78170368
--- /dev/null
+++ b/pygeoapi/api/environmental_data_retrieval.py
@@ -0,0 +1,601 @@
+# =================================================================
+
+# Authors: Tom Kralidis
+# Francesco Bartoli
+# Sander Schaminee
+# John A Stevenson
+# Colin Blackburn
+# Ricardo Garcia Silva
+# Bernhard Mallinger
+#
+# Copyright (c) 2024 Tom Kralidis
+# Copyright (c) 2022 Francesco Bartoli
+# Copyright (c) 2022 John A Stevenson and Colin Blackburn
+# Copyright (c) 2023 Ricardo Garcia Silva
+# Copyright (c) 2024 Bernhard Mallinger
+#
+# 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 http import HTTPStatus
+import logging
+from typing import Tuple
+import urllib
+
+from shapely.errors import ShapelyError
+from shapely.wkt import loads as shapely_loads
+
+from pygeoapi import l10n
+from pygeoapi.api import evaluate_limit
+from pygeoapi.plugin import load_plugin, PLUGINS
+from pygeoapi.provider.base import (
+ ProviderGenericError, ProviderItemNotFoundError)
+from pygeoapi.util import (
+ filter_providers_by_type, get_provider_by_type, render_j2_template,
+ to_json, filter_dict_by_key_value
+)
+
+from . import (APIRequest, API, F_COVERAGEJSON, F_HTML, F_JSON, F_JSONLD,
+ validate_datetime, validate_bbox)
+
+LOGGER = logging.getLogger(__name__)
+
+CONFORMANCE_CLASSES = [
+ 'http://www.opengis.net/spec/ogcapi-edr-1/1.0/conf/core'
+]
+
+
+def get_collection_edr_instances(api: API, request: APIRequest, dataset,
+ instance_id=None) -> Tuple[dict, int, str]:
+ """
+ Queries collection EDR instances
+
+ :param request: APIRequest instance with query params
+ :param dataset: dataset name
+
+ :returns: tuple of headers, status code, content
+ """
+
+ data = {
+ 'instances': [],
+ 'links': []
+ }
+
+ if not request.is_valid(PLUGINS['formatter'].keys()):
+ return api.get_format_exception(request)
+ headers = request.get_response_headers(api.default_locale,
+ **api.api_headers)
+ collections = filter_dict_by_key_value(api.config['resources'],
+ 'type', 'collection')
+
+ if dataset not in collections.keys():
+ msg = 'Collection not found'
+ return api.get_exception(
+ HTTPStatus.NOT_FOUND, headers, request.format, 'NotFound', msg)
+
+ uri = f'{api.get_collections_url()}/{dataset}'
+
+ 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_id is not None:
+ try:
+ instances = [p.get_instance(instance_id)]
+ except ProviderItemNotFoundError:
+ msg = 'Instance not found'
+ return api.get_exception(
+ HTTPStatus.NOT_FOUND, headers, request.format, 'NotFound', msg)
+ else:
+ instances = p.instances()
+
+ for instance in instances:
+ instance_dict = {
+ 'id': instance,
+ 'links': [{
+ 'href': f'{uri}/instances/{instance}?f={F_JSON}',
+ 'rel': request.get_linkrel(F_JSON),
+ 'type': 'application/json'
+ }, {
+ 'href': f'{uri}/instances/{instance}?f={F_HTML}',
+ 'rel': request.get_linkrel(F_HTML),
+ 'type': 'text/html'
+ }, {
+ 'href': f'{uri}?f={F_HTML}',
+ 'rel': 'collection',
+ 'title': collections[dataset]['title'],
+ 'type': 'text/html'
+ }, {
+ 'href': f'{uri}?f={F_JSON}',
+ 'rel': 'collection',
+ 'title': collections[dataset]['title'],
+ 'type': 'application/json'
+ }],
+ 'data_queries': {}
+ }
+
+ for qt in p.get_query_types():
+ if qt == 'instances':
+ continue
+ data_query = {
+ 'link': {
+ 'href': f'{uri}/instances/{instance}/{qt}',
+ 'rel': 'data',
+ 'title': f'{qt} query'
+ }
+ }
+ instance_dict['data_queries'][qt] = data_query
+
+ data['instances'].append(instance_dict)
+
+ if instance_id is not None:
+ data = data['instances'][0]
+ data.pop('instances', None)
+ links_uri = f'{uri}/instances/{instance_id}'
+ else:
+ links_uri = f'{uri}/instances'
+
+ if instance_id is None:
+ data['links'].extend([{
+ 'href': f'{links_uri}?f={F_JSON}',
+ 'rel': request.get_linkrel(F_JSON),
+ 'type': 'application/json'
+ }, {
+ 'href': f'{links_uri}?f={F_HTML}',
+ 'rel': request.get_linkrel(F_HTML),
+ 'type': 'text/html'
+ }])
+
+ if request.format == F_HTML: # render
+ tpl_config = api.get_dataset_templates(dataset)
+
+ 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=',')
+
+ if instance_id is None:
+ uri = f'{uri}/instances'
+ else:
+ uri = f'{uri}/instances/{instance_id}'
+
+ data['query_type'] = 'instances'
+ data['query_path'] = uri
+ data['title'] = collections[dataset]['title']
+ data['description'] = collections[dataset]['description']
+ data['keywords'] = collections[dataset]['keywords']
+ data['collections_path'] = api.get_collections_url()
+
+ if instance_id is None:
+ data['dataset_path'] = data['collections_path'] + '/' + uri.split('/')[-2] # noqa
+ template = 'collections/edr/instances.html'
+ else:
+ data['dataset_path'] = data['collections_path'] + '/' + uri.split('/')[-3] # noqa
+ template = 'collections/edr/instance.html'
+
+ data['links'] = [{
+ 'rel': 'collection',
+ 'title': collections[dataset]['title'],
+ 'href': f"{data['dataset_path']}?f={F_JSON}",
+ 'type': 'text/html'
+ }, {
+ 'rel': 'collection',
+ 'title': collections[dataset]['title'],
+ 'href': f"{data['dataset_path']}?f={F_HTML}",
+ 'type': 'application/json'
+ }, {
+ 'type': 'application/json',
+ 'rel': 'alternate',
+ 'title': l10n.translate('This document as JSON', request.locale),
+ 'href': f'{uri}?f={F_JSON}{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, tpl_config, template,
+ data, api.default_locale)
+ else:
+ content = to_json(data, api.pretty_print)
+
+ return headers, HTTPStatus.OK, content
+
+
+def get_collection_edr_query(api: API, request: APIRequest,
+ dataset, instance, query_type,
+ location_id=None) -> Tuple[dict, int, str]:
+ """
+ Queries collection EDR
+
+ :param request: APIRequest instance with query params
+ :param dataset: dataset name
+ :param instance: instance name
+ :param query_type: EDR query type
+ :param location_id: location id of a /location/ query
+
+ :returns: tuple of headers, status code, content
+ """
+
+ if not request.is_valid(PLUGINS['formatter'].keys()):
+ return api.get_format_exception(request)
+ headers = request.get_response_headers(api.default_locale,
+ **api.api_headers)
+ collections = filter_dict_by_key_value(api.config['resources'],
+ 'type', 'collection')
+
+ if dataset not in collections.keys():
+ msg = 'Collection not found'
+ 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')
+ datetime_ = request.params.get('datetime')
+ try:
+ datetime_ = validate_datetime(collections[dataset]['extents'],
+ datetime_)
+ except ValueError as err:
+ msg = str(err)
+ return api.get_exception(
+ HTTPStatus.BAD_REQUEST, headers, request.format,
+ 'InvalidParameterValue', msg)
+
+ LOGGER.debug('Processing parameter-name parameter')
+ parameternames = request.params.get('parameter-name') or []
+ if isinstance(parameternames, str):
+ parameternames = parameternames.split(',')
+
+ bbox = None
+ if query_type in ['cube', 'locations']:
+ LOGGER.debug('Processing cube bbox')
+ try:
+ bbox = validate_bbox(request.params.get('bbox'))
+ if not bbox and query_type == 'cube':
+ raise ValueError('bbox parameter required by cube queries')
+ except ValueError as err:
+ return api.get_exception(
+ HTTPStatus.BAD_REQUEST, headers, request.format,
+ 'InvalidParameterValue', str(err))
+
+ LOGGER.debug('Processing coords parameter')
+ wkt = request.params.get('coords')
+
+ if wkt:
+ try:
+ wkt = shapely_loads(wkt)
+ except ShapelyError:
+ msg = 'invalid coords parameter'
+ return api.get_exception(
+ HTTPStatus.BAD_REQUEST, headers, request.format,
+ 'InvalidParameterValue', msg)
+ elif query_type not in ['cube', 'locations']:
+ msg = 'missing coords parameter'
+ return api.get_exception(
+ HTTPStatus.BAD_REQUEST, headers, request.format,
+ 'InvalidParameterValue', msg)
+
+ within = within_units = None
+ if query_type == 'radius':
+ LOGGER.debug('Processing within / within-units parameters')
+ within = request.params.get('within')
+ within_units = request.params.get('within-units')
+
+ LOGGER.debug('Processing z parameter')
+ z = request.params.get('z')
+
+ if parameternames and not any((fld in parameternames)
+ for fld in p.get_fields().keys()):
+ msg = 'Invalid parameter-name'
+ return api.get_exception(
+ HTTPStatus.BAD_REQUEST, headers, request.format,
+ 'InvalidParameterValue', msg)
+
+ LOGGER.debug('Processing limit parameter')
+ if api.config['server'].get('limit') is not None:
+ msg = ('server.limit is no longer supported! '
+ 'Please use limits at the server or collection '
+ 'level (RFC5)')
+ LOGGER.warning(msg)
+ try:
+ limit = evaluate_limit(request.params.get('limit'),
+ api.config['server'].get('limits', {}),
+ collections[dataset].get('limits', {}))
+ except ValueError as err:
+ return api.get_exception(
+ HTTPStatus.BAD_REQUEST, headers, request.format,
+ 'InvalidParameterValue', str(err))
+
+ query_args = dict(
+ query_type=query_type,
+ instance=instance,
+ format_=request.format,
+ datetime_=datetime_,
+ select_properties=parameternames,
+ wkt=wkt,
+ z=z,
+ bbox=bbox,
+ within=within,
+ within_units=within_units,
+ limit=limit,
+ location_id=location_id
+ )
+
+ try:
+ data = p.query(**query_args)
+ except ProviderGenericError as err:
+ return api.get_exception(
+ err.http_status_code, headers, request.format,
+ err.ogc_exception_code, err.message)
+
+ if request.format == F_HTML: # render
+ tpl_config = api.get_dataset_templates(dataset)
+
+ 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, tpl_config,
+ 'collections/edr/query.html', data,
+ api.default_locale)
+ else:
+ content = to_json(data, api.pretty_print)
+
+ return headers, HTTPStatus.OK, content
+
+
+def get_oas_30(cfg: dict, locale: str) -> tuple[list[dict[str, str]], dict[str, dict]]: # noqa
+ """
+ Get OpenAPI fragments
+
+ :param cfg: `dict` of configuration
+ :param locale: `str` of locale
+
+ :returns: `tuple` of `list` of tag objects, and `dict` of path objects
+ """
+
+ from pygeoapi.openapi import OPENAPI_YAML, get_visible_collections
+
+ LOGGER.debug('setting up edr endpoints')
+
+ paths = {}
+
+ collections = filter_dict_by_key_value(cfg['resources'],
+ 'type', 'collection')
+
+ for k, v in get_visible_collections(cfg).items():
+ edr_extension = filter_providers_by_type(
+ collections[k]['providers'], 'edr')
+
+ if edr_extension:
+ collection_name_path = f'/collections/{k}'
+
+ ep = load_plugin('provider', edr_extension)
+
+ edr_query_endpoints = []
+
+ for qt in [qt for qt in ep.get_query_types() if qt != 'locations']:
+ edr_query_endpoints.append({
+ 'path': f'{collection_name_path}/{qt}',
+ 'qt': qt,
+ 'op_id': f'query{qt.capitalize()}{k.capitalize()}'
+ })
+ if 'instances' in ep.get_query_types() and qt != 'instances':
+ edr_query_endpoints.append({
+ 'path': f'{collection_name_path}/instances/{{instanceId}}/{qt}', # noqa
+ 'qt': qt,
+ 'op_id': f'query{qt.capitalize()}Instance{k.capitalize()}' # noqa
+ })
+
+ for eqe in edr_query_endpoints:
+ if eqe['qt'] == 'cube':
+ spatial_parameter = {
+ 'description': 'Only features that have a geometry that intersects the bounding box are selected.The bounding box is provided as four or six numbers, depending on whether the coordinate reference system includes a vertical axis (height or depth).', # noqa
+ 'explode': False,
+ 'in': 'query',
+ 'name': 'bbox',
+ 'required': True,
+ 'schema': {
+ 'items': {
+ 'type': 'number'
+ },
+ 'maxItems': 6,
+ 'minItems': 4,
+ 'type': 'array'
+ },
+ 'style': 'form'
+ }
+ else:
+ spatial_parameter = {
+ '$ref': f"{OPENAPI_YAML['oaedr']}/parameters/{eqe['qt']}Coords.yaml" # noqa
+ }
+ paths[eqe['path']] = {
+ 'get': {
+ 'summary': f"query {v['description']} by {eqe['qt']}",
+ 'description': v['description'],
+ 'tags': [k],
+ 'operationId': eqe['op_id'],
+ 'parameters': [
+ spatial_parameter,
+ {'$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': {
+ '200': {
+ 'description': 'Response',
+ 'content': {
+ 'application/prs.coverage+json': {
+ 'schema': {
+ '$ref': f"{OPENAPI_YAML['oaedr']}/schemas/coverageJSON.yaml" # noqa
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ if 'instanceId' in eqe['path']:
+ paths[eqe['path']]['get']['parameters'].insert(0, {
+ '$ref': f"{OPENAPI_YAML['oaedr']}/parameters/instanceId.yaml"} # noqa
+ )
+
+ if 'instances' in ep.get_query_types():
+ paths[f'{collection_name_path}/instances'] = {
+ 'get': {
+ 'summary': f"Get pre-defined instances of {v['description']}", # noqa
+ 'description': v['description'],
+ 'tags': [k],
+ 'operationId': f'getInstances{k.capitalize()}',
+ 'parameters': [
+ {'$ref': '#/components/parameters/f'}
+ ],
+ 'responses': {
+ '200': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/Features"}, # noqa
+ '400': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/InvalidParameter"}, # noqa
+ '500': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/ServerError"} # noqa
+ }
+ }
+ }
+ paths[f'{collection_name_path}/instances/{{instanceId}}'] = {
+ 'get': {
+ 'summary': f"Get {v['description']} instance",
+ 'description': v['description'],
+ 'tags': [k],
+ 'operationId': f'getInstance{k.capitalize()}',
+ 'parameters': [
+ {'$ref': f"{OPENAPI_YAML['oaedr']}/parameters/instanceId.yaml"}, # noqa
+ {'$ref': '#/components/parameters/f'}
+ ],
+ 'responses': {
+ '200': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/Features"}, # noqa
+ }
+ }
+ }
+
+ if 'locations' in ep.get_query_types():
+ paths[f'{collection_name_path}/locations'] = {
+ 'get': {
+ 'summary': f"Get pre-defined locations of {v['description']}", # noqa
+ 'description': v['description'],
+ 'tags': [k],
+ 'operationId': f'getLocations{k.capitalize()}',
+ 'parameters': [
+ {'$ref': f"{OPENAPI_YAML['oaedr']}/parameters/bbox.yaml"}, # noqa
+ {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/parameters/datetime"}, # noqa
+ {'$ref': '#/components/parameters/f'}
+ ],
+ 'responses': {
+ '200': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/Features"}, # noqa
+ '400': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/InvalidParameter"}, # noqa
+ '500': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/ServerError"} # noqa
+ }
+ }
+ }
+ paths[f'{collection_name_path}/locations/{{locId}}'] = {
+ 'get': {
+ 'summary': f"query {v['description']} by location",
+ 'description': v['description'],
+ 'tags': [k],
+ 'operationId': f'getLocation{k.capitalize()}',
+ 'parameters': [
+ {'$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': '#/components/parameters/f'}
+ ],
+ 'responses': {
+ '200': {
+ 'description': 'Response',
+ 'content': {
+ 'application/prs.coverage+json': {
+ 'schema': {
+ '$ref': f"{OPENAPI_YAML['oaedr']}/schemas/coverageJSON.yaml" # noqa
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ return [{'name': 'edr'}], {'paths': paths}
diff --git a/pygeoapi/api/itemtypes.py b/pygeoapi/api/itemtypes.py
new file mode 100644
index 000000000..fa97bc392
--- /dev/null
+++ b/pygeoapi/api/itemtypes.py
@@ -0,0 +1,1356 @@
+# =================================================================
+#
+# Authors: Tom Kralidis
+# Francesco Bartoli
+# Sander Schaminee
+# John A Stevenson
+# Colin Blackburn
+# Ricardo Garcia Silva
+#
+# Copyright (c) 2024 Tom Kralidis
+# Copyright (c) 2022 Francesco Bartoli
+# Copyright (c) 2022 John A Stevenson and Colin Blackburn
+# Copyright (c) 2023 Ricardo Garcia Silva
+#
+# 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 copy import deepcopy
+from datetime import datetime
+from http import HTTPStatus
+import logging
+from typing import Any, Tuple, Union, Optional
+import urllib.parse
+
+from pygeofilter.parsers.ecql import parse as parse_ecql_text
+from pygeofilter.parsers.cql2_json import parse as parse_cql2_json
+from pyproj.exceptions import CRSError
+
+from pygeoapi import l10n
+from pygeoapi.api import evaluate_limit
+from pygeoapi.formatter.base import FormatterSerializationError
+from pygeoapi.linked_data import geojson2jsonld
+from pygeoapi.plugin import load_plugin, PLUGINS
+from pygeoapi.provider.base import (
+ ProviderGenericError, ProviderTypeError, SchemaType)
+
+from pygeoapi.util import (CrsTransformSpec, filter_providers_by_type,
+ filter_dict_by_key_value, get_crs_from_uri,
+ get_provider_by_type, get_supported_crs_list,
+ modify_pygeofilter, render_j2_template, str2bool,
+ to_json, transform_bbox)
+
+from . import (
+ APIRequest, API, SYSTEM_LOCALE, F_JSON, FORMAT_TYPES, F_HTML, F_JSONLD,
+ validate_bbox, validate_datetime
+)
+
+LOGGER = logging.getLogger(__name__)
+
+OGC_RELTYPES_BASE = 'http://www.opengis.net/def/rel/ogc/1.0'
+
+DEFAULT_CRS_LIST = [
+ 'http://www.opengis.net/def/crs/OGC/1.3/CRS84',
+ 'http://www.opengis.net/def/crs/OGC/1.3/CRS84h',
+]
+
+DEFAULT_CRS = 'http://www.opengis.net/def/crs/OGC/1.3/CRS84'
+DEFAULT_STORAGE_CRS = DEFAULT_CRS
+
+CONFORMANCE_CLASSES_FEATURES = [
+ 'http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/core',
+ 'http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/oas30',
+ 'http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/html',
+ 'http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/geojson',
+ 'http://www.opengis.net/spec/ogcapi-features-2/1.0/conf/crs',
+ 'http://www.opengis.net/spec/ogcapi-features-3/1.0/conf/queryables',
+ 'http://www.opengis.net/spec/ogcapi-features-3/1.0/conf/queryables-query-parameters', # noqa
+ 'http://www.opengis.net/spec/ogcapi-features-4/1.0/conf/create-replace-delete', # noqa
+ 'http://www.opengis.net/spec/ogcapi-features-5/1.0/conf/schemas',
+ 'http://www.opengis.net/spec/ogcapi-features-5/1.0/conf/core-roles-features' # noqa
+]
+
+CONFORMANCE_CLASSES_RECORDS = [
+ 'http://www.opengis.net/spec/ogcapi-records-1/1.0/conf/core',
+ 'http://www.opengis.net/spec/ogcapi-records-1/1.0/conf/sorting',
+ 'http://www.opengis.net/spec/ogcapi-records-1/1.0/conf/opensearch',
+ 'http://www.opengis.net/spec/ogcapi-records-1/1.0/conf/json',
+ 'http://www.opengis.net/spec/ogcapi-records-1/1.0/conf/html'
+]
+
+
+def get_collection_queryables(api: API, request: Union[APIRequest, Any],
+ dataset=None) -> Tuple[dict, int, str]:
+ """
+ Provide collection queryables
+
+ :param request: A request object
+ :param dataset: name of collection
+
+ :returns: tuple of headers, status code, content
+ """
+
+ headers = request.get_response_headers(**api.api_headers)
+
+ if any([dataset is None,
+ dataset not in api.config['resources'].keys()]):
+
+ msg = 'Collection not found'
+ return api.get_exception(
+ HTTPStatus.NOT_FOUND, headers, request.format, 'NotFound', msg)
+
+ LOGGER.debug('Creating collection queryables')
+
+ p = None
+ for pt in ['feature', 'coverage', 'record']:
+ try:
+ LOGGER.debug(f'Loading {pt} provider')
+ p = load_plugin('provider', get_provider_by_type(
+ api.config['resources'][dataset]['providers'], pt))
+ break
+ except ProviderTypeError:
+ LOGGER.debug(f'Providing type {pt} not found')
+
+ if p is None:
+ msg = 'queryables not available for this collection'
+ return api.get_exception(
+ HTTPStatus.BAD_REQUEST, headers, request.format,
+ 'NoApplicableError', msg)
+
+ queryables = {
+ 'type': 'object',
+ 'title': l10n.translate(
+ api.config['resources'][dataset]['title'], request.locale),
+ 'properties': {},
+ '$schema': 'http://json-schema.org/draft/2019-09/schema',
+ '$id': f'{api.get_collections_url()}/{dataset}/queryables'
+ }
+
+ if p.fields:
+ queryables['properties']['geometry'] = {
+ 'format': 'geometry-any',
+ 'x-ogc-role': 'primary-geometry',
+ #GA Customisation - Add $ref link to ensure geojson definition is accessible.
+ #To be reviewed when upgrading beyond version 0.20.0
+ '$ref': 'https://geojson.org/schema/Geometry.json'
+ }
+
+ for k, v in p.fields.items():
+ show_field = False
+ if p.properties:
+ if k in p.properties:
+ show_field = True
+ else:
+ show_field = True
+
+ if show_field:
+ queryables['properties'][k] = {
+ 'title': k,
+ 'type': v['type']
+ }
+ if v.get('format') is not None:
+ queryables['properties'][k]['format'] = v['format']
+ if 'values' in v:
+ queryables['properties'][k]['enum'] = v['values']
+
+ if k == p.id_field:
+ queryables['properties'][k]['x-ogc-role'] = 'id'
+ if k == p.time_field:
+ queryables['properties'][k]['x-ogc-role'] = 'primary-instant' # noqa
+
+ if request.format == F_HTML: # render
+ tpl_config = api.get_dataset_templates(dataset)
+
+ queryables['title'] = l10n.translate(
+ 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, tpl_config,
+ 'collections/queryables.html',
+ queryables, request.locale)
+
+ return headers, HTTPStatus.OK, content
+
+ headers['Content-Type'] = 'application/schema+json'
+
+ return headers, HTTPStatus.OK, to_json(queryables, api.pretty_print)
+
+
+def get_collection_items(
+ api: API, request: Union[APIRequest, Any],
+ dataset) -> Tuple[dict, int, str]:
+ """
+ Queries collection
+
+ :param request: A request object
+ :param dataset: dataset name
+
+ :returns: tuple of headers, status code, content
+ """
+
+ if not request.is_valid(PLUGINS['formatter'].keys()):
+ return api.get_format_exception(request)
+
+ # Set Content-Language to system locale until provider locale
+ # has been determined
+ headers = request.get_response_headers(SYSTEM_LOCALE,
+ **api.api_headers)
+
+ properties = []
+ reserved_fieldnames = ['bbox', 'bbox-crs', 'crs', 'f', 'lang', 'limit',
+ 'offset', 'resulttype', 'datetime', 'sortby',
+ 'properties', 'skipGeometry', 'q',
+ 'filter', 'filter-lang', 'filter-crs']
+
+ collections = filter_dict_by_key_value(api.config['resources'],
+ 'type', 'collection')
+
+ if dataset not in collections.keys():
+ msg = 'Collection not found'
+ return api.get_exception(
+ HTTPStatus.NOT_FOUND, headers, request.format, 'NotFound', msg)
+
+ LOGGER.debug('Processing query parameters')
+
+ 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 ValueError:
+ msg = 'offset value should be an integer'
+ return api.get_exception(
+ HTTPStatus.BAD_REQUEST, headers, request.format,
+ 'InvalidParameterValue', msg)
+ except TypeError as err:
+ LOGGER.warning(err)
+ offset = 0
+
+ LOGGER.debug('Processing limit parameter')
+ if api.config['server'].get('limit') is not None:
+ msg = ('server.limit is no longer supported! '
+ 'Please use limits at the server or collection '
+ 'level (RFC5)')
+ LOGGER.warning(msg)
+ try:
+ limit = evaluate_limit(request.params.get('limit'),
+ api.config['server'].get('limits', {}),
+ collections[dataset].get('limits', {}))
+ except ValueError as err:
+ return api.get_exception(
+ HTTPStatus.BAD_REQUEST, headers, request.format,
+ 'InvalidParameterValue', str(err))
+
+ resulttype = request.params.get('resulttype') or 'results'
+
+ LOGGER.debug('Processing bbox parameter')
+
+ bbox = request.params.get('bbox')
+
+ if bbox is None:
+ bbox = []
+ else:
+ try:
+ bbox = validate_bbox(bbox)
+ except ValueError as err:
+ msg = str(err)
+ return api.get_exception(
+ HTTPStatus.BAD_REQUEST, headers, request.format,
+ 'InvalidParameterValue', msg)
+
+ LOGGER.debug('Processing datetime parameter')
+ datetime_ = request.params.get('datetime')
+ try:
+ datetime_ = validate_datetime(collections[dataset]['extents'],
+ datetime_)
+ except ValueError as err:
+ msg = str(err)
+ return api.get_exception(
+ HTTPStatus.BAD_REQUEST, headers, request.format,
+ 'InvalidParameterValue', msg)
+
+ LOGGER.debug('processing q parameter')
+ q = request.params.get('q') or None
+
+ LOGGER.debug('Loading provider')
+
+ provider_def = None
+ try:
+ provider_type = 'feature'
+ provider_def = get_provider_by_type(
+ collections[dataset]['providers'], provider_type)
+ p = load_plugin('provider', provider_def)
+ except ProviderTypeError:
+ try:
+ provider_type = 'record'
+ provider_def = get_provider_by_type(
+ collections[dataset]['providers'], provider_type)
+ p = load_plugin('provider', provider_def)
+ except ProviderTypeError:
+ msg = 'Invalid provider type'
+ return api.get_exception(
+ HTTPStatus.BAD_REQUEST, headers, request.format,
+ 'NoApplicableCode', msg)
+ except ProviderGenericError as err:
+ return api.get_exception(
+ err.http_status_code, headers, request.format,
+ err.ogc_exception_code, err.message)
+
+ crs_transform_spec = None
+ if provider_type == 'feature':
+ # crs query parameter is only available for OGC API - Features
+ # right now, not for OGC API - Records.
+ LOGGER.debug('Processing crs parameter')
+ query_crs_uri = request.params.get('crs')
+ try:
+ crs_transform_spec = create_crs_transform_spec(
+ provider_def, query_crs_uri,
+ )
+ except (ValueError, CRSError) as err:
+ msg = str(err)
+ return api.get_exception(
+ HTTPStatus.BAD_REQUEST, headers, request.format,
+ 'InvalidParameterValue', msg)
+ set_content_crs_header(headers, provider_def, query_crs_uri)
+
+ LOGGER.debug('Processing bbox-crs parameter')
+ bbox_crs = request.params.get('bbox-crs')
+ if bbox_crs is not None:
+ # Validate bbox-crs parameter
+ if len(bbox) == 0:
+ msg = 'bbox-crs specified without bbox parameter'
+ return api.get_exception(
+ HTTPStatus.BAD_REQUEST, headers, request.format,
+ 'NoApplicableCode', msg)
+
+ if len(bbox_crs) == 0:
+ msg = 'bbox-crs specified but is empty'
+ return api.get_exception(
+ HTTPStatus.BAD_REQUEST, headers, request.format,
+ 'NoApplicableCode', msg)
+
+ supported_crs_list = get_supported_crs_list(provider_def, DEFAULT_CRS_LIST) # noqa
+ if bbox_crs not in supported_crs_list:
+ msg = f'bbox-crs {bbox_crs} not supported for this collection'
+ return api.get_exception(
+ HTTPStatus.BAD_REQUEST, headers, request.format,
+ 'NoApplicableCode', msg)
+ elif len(bbox) > 0:
+ # bbox but no bbox-crs param: assume bbox is in default CRS
+ bbox_crs = DEFAULT_CRS
+
+ # Transform bbox to storageCRS
+ # when bbox-crs different from storageCRS.
+ if len(bbox) > 0:
+ try:
+ # Get a pyproj CRS instance for the Collection's Storage CRS
+ storage_crs = provider_def.get('storage_crs', DEFAULT_STORAGE_CRS) # noqa
+
+ # Do the (optional) Transform to the Storage CRS
+ bbox = transform_bbox(bbox, bbox_crs, storage_crs)
+ except CRSError as e:
+ return api.get_exception(
+ HTTPStatus.BAD_REQUEST, headers, request.format,
+ 'NoApplicableCode', str(e))
+
+ LOGGER.debug('processing property parameters')
+ for k, v in request.params.items():
+ if k not in reserved_fieldnames:
+ 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')
+ val = request.params.get('sortby')
+
+ if val is not None:
+ sortby = []
+ sorts = val.split(',')
+ for s in sorts:
+ prop = s
+ order = '+'
+ if s[0] in ['+', '-']:
+ order = s[0]
+ prop = s[1:]
+
+ if prop not in p.fields.keys():
+ msg = 'bad sort property'
+ return api.get_exception(
+ HTTPStatus.BAD_REQUEST, headers, request.format,
+ 'InvalidParameterValue', msg)
+
+ sortby.append({'property': prop, 'order': order})
+ else:
+ sortby = []
+
+ LOGGER.debug('processing properties parameter')
+ val = request.params.get('properties')
+
+ if val is not None:
+ select_properties = val.split(',')
+ properties_to_check = set(p.properties) | set(p.fields.keys())
+
+ if (len(list(set(select_properties) -
+ set(properties_to_check))) > 0):
+ msg = 'unknown properties specified'
+ return api.get_exception(
+ HTTPStatus.BAD_REQUEST, headers, request.format,
+ 'InvalidParameterValue', msg)
+ else:
+ select_properties = []
+
+ LOGGER.debug('processing skipGeometry parameter')
+ val = request.params.get('skipGeometry')
+ if val is not None:
+ skip_geometry = str2bool(val)
+ else:
+ skip_geometry = False
+
+ LOGGER.debug('Processing filter-crs parameter')
+ filter_crs_uri = request.params.get('filter-crs', DEFAULT_CRS)
+
+ LOGGER.debug('processing filter parameter')
+ cql_text = request.params.get('filter')
+
+ if cql_text is not None:
+ try:
+ filter_ = parse_ecql_text(cql_text)
+ filter_ = modify_pygeofilter(
+ filter_,
+ filter_crs_uri=filter_crs_uri,
+ storage_crs_uri=provider_def.get('storage_crs'),
+ geometry_column_name=provider_def.get('geom_field'),
+ )
+ except Exception:
+ msg = 'Bad CQL text'
+ LOGGER.error(f'{msg}: {cql_text}')
+ return api.get_exception(
+ HTTPStatus.BAD_REQUEST, headers, request.format,
+ 'InvalidParameterValue', msg)
+ elif request.data:
+ try:
+ request_data = request.data.decode()
+ filter_ = parse_cql2_json(request_data)
+ filter_ = modify_pygeofilter(
+ filter_,
+ filter_crs_uri=filter_crs_uri,
+ storage_crs_uri=provider_def.get('storage_crs'),
+ geometry_column_name=provider_def.get('geom_field'),
+ )
+ except Exception:
+ msg = 'Bad CQL JSON'
+ LOGGER.error(f'{msg}: {request_data}')
+ return api.get_exception(
+ HTTPStatus.BAD_REQUEST, headers, request.format,
+ 'InvalidParameterValue', msg)
+ else:
+ filter_ = None
+
+ LOGGER.debug('Processing filter-lang parameter')
+ filter_lang = request.params.get('filter-lang')
+ # Currently only cql-text is handled, but it is optional
+ if filter_lang not in [None, 'cql-json', 'cql-text']:
+ msg = 'Invalid filter language'
+ return api.get_exception(
+ HTTPStatus.BAD_REQUEST, headers, request.format,
+ 'InvalidParameterValue', msg)
+ # Get provider locale (if any)
+ prv_locale = l10n.get_plugin_locale(provider_def, request.raw_locale)
+
+ LOGGER.debug('Querying provider')
+ LOGGER.debug(f'offset: {offset}')
+ LOGGER.debug(f'limit: {limit}')
+ LOGGER.debug(f'resulttype: {resulttype}')
+ LOGGER.debug(f'sortby: {sortby}')
+ LOGGER.debug(f'bbox: {bbox}')
+ if provider_type == 'feature':
+ LOGGER.debug(f'crs: {query_crs_uri}')
+ LOGGER.debug(f'datetime: {datetime_}')
+ LOGGER.debug(f'properties: {properties}')
+ LOGGER.debug(f'select properties: {select_properties}')
+ LOGGER.debug(f'skipGeometry: {skip_geometry}')
+ LOGGER.debug(f'language: {prv_locale}')
+ LOGGER.debug(f'q: {q}')
+ LOGGER.debug(f'cql_text: {cql_text}')
+ LOGGER.debug(f'filter_: {filter_}')
+ LOGGER.debug(f'filter-lang: {filter_lang}')
+ LOGGER.debug(f'filter-crs: {filter_crs_uri}')
+
+ try:
+ content = p.query(offset=offset, limit=limit,
+ resulttype=resulttype, bbox=bbox,
+ datetime_=datetime_, properties=properties,
+ sortby=sortby, skip_geometry=skip_geometry,
+ select_properties=select_properties,
+ crs_transform_spec=crs_transform_spec,
+ q=q, language=prv_locale, filterq=filter_)
+ except ProviderGenericError as err:
+ return api.get_exception(
+ err.http_status_code, headers, request.format,
+ err.ogc_exception_code, err.message)
+
+ 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=',')
+
+ if 'links' not in content:
+ content['links'] = []
+
+ # TODO: translate titles
+ uri = f'{api.get_collections_url()}/{dataset}/items'
+ content['links'].extend([{
+ 'type': 'application/geo+json',
+ 'rel': request.get_linkrel(F_JSON),
+ 'title': l10n.translate('This document as GeoJSON', request.locale),
+ 'href': f'{uri}?f={F_JSON}{serialized_query_params}'
+ }, {
+ 'rel': request.get_linkrel(F_JSONLD),
+ 'type': FORMAT_TYPES[F_JSONLD],
+ 'title': l10n.translate('This document as RDF (JSON-LD)', request.locale), # noqa
+ 'href': f'{uri}?f={F_JSONLD}{serialized_query_params}'
+ }, {
+ 'type': FORMAT_TYPES[F_HTML],
+ 'rel': request.get_linkrel(F_HTML),
+ 'title': l10n.translate('This document as HTML', request.locale),
+ 'href': f'{uri}?f={F_HTML}{serialized_query_params}'
+ }])
+
+ if offset > 0:
+ prev = max(0, offset - limit)
+ content['links'].append(
+ {
+ 'type': 'application/geo+json',
+ 'rel': 'prev',
+ 'title': l10n.translate('Items (prev)', request.locale),
+ 'href': f'{uri}?offset={prev}{serialized_query_params}'
+ })
+
+ 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(
+ {
+ 'type': FORMAT_TYPES[F_JSON],
+ 'title': l10n.translate(
+ collections[dataset]['title'], request.locale),
+ 'rel': 'collection',
+ 'href': '/'.join(uri.split('/')[:-1])
+ })
+
+ content['timeStamp'] = datetime.utcnow().strftime(
+ '%Y-%m-%dT%H:%M:%S.%fZ')
+
+ # Set response language to requested provider locale
+ # (if it supports language) and/or otherwise the requested pygeoapi
+ # locale (or fallback default locale)
+ l10n.set_response_language(headers, prv_locale, request.locale)
+
+ if request.format == F_HTML: # render
+ tpl_config = api.get_dataset_templates(dataset)
+ # For constructing proper URIs to items
+
+ content['items_path'] = uri
+ content['dataset_path'] = '/'.join(uri.split('/')[:-1])
+ content['collections_path'] = api.get_collections_url()
+
+ content['offset'] = offset
+
+ content['id_field'] = p.id_field
+ if p.uri_field is not None:
+ content['uri_field'] = p.uri_field
+ if p.title_field is not None:
+ content['title_field'] = l10n.translate(p.title_field,
+ request.locale)
+ # If title exists, use it as id in html templates
+ content['id_field'] = content['title_field']
+ content = render_j2_template(api.tpl_config, tpl_config,
+ 'collections/items/index.html',
+ content, request.locale)
+ return headers, HTTPStatus.OK, content
+ elif request.format == 'csv': # render
+ formatter = load_plugin('formatter',
+ {'name': 'CSV', 'geom': True})
+
+ try:
+ content = formatter.write(
+ data=content,
+ options={
+ 'provider_def': get_provider_by_type(
+ collections[dataset]['providers'],
+ 'feature')
+ }
+ )
+ except FormatterSerializationError:
+ msg = 'Error serializing output'
+ return api.get_exception(
+ HTTPStatus.INTERNAL_SERVER_ERROR, headers, request.format,
+ 'NoApplicableCode', msg)
+
+ headers['Content-Type'] = formatter.mimetype
+
+ if p.filename is None:
+ filename = f'{dataset}.csv'
+ else:
+ filename = f'{p.filename}'
+
+ cd = f'attachment; filename="{filename}"'
+ headers['Content-Disposition'] = cd
+
+ return headers, HTTPStatus.OK, content
+
+ elif request.format == F_JSONLD:
+ content = geojson2jsonld(
+ api, content, dataset, id_field=(p.uri_field or 'id')
+ )
+
+ return headers, HTTPStatus.OK, content
+
+ return headers, HTTPStatus.OK, to_json(content, api.pretty_print)
+
+
+def manage_collection_item(
+ api: API, request: APIRequest,
+ action, dataset, identifier=None) -> Tuple[dict, int, str]:
+ """
+ Adds an item to a collection
+
+ :param request: A request object
+ :param action: an action among 'create', 'update', 'delete', 'options'
+ :param dataset: dataset name
+
+ :returns: tuple of headers, status code, content
+ """
+
+ if not request.is_valid(PLUGINS['formatter'].keys()):
+ return api.get_format_exception(request)
+
+ # Set Content-Language to system locale until provider locale
+ # has been determined
+ headers = request.get_response_headers(SYSTEM_LOCALE, **api.api_headers)
+
+ collections = filter_dict_by_key_value(api.config['resources'],
+ 'type', 'collection')
+
+ if dataset not in collections.keys():
+ msg = 'Collection not found'
+ return api.get_exception(
+ HTTPStatus.NOT_FOUND, headers, request.format, 'NotFound', msg)
+
+ LOGGER.debug('Loading provider')
+ try:
+ provider_def = get_provider_by_type(
+ collections[dataset]['providers'], 'feature')
+ p = load_plugin('provider', provider_def)
+ except ProviderTypeError:
+ try:
+ provider_def = get_provider_by_type(
+ collections[dataset]['providers'], 'record')
+ p = load_plugin('provider', provider_def)
+ except ProviderTypeError:
+ msg = 'Invalid provider type'
+ return api.get_exception(
+ HTTPStatus.BAD_REQUEST, headers, request.format,
+ 'InvalidParameterValue', msg)
+
+ if action == 'options':
+ headers['Allow'] = 'HEAD, GET'
+ if p.editable:
+ if identifier is None:
+ headers['Allow'] += ', POST'
+ else:
+ headers['Allow'] += ', PUT, DELETE'
+ return headers, HTTPStatus.OK, ''
+
+ if not p.editable:
+ msg = 'Collection is not editable'
+ return api.get_exception(
+ HTTPStatus.BAD_REQUEST, headers, request.format,
+ 'InvalidParameterValue', msg)
+
+ if action in ['create', 'update'] and not request.data:
+ msg = 'No data found'
+ return api.get_exception(
+ HTTPStatus.BAD_REQUEST, headers, request.format,
+ 'InvalidParameterValue', msg)
+
+ if action == 'create':
+ LOGGER.debug('Creating item')
+ try:
+ identifier = p.create(request.data)
+ except TypeError as err:
+ msg = str(err)
+ return api.get_exception(
+ HTTPStatus.BAD_REQUEST, headers, request.format,
+ 'InvalidParameterValue', msg)
+ except ProviderGenericError as err:
+ return api.get_exception(
+ err.http_status_code, headers, request.format,
+ err.ogc_exception_code, err.message)
+
+ headers['Location'] = f'{api.get_collections_url()}/{dataset}/items/{identifier}' # noqa
+
+ return headers, HTTPStatus.CREATED, ''
+
+ if action == 'update':
+ LOGGER.debug('Updating item')
+ try:
+ _ = p.update(identifier, request.data)
+ except TypeError as err:
+ msg = str(err)
+ return api.get_exception(
+ HTTPStatus.BAD_REQUEST, headers, request.format,
+ 'InvalidParameterValue', msg)
+ except ProviderGenericError as err:
+ return api.get_exception(
+ err.http_status_code, headers, request.format,
+ err.ogc_exception_code, err.message)
+
+ return headers, HTTPStatus.NO_CONTENT, ''
+
+ if action == 'delete':
+ LOGGER.debug('Deleting item')
+ try:
+ _ = p.delete(identifier)
+ except ProviderGenericError as err:
+ return api.get_exception(
+ err.http_status_code, headers, request.format,
+ err.ogc_exception_code, err.message)
+
+ return headers, HTTPStatus.OK, ''
+
+
+def get_collection_item(api: API, request: APIRequest,
+ dataset, identifier) -> Tuple[dict, int, str]:
+ """
+ Get a single collection item
+
+ :param request: A request object
+ :param dataset: dataset name
+ :param identifier: item identifier
+
+ :returns: tuple of headers, status code, content
+ """
+
+ # Set Content-Language to system locale until provider locale
+ # has been determined
+ headers = request.get_response_headers(SYSTEM_LOCALE, **api.api_headers)
+
+ LOGGER.debug('Processing query parameters')
+
+ collections = filter_dict_by_key_value(api.config['resources'],
+ 'type', 'collection')
+
+ if dataset not in collections.keys():
+ msg = 'Collection not found'
+ return api.get_exception(
+ HTTPStatus.NOT_FOUND, headers, request.format, 'NotFound', msg)
+
+ LOGGER.debug('Loading provider')
+
+ try:
+ provider_type = 'feature'
+ provider_def = get_provider_by_type(
+ collections[dataset]['providers'], provider_type)
+ p = load_plugin('provider', provider_def)
+ except ProviderTypeError:
+ try:
+ provider_type = 'record'
+ provider_def = get_provider_by_type(
+ collections[dataset]['providers'], provider_type)
+ p = load_plugin('provider', provider_def)
+ except ProviderTypeError:
+ msg = 'Invalid provider type'
+ return api.get_exception(
+ HTTPStatus.BAD_REQUEST, headers, request.format,
+ 'InvalidParameterValue', msg)
+ except ProviderGenericError as err:
+ return api.get_exception(
+ err.http_status_code, headers, request.format,
+ err.ogc_exception_code, err.message)
+
+ crs_transform_spec = None
+ if provider_type == 'feature':
+ # crs query parameter is only available for OGC API - Features
+ # right now, not for OGC API - Records.
+ LOGGER.debug('Processing crs parameter')
+ query_crs_uri = request.params.get('crs')
+ try:
+ crs_transform_spec = create_crs_transform_spec(
+ provider_def, query_crs_uri,
+ )
+ except (ValueError, CRSError) as err:
+ msg = str(err)
+ return api.get_exception(
+ HTTPStatus.BAD_REQUEST, headers, request.format,
+ 'InvalidParameterValue', msg)
+ set_content_crs_header(headers, provider_def, query_crs_uri)
+
+ # Get provider language (if any)
+ prv_locale = l10n.get_plugin_locale(provider_def, request.raw_locale)
+
+ try:
+ LOGGER.debug(f'Fetching id {identifier}')
+ content = p.get(
+ identifier,
+ language=prv_locale,
+ crs_transform_spec=crs_transform_spec,
+ )
+ except ProviderGenericError as err:
+ return api.get_exception(
+ err.http_status_code, headers, request.format,
+ err.ogc_exception_code, err.message)
+
+ if content is None:
+ msg = 'identifier not found'
+ return api.get_exception(HTTPStatus.BAD_REQUEST, headers,
+ request.format, 'NotFound', msg)
+
+ uri = content['properties'].get(p.uri_field) if p.uri_field else \
+ f'{api.get_collections_url()}/{dataset}/items/{identifier}'
+
+ if 'links' not in content:
+ content['links'] = []
+
+ content['links'].extend([{
+ 'type': FORMAT_TYPES[F_JSON],
+ 'rel': 'root',
+ 'title': l10n.translate('The landing page of this server as JSON', request.locale), # noqa
+ 'href': f"{api.base_url}?f={F_JSON}"
+ }, {
+ 'type': FORMAT_TYPES[F_HTML],
+ 'rel': 'root',
+ 'title': l10n.translate('The landing page of this server as HTML', request.locale), # noqa
+ 'href': f"{api.base_url}?f={F_HTML}"
+ }, {
+ 'rel': request.get_linkrel(F_JSON),
+ 'type': 'application/geo+json',
+ 'title': l10n.translate('This document as JSON', request.locale),
+ 'href': f'{uri}?f={F_JSON}'
+ }, {
+ 'rel': request.get_linkrel(F_JSONLD),
+ 'type': FORMAT_TYPES[F_JSONLD],
+ 'title': l10n.translate('This document as RDF (JSON-LD)', request.locale), # noqa
+ 'href': f'{uri}?f={F_JSONLD}'
+ }, {
+ 'rel': request.get_linkrel(F_HTML),
+ 'type': FORMAT_TYPES[F_HTML],
+ 'title': l10n.translate('This document as HTML', request.locale),
+ 'href': f'{uri}?f={F_HTML}'
+ }, {
+ 'rel': 'collection',
+ 'type': FORMAT_TYPES[F_JSON],
+ 'title': l10n.translate(collections[dataset]['title'],
+ request.locale),
+ 'href': f'{api.get_collections_url()}/{dataset}'
+ }])
+
+ link_request_format = (
+ request.format if request.format is not None else F_JSON
+ )
+ if 'prev' in content:
+ content['links'].append({
+ 'rel': 'prev',
+ 'type': FORMAT_TYPES[link_request_format],
+ 'href': f"{api.get_collections_url()}/{dataset}/items/{content['prev']}?f={link_request_format}" # noqa
+ })
+ if 'next' in content:
+ content['links'].append({
+ 'rel': 'next',
+ 'type': FORMAT_TYPES[link_request_format],
+ 'href': f"{api.get_collections_url()}/{dataset}/items/{content['next']}?f={link_request_format}" # noqa
+ })
+
+ # Set response language to requested provider locale
+ # (if it supports language) and/or otherwise the requested pygeoapi
+ # locale (or fallback default locale)
+ l10n.set_response_language(headers, prv_locale, request.locale)
+
+ # GA customisation - Add PID LinkAdd commentMore actions
+ if collections[dataset]['other']:
+ content['pid'] = f'{collections[dataset]["other"]["href"]}{identifier}'
+ # End GA customisation
+
+ if request.format == F_HTML: # render
+ tpl_config = api.get_dataset_templates(dataset)
+ content['title'] = l10n.translate(collections[dataset]['title'],
+ request.locale)
+ content['id_field'] = p.id_field
+ if p.uri_field is not None:
+ content['uri_field'] = p.uri_field
+ if p.title_field is not None:
+ content['title_field'] = l10n.translate(p.title_field,
+ request.locale)
+ content['collections_path'] = api.get_collections_url()
+
+ content = render_j2_template(api.tpl_config, tpl_config,
+ 'collections/items/item.html',
+ content, request.locale)
+ return headers, HTTPStatus.OK, content
+
+ elif request.format == F_JSONLD:
+ content = geojson2jsonld(
+ api, content, dataset, uri, (p.uri_field or 'id')
+ )
+
+ return headers, HTTPStatus.OK, content
+
+ return headers, HTTPStatus.OK, to_json(content, api.pretty_print)
+
+
+def create_crs_transform_spec(
+ config: dict, query_crs_uri: Optional[str] = None) -> Union[None, CrsTransformSpec]: # noqa
+ """
+ Create a `CrsTransformSpec` instance based on provider config and
+ *crs* query parameter.
+
+ :param config: Provider config dictionary.
+ :type config: dict
+ :param query_crs_uri: Uniform resource identifier of the coordinate
+ reference system (CRS) specified in query parameter (if specified).
+ :type query_crs_uri: str, optional
+
+ :raises ValueError: Error raised if the CRS specified in the query
+ parameter is not in the list of supported CRSs of the provider.
+ :raises `CRSError`: Error raised if no CRS could be identified from the
+ query *crs* parameter (URI).
+
+ :returns: `CrsTransformSpec` instance if the CRS specified in query
+ parameter differs from the storage CRS, else `None`.
+ :rtype: Union[None, CrsTransformSpec]
+ """
+
+ # Get storage/default CRS for Collection.
+ storage_crs_uri = config.get('storage_crs', DEFAULT_STORAGE_CRS)
+
+ if not query_crs_uri:
+ if storage_crs_uri in DEFAULT_CRS_LIST:
+ # Could be that storageCRS is
+ # http://www.opengis.net/def/crs/OGC/1.3/CRS84h
+ query_crs_uri = storage_crs_uri
+ else:
+ query_crs_uri = DEFAULT_CRS
+ LOGGER.debug(f'no crs parameter, using default: {query_crs_uri}')
+
+ supported_crs_list = get_supported_crs_list(config, DEFAULT_CRS_LIST)
+ # Check that the crs specified by the query parameter is supported.
+ if query_crs_uri not in supported_crs_list:
+ raise ValueError(
+ f'CRS {query_crs_uri!r} not supported for this '
+ 'collection. List of supported CRSs: '
+ f'{", ".join(supported_crs_list)}.'
+ )
+ crs_out = get_crs_from_uri(query_crs_uri)
+
+ storage_crs = get_crs_from_uri(storage_crs_uri)
+ # Check if the crs specified in query parameter differs from the
+ # storage crs.
+ if str(storage_crs) != str(crs_out):
+ LOGGER.debug(
+ f'CRS transformation: {storage_crs} -> {crs_out}'
+ )
+ return CrsTransformSpec(
+ source_crs_uri=storage_crs_uri,
+ source_crs_wkt=storage_crs.to_wkt(),
+ target_crs_uri=query_crs_uri,
+ target_crs_wkt=crs_out.to_wkt(),
+ )
+ else:
+ LOGGER.debug('No CRS transformation')
+ return None
+
+
+def set_content_crs_header(
+ headers: dict, config: dict, query_crs_uri: Optional[str] = None):
+ """
+ Set the *Content-Crs* header in responses from providers of Feature type.
+
+ :param headers: Response headers dictionary.
+ :type headers: dict
+ :param config: Provider config dictionary.
+ :type config: dict
+ :param query_crs_uri: Uniform resource identifier of the coordinate
+ reference system specified in query parameter (if specified).
+ :type query_crs_uri: str, optional
+
+ :returns: None
+ """
+
+ if query_crs_uri:
+ content_crs_uri = query_crs_uri
+ else:
+ # If empty use default CRS
+ storage_crs_uri = config.get('storage_crs', DEFAULT_STORAGE_CRS)
+ if storage_crs_uri in DEFAULT_CRS_LIST:
+ # Could be that storageCRS is one of the defaults like
+ # http://www.opengis.net/def/crs/OGC/1.3/CRS84h
+ content_crs_uri = storage_crs_uri
+ else:
+ content_crs_uri = DEFAULT_CRS
+
+ headers['Content-Crs'] = f'<{content_crs_uri}>'
+
+
+def get_oas_30(cfg: dict, locale: str) -> tuple[list[dict[str, str]], dict[str, dict]]: # noqa
+ """
+ Get OpenAPI fragments
+
+ :param cfg: `dict` of configuration
+ :param locale: `str` of locale
+
+ :returns: `tuple` of `list` of tag objects, and `dict` of path objects
+ """
+
+ from pygeoapi.openapi import OPENAPI_YAML, get_visible_collections
+
+ properties = {
+ 'name': 'properties',
+ 'in': 'query',
+ 'description': 'The properties that should be included for each feature. The parameter value is a comma-separated list of property names.', # noqa
+ 'required': False,
+ 'style': 'form',
+ 'explode': False,
+ 'schema': {
+ 'type': 'array',
+ 'items': {
+ 'type': 'string'
+ }
+ }
+ }
+
+ LOGGER.debug('setting up collection endpoints')
+ paths = {}
+
+ collections = filter_dict_by_key_value(cfg['resources'],
+ 'type', 'collection')
+
+ for k, v in get_visible_collections(cfg).items():
+ try:
+ ptype = None
+
+ if filter_providers_by_type(
+ collections[k]['providers'], 'feature'):
+ ptype = 'feature'
+
+ if filter_providers_by_type(
+ collections[k]['providers'], 'record'):
+ ptype = 'record'
+
+ p = load_plugin('provider', get_provider_by_type(
+ collections[k]['providers'], ptype))
+
+ collection_name_path = f'/collections/{k}'
+ items_path = f'/collections/{k}/items'
+ title = l10n.translate(v['title'], locale)
+ description = l10n.translate(v['description'], locale)
+
+ coll_properties = deepcopy(properties)
+
+ coll_properties['schema']['items']['enum'] = list(p.fields.keys())
+
+ paths[items_path] = {
+ 'get': {
+ 'summary': f'Get {title} items',
+ 'description': description,
+ 'tags': [k],
+ 'operationId': f'get{k.capitalize()}Features',
+ 'parameters': [
+ {'$ref': '#/components/parameters/f'},
+ {'$ref': '#/components/parameters/lang'},
+ {'$ref': '#/components/parameters/bbox'},
+ {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/parameters/limit"}, # noqa
+ {'$ref': '#/components/parameters/crs'}, # noqa
+ {'$ref': '#/components/parameters/bbox-crs'},
+ coll_properties,
+ {'$ref': '#/components/parameters/vendorSpecificParameters'}, # noqa
+ {'$ref': '#/components/parameters/skipGeometry'},
+ {'$ref': f"{OPENAPI_YAML['oapir']}/parameters/sortby.yaml"}, # noqa
+ {'$ref': '#/components/parameters/offset'}
+ ],
+ 'responses': {
+ '200': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/Features"}, # noqa
+ '400': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/InvalidParameter"}, # noqa
+ '404': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/NotFound"}, # noqa
+ '500': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/ServerError"} # noqa
+ }
+ },
+ 'options': {
+ 'summary': f'Options for {title} items',
+ 'description': description,
+ 'tags': [k],
+ 'operationId': f'options{k.capitalize()}Features',
+ 'responses': {
+ '200': {'description': 'options response'}
+ }
+ }
+ }
+
+ if p.editable:
+ LOGGER.debug('Provider is editable; adding post')
+
+ paths[items_path]['post'] = {
+ 'summary': f'Add {title} items',
+ 'description': description,
+ 'tags': [k],
+ 'operationId': f'add{k.capitalize()}Features',
+ 'requestBody': {
+ 'description': 'Adds item to collection',
+ 'content': {
+ 'application/geo+json': {
+ 'schema': {}
+ }
+ },
+ 'required': True
+ },
+ 'responses': {
+ '201': {'description': 'Successful creation'},
+ '400': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/InvalidParameter"}, # noqa
+ '500': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/ServerError"} # noqa
+ }
+ }
+
+ try:
+ schema_ref = p.get_schema(SchemaType.create)
+ paths[items_path]['post']['requestBody']['content'][schema_ref[0]] = { # noqa
+ 'schema': schema_ref[1]
+ }
+ except Exception as err:
+ LOGGER.debug(err)
+
+ if ptype == 'record':
+ paths[items_path]['get']['parameters'].append(
+ {'$ref': f"{OPENAPI_YAML['oapir']}/parameters/q.yaml"})
+ if p.fields:
+ schema_path = f'{collection_name_path}/schema'
+
+ paths[schema_path] = {
+ 'get': {
+ 'summary': f'Get {title} schema',
+ 'description': description,
+ 'tags': [k],
+ 'operationId': f'get{k.capitalize()}Schema',
+ 'parameters': [
+ {'$ref': '#/components/parameters/f'},
+ {'$ref': '#/components/parameters/lang'}
+ ],
+ 'responses': {
+ '200': {'$ref': '#/components/responses/Queryables'}, # noqa
+ '400': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/InvalidParameter"}, # noqa
+ '404': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/NotFound"}, # noqa
+ '500': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/ServerError"}, # noqa
+ }
+ }
+ }
+
+ queryables_path = f'{collection_name_path}/queryables'
+
+ paths[queryables_path] = {
+ 'get': {
+ 'summary': f'Get {title} queryables',
+ 'description': description,
+ 'tags': [k],
+ 'operationId': f'get{k.capitalize()}Queryables',
+ 'parameters': [
+ {'$ref': '#/components/parameters/f'},
+ {'$ref': '#/components/parameters/lang'}
+ ],
+ 'responses': {
+ '200': {'$ref': '#/components/responses/Queryables'}, # noqa
+ '400': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/InvalidParameter"}, # noqa
+ '404': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/NotFound"}, # noqa
+ '500': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/ServerError"}, # noqa
+ }
+ }
+ }
+
+ if p.time_field is not None:
+ paths[items_path]['get']['parameters'].append(
+ {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/parameters/datetime"}) # noqa
+
+ for field, type_ in p.fields.items():
+
+ if p.properties and field not in p.properties:
+ LOGGER.debug('Provider specified not to advertise property') # noqa
+ continue
+
+ if field == 'q' and ptype == 'record':
+ LOGGER.debug('q parameter already declared, skipping')
+ continue
+
+ if type_ == 'date':
+ schema = {
+ 'type': 'string',
+ 'format': 'date'
+ }
+ elif type_ == 'float':
+ schema = {
+ 'type': 'number',
+ 'format': 'float'
+ }
+ elif type_ == 'long':
+ schema = {
+ 'type': 'integer',
+ 'format': 'int64'
+ }
+ else:
+ schema = type_
+
+ if schema.get('format') is None:
+ schema.pop('format', None)
+
+ path_ = f'{collection_name_path}/items'
+ paths[path_]['get']['parameters'].append({
+ 'name': field,
+ 'in': 'query',
+ 'required': False,
+ 'schema': schema,
+ 'style': 'form',
+ 'explode': False
+ })
+
+ paths[f'{collection_name_path}/items/{{featureId}}'] = {
+ 'get': {
+ 'summary': f'Get {title} item by id',
+ 'description': description,
+ 'tags': [k],
+ 'operationId': f'get{k.capitalize()}Feature',
+ 'parameters': [
+ {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/parameters/featureId"}, # noqa
+ {'$ref': '#/components/parameters/crs'}, # noqa
+ {'$ref': '#/components/parameters/f'},
+ {'$ref': '#/components/parameters/lang'}
+ ],
+ 'responses': {
+ '200': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/Feature"}, # noqa
+ '400': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/InvalidParameter"}, # noqa
+ '404': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/NotFound"}, # noqa
+ '500': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/ServerError"} # noqa
+ }
+ },
+ 'options': {
+ 'summary': f'Options for {title} item by id',
+ 'description': description,
+ 'tags': [k],
+ 'operationId': f'options{k.capitalize()}Feature',
+ 'parameters': [
+ {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/parameters/featureId"} # noqa
+ ],
+ 'responses': {
+ '200': {'description': 'options response'}
+ }
+ }
+ }
+
+ try:
+ schema_ref = p.get_schema()
+ paths[f'{collection_name_path}/items/{{featureId}}']['get']['responses']['200'] = { # noqa
+ 'content': {
+ schema_ref[0]: {
+ 'schema': schema_ref[1]
+ }
+ }
+ }
+ except Exception as err:
+ LOGGER.debug(err)
+
+ if p.editable:
+ LOGGER.debug('Provider is editable; adding put/delete')
+ put_path = f'{collection_name_path}/items/{{featureId}}' # noqa
+ paths[put_path]['put'] = { # noqa
+ 'summary': f'Update {title} items',
+ 'description': description,
+ 'tags': [k],
+ 'operationId': f'update{k.capitalize()}Features',
+ 'parameters': [
+ {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/parameters/featureId"} # noqa
+ ],
+ 'requestBody': {
+ 'description': 'Updates item in collection',
+ 'content': {
+ 'application/geo+json': {
+ 'schema': {}
+ }
+ },
+ 'required': True
+ },
+ 'responses': {
+ '204': {'$ref': '#/components/responses/204'},
+ '400': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/InvalidParameter"}, # noqa
+ '500': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/ServerError"} # noqa
+ }
+ }
+
+ try:
+ schema_ref = p.get_schema(SchemaType.replace)
+ paths[put_path]['put']['requestBody']['content'][schema_ref[0]] = { # noqa
+ 'schema': schema_ref[1]
+ }
+ except Exception as err:
+ LOGGER.debug(err)
+
+ paths[f'{collection_name_path}/items/{{featureId}}']['delete'] = { # noqa
+ 'summary': f'Delete {title} items',
+ 'description': description,
+ 'tags': [k],
+ 'operationId': f'delete{k.capitalize()}Features',
+ 'parameters': [
+ {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/parameters/featureId"}, # noqa
+ ],
+ 'responses': {
+ '200': {'description': 'Successful delete'},
+ '400': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/InvalidParameter"}, # noqa
+ '500': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/ServerError"} # noqa
+ }
+ }
+
+ except ProviderTypeError:
+ LOGGER.debug('collection is not feature/item based')
+
+ return [{'name': 'records'}, {'name': 'features'}], {'paths': paths}
diff --git a/pygeoapi/api/maps.py b/pygeoapi/api/maps.py
new file mode 100644
index 000000000..036892fe2
--- /dev/null
+++ b/pygeoapi/api/maps.py
@@ -0,0 +1,336 @@
+# =================================================================
+
+# Authors: Tom Kralidis
+# Francesco Bartoli
+# Sander Schaminee
+# John A Stevenson
+# Colin Blackburn
+# Ricardo Garcia Silva
+# Bernhard Mallinger
+#
+# Copyright (c) 2024 Tom Kralidis
+# Copyright (c) 2022 Francesco Bartoli
+# Copyright (c) 2022 John A Stevenson and Colin Blackburn
+# Copyright (c) 2023 Ricardo Garcia Silva
+# Copyright (c) 2024 Bernhard Mallinger
+#
+# 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 copy import deepcopy
+from http import HTTPStatus
+import logging
+from typing import Tuple
+
+from pygeoapi.openapi import get_oas_30_parameters
+from pygeoapi.plugin import load_plugin
+from pygeoapi.provider.base import ProviderGenericError
+from pygeoapi.util import (
+ get_provider_by_type, to_json, filter_providers_by_type,
+ filter_dict_by_key_value
+)
+
+from . import APIRequest, API, validate_datetime
+
+LOGGER = logging.getLogger(__name__)
+
+CONFORMANCE_CLASSES = [
+ 'http://www.opengis.net/spec/ogcapi-maps-1/1.0/conf/core'
+]
+
+
+def get_collection_map(api: API, request: APIRequest,
+ dataset, style=None) -> Tuple[dict, int, str]:
+ """
+ Returns a subset of a collection map
+
+ :param request: A request object
+ :param dataset: dataset name
+ :param style: style name
+
+ :returns: tuple of headers, status code, content
+ """
+
+ query_args = {
+ 'crs': 'CRS84'
+ }
+
+ format_ = request.format or 'png'
+ headers = request.get_response_headers(**api.api_headers)
+ LOGGER.debug('Processing query parameters')
+
+ LOGGER.debug('Loading provider')
+ try:
+ collection_def = get_provider_by_type(
+ api.config['resources'][dataset]['providers'], 'map')
+
+ p = load_plugin('provider', collection_def)
+ except KeyError:
+ exception = {
+ 'code': 'InvalidParameterValue',
+ 'description': 'collection does not exist'
+ }
+ headers['Content-type'] = 'application/json'
+ LOGGER.error(exception)
+ return headers, HTTPStatus.NOT_FOUND, to_json(
+ exception, api.pretty_print)
+ except ProviderGenericError as err:
+ return api.get_exception(
+ err.http_status_code, headers, request.format,
+ err.ogc_exception_code, err.message)
+
+ query_args['format_'] = request.params.get('f', 'png')
+ query_args['style'] = style
+ query_args['crs'] = request.params.get('bbox-crs', 4326)
+ query_args['transparent'] = request.params.get('transparent', True)
+
+ try:
+ query_args['width'] = int(request.params.get('width', 500))
+ query_args['height'] = int(request.params.get('height', 300))
+ except ValueError:
+ exception = {
+ 'code': 'InvalidParameterValue',
+ 'description': 'invalid width/height'
+ }
+ headers['Content-type'] = 'application/json'
+ LOGGER.error(exception)
+ return headers, HTTPStatus.BAD_REQUEST, to_json(
+ exception, api.pretty_print)
+
+ LOGGER.debug('Processing bbox parameter')
+ try:
+ bbox = request.params.get('bbox').split(',')
+ if len(bbox) != 4:
+ exception = {
+ 'code': 'InvalidParameterValue',
+ 'description': 'bbox values should be minx,miny,maxx,maxy'
+ }
+ headers['Content-type'] = 'application/json'
+ LOGGER.error(exception)
+ return headers, HTTPStatus.BAD_REQUEST, to_json(
+ exception, api.pretty_print)
+ except AttributeError:
+ bbox = api.config['resources'][dataset]['extents']['spatial']['bbox'] # noqa
+ try:
+ query_args['bbox'] = [float(c) for c in bbox]
+ except ValueError:
+ exception = {
+ 'code': 'InvalidParameterValue',
+ 'description': 'bbox values must be numbers'
+ }
+ headers['Content-type'] = 'application/json'
+ LOGGER.error(exception)
+ return headers, HTTPStatus.BAD_REQUEST, to_json(
+ exception, api.pretty_print)
+
+ LOGGER.debug('Processing datetime parameter')
+ datetime_ = request.params.get('datetime')
+ try:
+ query_args['datetime_'] = validate_datetime(
+ api.config['resources'][dataset]['extents'], datetime_)
+ except ValueError as err:
+ msg = str(err)
+ return api.get_exception(
+ HTTPStatus.BAD_REQUEST, headers, request.format,
+ 'InvalidParameterValue', msg)
+
+ LOGGER.debug('Generating map')
+ try:
+ data = p.query(**query_args)
+ except ProviderGenericError as err:
+ return api.get_exception(
+ err.http_status_code, headers, request.format,
+ err.ogc_exception_code, err.message)
+
+ mt = collection_def['format']['name']
+
+ if format_ == mt:
+ headers['Content-Type'] = collection_def['format']['mimetype']
+ return headers, HTTPStatus.OK, data
+ elif format_ in [None, 'html']:
+ headers['Content-Type'] = collection_def['format']['mimetype']
+ return headers, HTTPStatus.OK, data
+ else:
+ exception = {
+ 'code': 'InvalidParameterValue',
+ 'description': 'invalid format parameter'
+ }
+ LOGGER.error(exception)
+ return headers, HTTPStatus.BAD_REQUEST, to_json(
+ data, api.pretty_print)
+
+
+def get_collection_map_legend(api: API, request: APIRequest,
+ dataset, style=None) -> Tuple[dict, int, str]:
+ """
+ Returns a subset of a collection map legend
+
+ :param request: A request object
+ :param dataset: dataset name
+ :param style: style name
+
+ :returns: tuple of headers, status code, content
+ """
+
+ format_ = 'png'
+ headers = request.get_response_headers(**api.api_headers)
+ LOGGER.debug('Processing query parameters')
+
+ LOGGER.debug('Loading provider')
+ try:
+ collection_def = get_provider_by_type(
+ api.config['resources'][dataset]['providers'], 'map')
+
+ p = load_plugin('provider', collection_def)
+ except KeyError:
+ exception = {
+ 'code': 'InvalidParameterValue',
+ 'description': 'collection does not exist'
+ }
+ LOGGER.error(exception)
+ return headers, HTTPStatus.NOT_FOUND, to_json(
+ exception, api.pretty_print)
+ except ProviderGenericError as err:
+ return api.get_exception(
+ err.http_status_code, headers, request.format,
+ err.ogc_exception_code, err.message)
+
+ LOGGER.debug('Generating legend')
+ try:
+ data = p.get_legend(style, request.params.get('f', 'png'))
+ except ProviderGenericError as err:
+ return api.get_exception(
+ err.http_status_code, headers, request.format,
+ err.ogc_exception_code, err.message)
+
+ mt = collection_def['format']['name']
+
+ if format_ == mt:
+ headers['Content-Type'] = collection_def['format']['mimetype']
+ return headers, HTTPStatus.OK, data
+ else:
+ exception = {
+ 'code': 'InvalidParameterValue',
+ 'description': 'invalid format parameter'
+ }
+ LOGGER.error(exception)
+ return headers, HTTPStatus.BAD_REQUEST, to_json(
+ data, api.pretty_print)
+
+
+def get_oas_30(cfg: dict, locale: str) -> tuple[list[dict[str, str]], dict[str, dict]]: # noqa
+ """
+ Get OpenAPI fragments
+
+ :param cfg: `dict` of configuration
+ :param locale: `str` of locale
+
+ :returns: `tuple` of `list` of tag objects, and `dict` of path objects
+ """
+
+ from pygeoapi.openapi import OPENAPI_YAML, get_visible_collections
+
+ LOGGER.debug('setting up maps endpoints')
+
+ paths = {}
+
+ collections = filter_dict_by_key_value(cfg['resources'],
+ 'type', 'collection')
+
+ parameters = get_oas_30_parameters(cfg, locale)
+ for k, v in get_visible_collections(cfg).items():
+ map_extension = filter_providers_by_type(
+ collections[k]['providers'], 'map')
+
+ if map_extension:
+ mp = load_plugin('provider', map_extension)
+
+ map_f = deepcopy(parameters['f'])
+ map_f['schema']['enum'] = [map_extension['format']['name']]
+ map_f['schema']['default'] = map_extension['format']['name']
+
+ pth = f'/collections/{k}/map'
+ paths[pth] = {
+ 'get': {
+ 'summary': 'Get map',
+ 'description': f"{v['description']} map",
+ 'tags': [k],
+ 'operationId': 'getMap',
+ 'parameters': [
+ {'$ref': '#/components/parameters/bbox'},
+ {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/parameters/datetime"}, # noqa
+ {
+ 'name': 'width',
+ 'in': 'query',
+ 'description': 'Response image width',
+ 'required': False,
+ 'schema': {
+ 'type': 'integer',
+ },
+ 'style': 'form',
+ 'explode': False
+ },
+ {
+ 'name': 'height',
+ 'in': 'query',
+ 'description': 'Response image height',
+ 'required': False,
+ 'schema': {
+ 'type': 'integer',
+ },
+ 'style': 'form',
+ 'explode': False
+ },
+ {
+ 'name': 'transparent',
+ 'in': 'query',
+ 'description': 'Background transparency of map (default=true).', # noqa
+ 'required': False,
+ 'schema': {
+ 'type': 'boolean',
+ 'default': True,
+ },
+ 'style': 'form',
+ 'explode': False
+ },
+ {'$ref': '#/components/parameters/bbox-crs-epsg'},
+ map_f
+ ],
+ 'responses': {
+ '200': {
+ 'description': 'Response',
+ 'content': {
+ 'application/json': {}
+ }
+ },
+ '400': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/InvalidParameter"}, # noqa
+ '500': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/ServerError"}, # noqa
+ }
+ }
+ }
+ if mp.time_field is not None:
+ paths[pth]['get']['parameters'].append(
+ {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/parameters/datetime"}) # noqa
+
+ return [{'name': 'maps'}], {'paths': paths}
diff --git a/pygeoapi/api/processes.py b/pygeoapi/api/processes.py
new file mode 100644
index 000000000..dcf99d541
--- /dev/null
+++ b/pygeoapi/api/processes.py
@@ -0,0 +1,844 @@
+# =================================================================
+
+# Authors: Tom Kralidis
+# Francesco Bartoli
+# Sander Schaminee
+# John A Stevenson
+# Colin Blackburn
+# Ricardo Garcia Silva
+# Bernhard Mallinger
+# Francesco Martinelli
+#
+# Copyright (c) 2024 Tom Kralidis
+# Copyright (c) 2022 Francesco Bartoli
+# Copyright (c) 2022 John A Stevenson and Colin Blackburn
+# Copyright (c) 2023 Ricardo Garcia Silva
+# Copyright (c) 2024 Bernhard Mallinger
+# Copyright (c) 2024 Francesco Martinelli
+#
+# 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 copy import deepcopy
+from datetime import datetime, timezone
+from http import HTTPStatus
+import json
+import logging
+from typing import Tuple
+import urllib.parse
+
+from pygeoapi import l10n
+from pygeoapi.api import evaluate_limit
+from pygeoapi.util import (
+ json_serial, render_j2_template, JobStatus, RequestedProcessExecutionMode,
+ to_json, DATETIME_FORMAT)
+from pygeoapi.process.base import (
+ JobNotFoundError, JobResultNotFoundError, ProcessorExecuteError
+)
+from pygeoapi.process.manager.base import get_manager, Subscriber
+
+from . import (
+ APIRequest, API, SYSTEM_LOCALE, F_JSON, FORMAT_TYPES, F_HTML, F_JSONLD,
+)
+
+LOGGER = logging.getLogger(__name__)
+
+CONFORMANCE_CLASSES = [
+ 'http://www.opengis.net/spec/ogcapi-processes-1/1.0/conf/ogc-process-description', # noqa
+ 'http://www.opengis.net/spec/ogcapi-processes-1/1.0/conf/core',
+ 'http://www.opengis.net/spec/ogcapi-processes-1/1.0/conf/json',
+ 'http://www.opengis.net/spec/ogcapi-processes-1/1.0/conf/oas30',
+ 'http://www.opengis.net/spec/ogcapi-processes-1/1.0/conf/callback'
+]
+
+
+def describe_processes(api: API, request: APIRequest,
+ process=None) -> Tuple[dict, int, str]:
+ """
+ Provide processes metadata
+
+ :param request: A request object
+ :param process: process identifier, defaults to None to obtain
+ information about all processes
+
+ :returns: tuple of headers, status code, content
+ """
+
+ processes = []
+
+ headers = request.get_response_headers(**api.api_headers)
+
+ if process is not None:
+ if process not in api.manager.processes.keys():
+ msg = 'Identifier not found'
+ return api.get_exception(
+ HTTPStatus.NOT_FOUND, headers,
+ request.format, 'NoSuchProcess', msg)
+
+ if len(api.manager.processes) > 0:
+ if process is not None:
+ relevant_processes = [process]
+ else:
+ LOGGER.debug('Processing limit parameter')
+ if api.config['server'].get('limit') is not None:
+ msg = ('server.limit is no longer supported! '
+ 'Please use limits at the server or collection '
+ 'level (RFC5)')
+ LOGGER.warning(msg)
+ try:
+ limit = evaluate_limit(request.params.get('limit'),
+ api.config['server'].get('limits', {}),
+ {})
+ relevant_processes = list(api.manager.processes)[:limit]
+ except ValueError as err:
+ return api.get_exception(
+ HTTPStatus.BAD_REQUEST, headers, request.format,
+ 'InvalidParameterValue', str(err))
+
+ for key in relevant_processes:
+ p = api.manager.get_processor(key)
+ p2 = l10n.translate_struct(deepcopy(p.metadata),
+ request.locale)
+ p2['id'] = key
+
+ if process is None:
+ p2.pop('inputs')
+ p2.pop('outputs')
+ p2.pop('example', None)
+
+ p2['jobControlOptions'] = ['sync-execute']
+ if api.manager.is_async:
+ p2['jobControlOptions'].append('async-execute')
+
+ p2['outputTransmission'] = ['value']
+ p2['links'] = p2.get('links', [])
+
+ jobs_url = f"{api.base_url}/jobs"
+ process_url = f"{api.base_url}/processes/{key}"
+
+ # TODO translation support
+ link = {
+ 'type': FORMAT_TYPES[F_JSON],
+ 'rel': request.get_linkrel(F_JSON),
+ 'href': f'{process_url}?f={F_JSON}',
+ 'title': l10n.translate('Process description as JSON', request.locale), # noqa
+ 'hreflang': api.default_locale
+ }
+ p2['links'].append(link)
+
+ link = {
+ 'type': FORMAT_TYPES[F_HTML],
+ 'rel': request.get_linkrel(F_HTML),
+ 'href': f'{process_url}?f={F_HTML}',
+ 'title': l10n.translate('Process description as HTML', request.locale), # noqa
+ 'hreflang': api.default_locale
+ }
+ p2['links'].append(link)
+
+ link = {
+ 'type': FORMAT_TYPES[F_HTML],
+ 'rel': 'http://www.opengis.net/def/rel/ogc/1.0/job-list',
+ 'href': f'{jobs_url}?f={F_HTML}',
+ 'title': l10n.translate('Jobs list as HTML', request.locale), # noqa
+ 'hreflang': api.default_locale
+ }
+ p2['links'].append(link)
+
+ link = {
+ 'type': FORMAT_TYPES[F_JSON],
+ 'rel': 'http://www.opengis.net/def/rel/ogc/1.0/job-list',
+ 'href': f'{jobs_url}?f={F_JSON}',
+ 'title': l10n.translate('Jobs list as JSON', request.locale), # noqa
+ 'hreflang': api.default_locale
+ }
+ p2['links'].append(link)
+
+ link = {
+ 'type': FORMAT_TYPES[F_JSON],
+ 'rel': 'http://www.opengis.net/def/rel/ogc/1.0/execute',
+ 'href': f'{process_url}/execution?f={F_JSON}',
+ 'title': l10n.translate('Execution for this process as JSON', request.locale), # noqa
+ 'hreflang': api.default_locale
+ }
+ p2['links'].append(link)
+
+ processes.append(p2)
+
+ if process is not None:
+ response = processes[0]
+ else:
+ process_url = f"{api.base_url}/processes"
+ response = {
+ 'processes': processes,
+ 'links': [{
+ 'type': FORMAT_TYPES[F_JSON],
+ 'rel': request.get_linkrel(F_JSON),
+ 'title': l10n.translate('This document as JSON', request.locale), # noqa
+ 'href': f'{process_url}?f={F_JSON}'
+ }, {
+ 'type': FORMAT_TYPES[F_JSONLD],
+ 'rel': request.get_linkrel(F_JSONLD),
+ 'title': l10n.translate('This document as RDF (JSON-LD)', request.locale), # noqa
+ 'href': f'{process_url}?f={F_JSONLD}'
+ }, {
+ 'type': FORMAT_TYPES[F_HTML],
+ 'rel': request.get_linkrel(F_HTML),
+ 'title': l10n.translate('This document as HTML', request.locale), # noqa
+ 'href': f'{process_url}?f={F_HTML}'
+ }]
+ }
+
+ if request.format == F_HTML: # render
+ if process is not None:
+ tpl_config = api.get_dataset_templates(process)
+ response = render_j2_template(api.tpl_config, tpl_config,
+ 'processes/process.html',
+ response, request.locale)
+ else:
+ response = render_j2_template(
+ api.tpl_config, api.config['server']['templates'],
+ 'processes/index.html', response, request.locale)
+
+ return headers, HTTPStatus.OK, response
+
+ return headers, HTTPStatus.OK, to_json(response, api.pretty_print)
+
+
+# TODO: get_jobs doesn't have tests
+def get_jobs(api: API, request: APIRequest,
+ job_id=None) -> Tuple[dict, int, str]:
+ """
+ Get process jobs
+
+ :param request: A request object
+ :param job_id: id of job
+
+ :returns: tuple of headers, status code, content
+ """
+
+ headers = request.get_response_headers(SYSTEM_LOCALE,
+ **api.api_headers)
+ LOGGER.debug('Processing limit parameter')
+ try:
+ limit = evaluate_limit(request.params.get('limit'),
+ api.config['server'].get('limits', {}),
+ {})
+ except ValueError as err:
+ return api.get_exception(
+ HTTPStatus.BAD_REQUEST, headers, request.format,
+ 'InvalidParameterValue', str(err))
+
+ 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_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['started'],
+ reverse=True)
+ numberMatched = jobs_data['numberMatched']
+
+ else:
+ try:
+ jobs = [api.manager.get_job(job_id)]
+ except JobNotFoundError:
+ return api.get_exception(
+ HTTPStatus.NOT_FOUND, headers, request.format,
+ 'InvalidParameterValue', job_id)
+ numberMatched = 1
+
+ serialized_jobs = {
+ 'jobs': [],
+ 'links': [{
+ 'href': f"{api.base_url}/jobs?f={F_HTML}",
+ 'rel': request.get_linkrel(F_HTML),
+ 'type': FORMAT_TYPES[F_HTML],
+ 'title': l10n.translate('Jobs list as HTML', request.locale)
+ }, {
+ 'href': f"{api.base_url}/jobs?f={F_JSON}",
+ 'rel': request.get_linkrel(F_JSON),
+ 'type': FORMAT_TYPES[F_JSON],
+ 'title': l10n.translate('Jobs list as JSON', request.locale)
+ }]
+ }
+ for job_ in jobs:
+ job2 = {
+ 'type': 'process',
+ 'processID': job_['process_id'],
+ 'jobID': job_['identifier'],
+ 'status': job_['status'],
+ 'message': job_['message'],
+ 'progress': job_['progress'],
+ 'parameters': job_.get('parameters'),
+ 'created': job_['created'],
+ 'started': job_['started'],
+ 'finished': job_['finished'],
+ 'updated': job_['updated']
+ }
+
+ # TODO: translate
+ if JobStatus[job_['status']] in (
+ JobStatus.successful, JobStatus.running, JobStatus.accepted):
+
+ job_result_url = f"{api.base_url}/jobs/{job_['identifier']}/results" # noqa
+
+ job2['links'] = [{
+ 'href': f'{job_result_url}?f={F_HTML}',
+ 'rel': 'http://www.opengis.net/def/rel/ogc/1.0/results',
+ 'type': FORMAT_TYPES[F_HTML],
+ 'title': l10n.translate(f'Results of job as HTML', request.locale), # noqa
+ }, {
+ 'href': f'{job_result_url}?f={F_JSON}',
+ 'rel': 'http://www.opengis.net/def/rel/ogc/1.0/results',
+ 'type': FORMAT_TYPES[F_JSON],
+ 'title': l10n.translate(f'Results of job as JSON', request.locale), # noqa
+ }]
+
+ if job_['mimetype'] not in (FORMAT_TYPES[F_JSON],
+ FORMAT_TYPES[F_HTML]):
+
+ job2['links'].append({
+ 'href': job_result_url,
+ 'rel': 'http://www.opengis.net/def/rel/ogc/1.0/results', # noqa
+ 'type': job_['mimetype'],
+ 'title': f"Results of job {job_id} as {job_['mimetype']}" # noqa
+ })
+
+ 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:
+ serialized_jobs = serialized_jobs['jobs'][0]
+ j2_template = 'jobs/job.html'
+
+ 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, api.config['server']['templates'], j2_template,
+ data, request.locale)
+
+ return headers, HTTPStatus.OK, response
+
+ return headers, HTTPStatus.OK, to_json(serialized_jobs,
+ api.pretty_print)
+
+
+def execute_process(api: API, request: APIRequest,
+ process_id) -> Tuple[dict, int, str]:
+ """
+ Execute process
+
+ :param request: A request object
+ :param process_id: id of process
+
+ :returns: tuple of headers, status code, content
+ """
+
+ # Responses are always in US English only
+ headers = request.get_response_headers(SYSTEM_LOCALE,
+ **api.api_headers)
+ if process_id not in api.manager.processes:
+ msg = 'identifier not found'
+ return api.get_exception(
+ HTTPStatus.NOT_FOUND, headers,
+ request.format, 'NoSuchProcess', msg)
+
+ data = request.data
+ if not data:
+ # TODO not all processes require input, e.g. time-dependent or
+ # random value generators
+ msg = 'missing request data'
+ return api.get_exception(
+ HTTPStatus.BAD_REQUEST, headers, request.format,
+ 'MissingParameterValue', msg)
+
+ try:
+ # Parse bytes data, if applicable
+ data = data.decode()
+ LOGGER.debug(data)
+ except (UnicodeDecodeError, AttributeError):
+ pass
+
+ try:
+ data = json.loads(data)
+ except (json.decoder.JSONDecodeError, TypeError):
+ # Input does not appear to be valid JSON
+ msg = 'invalid request data'
+ return api.get_exception(
+ HTTPStatus.BAD_REQUEST, headers, request.format,
+ 'InvalidParameterValue', msg)
+
+ data_dict = data.get('inputs', {})
+ LOGGER.debug(data_dict)
+
+ 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:
+ try:
+ success_uri = subscriber_dict['successUri']
+ except KeyError:
+ return api.get_exception(
+ HTTPStatus.BAD_REQUEST, headers, request.format,
+ 'MissingParameterValue', 'Missing successUri')
+ else:
+ subscriber = Subscriber(
+ # NOTE: successUri is mandatory according to the standard
+ success_uri=success_uri,
+ in_progress_uri=subscriber_dict.get('inProgressUri'),
+ failed_uri=subscriber_dict.get('failedUri'),
+ )
+
+ try:
+ execution_mode = RequestedProcessExecutionMode(
+ request.headers.get('Prefer', request.headers.get('prefer'))
+ )
+ except ValueError:
+ execution_mode = None
+ try:
+ LOGGER.debug('Executing process')
+ result = api.manager.execute_process(
+ process_id, data_dict, execution_mode=execution_mode,
+ requested_outputs=requested_outputs,
+ subscriber=subscriber,
+ requested_response=requested_response)
+ job_id, mime_type, outputs, status, additional_headers = result
+ headers.update(additional_headers or {})
+
+ 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,
+ request.format, err.ogc_exception_code, err.message)
+
+ response = {}
+ if status == JobStatus.failed:
+ response = outputs
+
+ if requested_response == 'raw':
+ headers['Content-Type'] = mime_type
+ response = outputs
+ elif status not in (JobStatus.failed, JobStatus.accepted):
+ response = outputs
+
+ if status == JobStatus.accepted:
+ http_status = HTTPStatus.CREATED
+ elif status == JobStatus.failed:
+ http_status = HTTPStatus.BAD_REQUEST
+ else:
+ http_status = HTTPStatus.OK
+
+ if mime_type == 'application/json' or requested_response == 'document':
+ response2 = to_json(response, api.pretty_print)
+ else:
+ response2 = response
+
+ return headers, http_status, response2
+
+
+def get_job_result(api: API, request: APIRequest,
+ job_id) -> Tuple[dict, int, str]:
+ """
+ Get result of job (instance of a process)
+
+ :param request: A request object
+ :param job_id: ID of job
+
+ :returns: tuple of headers, status code, content
+ """
+
+ headers = request.get_response_headers(SYSTEM_LOCALE,
+ **api.api_headers)
+ try:
+ job = api.manager.get_job(job_id)
+ except JobNotFoundError:
+ return api.get_exception(
+ HTTPStatus.NOT_FOUND, headers,
+ request.format, 'NoSuchJob', job_id
+ )
+
+ status = JobStatus[job['status']]
+
+ if status == JobStatus.running:
+ msg = 'job still running'
+ return api.get_exception(
+ HTTPStatus.NOT_FOUND, headers,
+ request.format, 'ResultNotReady', msg)
+
+ elif status == JobStatus.accepted:
+ # NOTE: this case is not mentioned in the specification
+ msg = 'job accepted but not yet running'
+ return api.get_exception(
+ HTTPStatus.NOT_FOUND, headers,
+ request.format, 'ResultNotReady', msg)
+
+ elif status == JobStatus.failed:
+ msg = 'job failed'
+ return api.get_exception(
+ HTTPStatus.BAD_REQUEST, headers, request.format,
+ 'InvalidParameterValue', msg)
+
+ try:
+ mimetype, job_output = api.manager.get_job_result(job_id)
+ except JobResultNotFoundError:
+ return api.get_exception(
+ HTTPStatus.INTERNAL_SERVER_ERROR, headers,
+ request.format, 'JobResultNotFound', job_id
+ )
+
+ if mimetype not in (None, FORMAT_TYPES[F_JSON]):
+ headers['Content-Type'] = mimetype
+ content = job_output
+ else:
+ if request.format == F_JSON:
+ content = json.dumps(job_output, sort_keys=True, indent=4,
+ default=json_serial)
+ else:
+ # HTML
+ headers['Content-Type'] = "text/html"
+ data = {
+ 'job': {'id': job_id},
+ 'result': job_output
+ }
+ content = render_j2_template(
+ api.config, api.config['server']['templates'],
+ 'jobs/results/index.html', data, request.locale)
+
+ return headers, HTTPStatus.OK, content
+
+
+def delete_job(api: API, request: APIRequest, job_id) -> Tuple[dict, int, str]:
+ """
+ Delete a process job
+
+ :param job_id: job identifier
+
+ :returns: tuple of headers, status code, content
+ """
+
+ response_headers = request.get_response_headers(
+ SYSTEM_LOCALE, **api.api_headers)
+ try:
+ success = api.manager.delete_job(job_id)
+ except JobNotFoundError:
+ return api.get_exception(
+ HTTPStatus.NOT_FOUND, response_headers, request.format,
+ 'NoSuchJob', job_id
+ )
+ else:
+ if success:
+ http_status = HTTPStatus.OK
+ jobs_url = f"{api.base_url}/jobs"
+
+ response = {
+ 'jobID': job_id,
+ 'status': JobStatus.dismissed.value,
+ 'message': 'Job dismissed',
+ 'progress': 100,
+ 'links': [{
+ 'href': jobs_url,
+ 'rel': 'up',
+ 'type': FORMAT_TYPES[F_JSON],
+ 'title': l10n.translate('The job list for the current process', request.locale) # noqa
+ }]
+ }
+ else:
+ return api.get_exception(
+ HTTPStatus.INTERNAL_SERVER_ERROR, response_headers,
+ request.format, 'InternalError', job_id
+ )
+ LOGGER.info(response)
+ # TODO: this response does not have any headers
+ return {}, http_status, to_json(response, api.pretty_print)
+
+
+def get_oas_30(cfg: dict, locale: str) -> tuple[list[dict[str, str]], dict[str, dict]]: # noqa
+ """
+ Get OpenAPI fragments
+
+ :param cfg: `dict` of configuration
+ :param locale: `str` of locale
+
+ :returns: `tuple` of `list` of tag objects, and `dict` of path objects
+ """
+
+ from pygeoapi.openapi import OPENAPI_YAML
+
+ LOGGER.debug('setting up processes endpoints')
+
+ oas = {'tags': []}
+
+ paths = {}
+
+ process_manager = get_manager(cfg)
+
+ if len(process_manager.processes) > 0:
+ paths['/processes'] = {
+ 'get': {
+ 'summary': 'Processes',
+ 'description': 'Processes',
+ 'tags': ['server'],
+ 'operationId': 'getProcesses',
+ 'parameters': [
+ {'$ref': '#/components/parameters/f'}
+ ],
+ 'responses': {
+ '200': {'$ref': f"{OPENAPI_YAML['oapip']}/responses/ProcessList.yaml"}, # noqa
+ 'default': {'$ref': '#/components/responses/default'}
+ }
+ }
+ }
+
+ LOGGER.debug('setting up processes')
+
+ for k, v in process_manager.processes.items():
+ if k.startswith('_'):
+ LOGGER.debug(f'Skipping hidden layer: {k}')
+ continue
+ name = l10n.translate(k, locale)
+ p = process_manager.get_processor(k)
+ md_desc = l10n.translate(p.metadata['description'], locale)
+ process_name_path = f'/processes/{name}'
+ tag = {
+ 'name': name,
+ 'description': md_desc,
+ 'externalDocs': {}
+ }
+ for link in p.metadata.get('links', []):
+ if link['type'] == 'information':
+ translated_link = l10n.translate(link, locale)
+ tag['externalDocs']['description'] = translated_link[
+ 'type']
+ tag['externalDocs']['url'] = translated_link['url']
+ break
+ if len(tag['externalDocs']) == 0:
+ del tag['externalDocs']
+
+ oas['tags'].append(tag)
+
+ paths[process_name_path] = {
+ 'get': {
+ 'summary': 'Get process metadata',
+ 'description': md_desc,
+ 'tags': [name],
+ 'operationId': f'describe{name.capitalize()}Process',
+ 'parameters': [
+ {'$ref': '#/components/parameters/f'}
+ ],
+ 'responses': {
+ '200': {'$ref': '#/components/responses/200'},
+ 'default': {'$ref': '#/components/responses/default'}
+ }
+ }
+ }
+
+ paths[f'{process_name_path}/execution'] = {
+ 'post': {
+ 'summary': f"Process {l10n.translate(p.metadata['title'], locale)} execution", # noqa
+ 'description': md_desc,
+ 'tags': [name],
+ 'operationId': f'execute{name.capitalize()}Job',
+ 'responses': {
+ '200': {'$ref': '#/components/responses/200'},
+ '201': {'$ref': f"{OPENAPI_YAML['oapip']}/responses/ExecuteAsync.yaml"}, # noqa
+ '404': {'$ref': f"{OPENAPI_YAML['oapip']}/responses/NotFound.yaml"}, # noqa
+ '500': {'$ref': f"{OPENAPI_YAML['oapip']}/responses/ServerError.yaml"}, # noqa
+ 'default': {'$ref': '#/components/responses/default'}
+ },
+ 'requestBody': {
+ 'description': 'Mandatory execute request JSON',
+ 'required': True,
+ 'content': {
+ 'application/json': {
+ 'schema': {
+ '$ref': f"{OPENAPI_YAML['oapip']}/schemas/execute.yaml" # noqa
+ }
+ }
+ }
+ }
+ }
+ }
+
+ try:
+ first_key = list(p.metadata['outputs'])[0]
+ p_output = p.metadata['outputs'][first_key]
+
+ if p_output.get('schema') is not None:
+ LOGGER.debug('Adding output schema')
+ content_media_type = p_output['schema'].pop('contentMediaType', 'application/json') # noqa
+ paths[f'{process_name_path}/execution']['post']['responses']['200'] = { # noqa
+ 'description': 'Process output schema',
+ 'content': {
+ content_media_type: {
+ 'schema': p_output['schema']
+ }
+ }
+ }
+ except (IndexError, KeyError):
+ LOGGER.debug('No output defined')
+
+ if 'example' in p.metadata:
+ paths[f'{process_name_path}/execution']['post']['requestBody']['content']['application/json']['example'] = p.metadata['example'] # noqa
+
+ name_in_path = {
+ 'name': 'jobId',
+ 'in': 'path',
+ 'description': 'job identifier',
+ 'required': True,
+ 'schema': {
+ 'type': 'string'
+ }
+ }
+
+ paths['/jobs'] = {
+ 'get': {
+ 'summary': 'Retrieve jobs list',
+ 'description': 'Retrieve a list of jobs',
+ 'tags': ['jobs'],
+ 'operationId': 'getJobs',
+ 'responses': {
+ '200': {'$ref': '#/components/responses/200'},
+ '404': {'$ref': f"{OPENAPI_YAML['oapip']}/responses/NotFound.yaml"}, # noqa
+ 'default': {'$ref': '#/components/responses/default'}
+ }
+ }
+ }
+
+ paths['/jobs/{jobId}'] = {
+ 'get': {
+ 'summary': 'Retrieve job details',
+ 'description': 'Retrieve job details',
+ 'tags': ['jobs'],
+ 'parameters': [
+ name_in_path,
+ {'$ref': '#/components/parameters/f'}
+ ],
+ 'operationId': 'getJob',
+ 'responses': {
+ '200': {'$ref': '#/components/responses/200'},
+ '404': {'$ref': f"{OPENAPI_YAML['oapip']}/responses/NotFound.yaml"}, # noqa
+ 'default': {'$ref': '#/components/responses/default'}
+ }
+ },
+ 'delete': {
+ 'summary': 'Cancel / delete job',
+ 'description': 'Cancel / delete job',
+ 'tags': ['jobs'],
+ 'parameters': [
+ name_in_path
+ ],
+ 'operationId': 'deleteJob',
+ 'responses': {
+ '204': {'$ref': '#/components/responses/204'},
+ '404': {'$ref': f"{OPENAPI_YAML['oapip']}/responses/NotFound.yaml"}, # noqa
+ 'default': {'$ref': '#/components/responses/default'}
+ }
+ },
+ }
+
+ paths['/jobs/{jobId}/results'] = {
+ 'get': {
+ 'summary': 'Retrieve job results',
+ 'description': 'Retrieve job results',
+ 'tags': ['jobs'],
+ 'parameters': [
+ name_in_path,
+ {'$ref': '#/components/parameters/f'}
+ ],
+ 'operationId': 'getJobResults',
+ 'responses': {
+ '200': {'$ref': '#/components/responses/200'},
+ '404': {'$ref': f"{OPENAPI_YAML['oapip']}/responses/NotFound.yaml"}, # noqa
+ 'default': {'$ref': '#/components/responses/default'}
+ }
+ }
+ }
+
+ return [{'name': 'processes'}, {'name': 'jobs'}], {'paths': paths}
diff --git a/pygeoapi/api/stac.py b/pygeoapi/api/stac.py
new file mode 100644
index 000000000..716862fc8
--- /dev/null
+++ b/pygeoapi/api/stac.py
@@ -0,0 +1,248 @@
+# =================================================================
+
+# Authors: Tom Kralidis
+# Francesco Bartoli
+# Sander Schaminee
+# John A Stevenson
+# Colin Blackburn
+# Ricardo Garcia Silva
+# Bernhard Mallinger
+#
+# Copyright (c) 2024 Tom Kralidis
+# Copyright (c) 2022 Francesco Bartoli
+# Copyright (c) 2022 John A Stevenson and Colin Blackburn
+# Copyright (c) 2023 Ricardo Garcia Silva
+# Copyright (c) 2024 Bernhard Mallinger
+#
+# 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 http import HTTPStatus
+import logging
+from typing import Tuple
+
+from pygeoapi import l10n
+from pygeoapi.plugin import load_plugin
+
+from pygeoapi.provider.base import (
+ ProviderConnectionError, ProviderNotFoundError
+)
+from pygeoapi.util import (
+ get_provider_by_type, to_json, filter_dict_by_key_value,
+ render_j2_template
+)
+
+from . import APIRequest, API, FORMAT_TYPES, F_JSON, F_HTML
+
+
+LOGGER = logging.getLogger(__name__)
+
+
+CONFORMANCE_CLASSES = []
+
+
+# TODO: no tests for this?
+def get_stac_root(api: API, request: APIRequest) -> Tuple[dict, int, str]:
+ """
+ Provide STAC root page
+
+ :param request: APIRequest instance with query params
+
+ :returns: tuple of headers, status code, content
+ """
+ headers = request.get_response_headers(**api.api_headers)
+
+ id_ = 'pygeoapi-stac'
+ stac_version = '1.0.0-rc.2'
+ stac_url = f'{api.base_url}/stac'
+
+ content = {
+ 'id': id_,
+ 'type': 'Catalog',
+ 'stac_version': stac_version,
+ 'title': l10n.translate(
+ api.config['metadata']['identification']['title'],
+ request.locale),
+ 'description': l10n.translate(
+ api.config['metadata']['identification']['description'],
+ request.locale),
+ 'links': []
+ }
+
+ stac_collections = filter_dict_by_key_value(api.config['resources'],
+ 'type', 'stac-collection')
+
+ for key, value in stac_collections.items():
+ content['links'].append({
+ 'rel': 'child',
+ 'href': f'{stac_url}/{key}?f={F_JSON}',
+ 'type': FORMAT_TYPES[F_JSON]
+ })
+ content['links'].append({
+ 'rel': 'child',
+ 'href': f'{stac_url}/{key}',
+ 'type': FORMAT_TYPES[F_HTML]
+ })
+
+ if request.format == F_HTML: # render
+ content = render_j2_template(
+ api.tpl_config, api.config['server']['templates'],
+ 'stac/collection.html', content, request.locale)
+
+ return headers, HTTPStatus.OK, content
+
+ return headers, HTTPStatus.OK, to_json(content, api.pretty_print)
+
+
+# TODO: no tests for this?
+def get_stac_path(api: API, request: APIRequest,
+ path) -> Tuple[dict, int, str]:
+ """
+ Provide STAC resource path
+
+ :param request: APIRequest instance with query params
+
+ :returns: tuple of headers, status code, content
+ """
+ headers = request.get_response_headers(**api.api_headers)
+
+ dataset = None
+ LOGGER.debug(f'Path: {path}')
+ dir_tokens = path.split('/')
+ if dir_tokens:
+ dataset = dir_tokens[0]
+
+ stac_collections = filter_dict_by_key_value(api.config['resources'],
+ 'type', 'stac-collection')
+
+ if dataset not in stac_collections:
+ msg = 'Collection not found'
+ 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(
+ stac_collections[dataset]['providers'], 'stac'))
+ except ProviderConnectionError:
+ msg = 'connection error (check logs)'
+ return api.get_exception(
+ HTTPStatus.INTERNAL_SERVER_ERROR, headers,
+ request.format, 'NoApplicableCode', msg)
+
+ id_ = f'{dataset}-stac'
+ stac_version = '1.0.0-rc.2'
+
+ content = {
+ 'id': id_,
+ 'type': 'Catalog',
+ 'stac_version': stac_version,
+ 'description': l10n.translate(
+ stac_collections[dataset]['description'], request.locale),
+ 'links': []
+ }
+ try:
+ stac_data = p.get_data_path(
+ f'{api.base_url}/stac',
+ path,
+ path.replace(dataset, '', 1)
+ )
+ except ProviderNotFoundError:
+ msg = 'resource not found'
+ return api.get_exception(HTTPStatus.NOT_FOUND, headers,
+ request.format, 'NotFound', msg)
+ except Exception:
+ msg = 'data query error'
+ return api.get_exception(
+ HTTPStatus.INTERNAL_SERVER_ERROR, headers,
+ request.format, 'NoApplicableCode', msg)
+
+ if isinstance(stac_data, dict):
+ content.update(stac_data)
+ content['links'].extend(
+ stac_collections[dataset].get('links', []))
+
+ if request.format == F_HTML: # render
+ content['path'] = path
+ if 'assets' in content: # item view
+ if content['type'] == 'Collection':
+ content = render_j2_template(
+ api.tpl_config, api.config['server']['templates'],
+ 'stac/collection_base.html',
+ content, request.locale)
+ elif content['type'] == 'Feature':
+ content = render_j2_template(
+ api.tpl_config, api.config['server']['templattes'],
+ 'stac/item.html', content, request.locale)
+ else:
+ msg = f'Unknown STAC type {content.type}'
+ return api.get_exception(
+ HTTPStatus.INTERNAL_SERVER_ERROR,
+ headers,
+ request.format,
+ 'NoApplicableCode',
+ msg)
+ else:
+ content = render_j2_template(
+ api.tpl_config, api.config['server']['templates'],
+ 'stac/catalog.html', content, request.locale)
+
+ return headers, HTTPStatus.OK, content
+
+ return headers, HTTPStatus.OK, to_json(content, api.pretty_print)
+
+ else: # send back file
+ headers.pop('Content-Type', None)
+ return headers, HTTPStatus.OK, stac_data
+
+
+def get_oas_30(cfg: dict, locale: str) -> tuple[list[dict[str, str]], dict[str, dict]]: # noqa
+ """
+ Get OpenAPI fragments
+
+ :param cfg: `dict` of configuration
+ :param locale: `str` of locale
+
+ :returns: `tuple` of `list` of tag objects, and `dict` of path objects
+ """
+
+ LOGGER.debug('setting up STAC')
+ stac_collections = filter_dict_by_key_value(cfg['resources'],
+ 'type', 'stac-collection')
+ paths = {}
+ if stac_collections:
+ paths['/stac'] = {
+ 'get': {
+ 'summary': 'SpatioTemporal Asset Catalog',
+ 'description': 'SpatioTemporal Asset Catalog',
+ 'tags': ['stac'],
+ 'operationId': 'getStacCatalog',
+ 'parameters': [],
+ 'responses': {
+ '200': {'$ref': '#/components/responses/200'},
+ 'default': {'$ref': '#/components/responses/default'}
+ }
+ }
+ }
+ return [{'name': 'stac'}], {'paths': paths}
diff --git a/pygeoapi/api/tiles.py b/pygeoapi/api/tiles.py
new file mode 100644
index 000000000..3a02db3d4
--- /dev/null
+++ b/pygeoapi/api/tiles.py
@@ -0,0 +1,546 @@
+# =================================================================
+
+# Authors: Tom Kralidis
+# Francesco Bartoli
+# Sander Schaminee
+# John A Stevenson
+# Colin Blackburn
+# Ricardo Garcia Silva
+# Bernhard Mallinger
+#
+# Copyright (c) 2024 Tom Kralidis
+# Copyright (c) 2022 Francesco Bartoli
+# Copyright (c) 2022 John A Stevenson and Colin Blackburn
+# Copyright (c) 2023 Ricardo Garcia Silva
+# Copyright (c) 2024 Bernhard Mallinger
+#
+# 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 logging
+from http import HTTPStatus
+from typing import Tuple
+
+from pygeoapi import l10n
+from pygeoapi.plugin import load_plugin
+from pygeoapi.models.provider.base import (TilesMetadataFormat,
+ TileMatrixSetEnum)
+from pygeoapi.provider.base import (
+ ProviderGenericError, ProviderTypeError
+)
+from pygeoapi.provider.tile import ProviderTileNotFoundError
+
+from pygeoapi.util import (
+ get_provider_by_type, to_json, filter_dict_by_key_value,
+ filter_providers_by_type, render_j2_template
+)
+
+from . import (
+ APIRequest, API, FORMAT_TYPES, F_JSON, F_HTML, SYSTEM_LOCALE, F_JSONLD
+)
+
+LOGGER = logging.getLogger(__name__)
+
+CONFORMANCE_CLASSES = [
+ 'http://www.opengis.net/spec/ogcapi-tiles-1/1.0/conf/core',
+ 'http://www.opengis.net/spec/ogcapi-tiles-1/1.0/conf/mvt',
+ 'http://www.opengis.net/spec/ogcapi-tiles-1/1.0/conf/tileset',
+ 'http://www.opengis.net/spec/ogcapi-tiles-1/1.0/conf/tilesets-list',
+ 'http://www.opengis.net/spec/ogcapi-tiles-1/1.0/conf/oas30',
+ 'http://www.opengis.net/spec/ogcapi-tiles-1/1.0/conf/geodata-tilesets'
+]
+
+
+def get_collection_tiles(api: API, request: APIRequest,
+ dataset=None) -> Tuple[dict, int, str]:
+ """
+ Provide collection tiles
+
+ :param request: A request object
+ :param dataset: name of collection
+
+ :returns: tuple of headers, status code, content
+ """
+
+ headers = request.get_response_headers(SYSTEM_LOCALE,
+ **api.api_headers)
+ if any([dataset is None,
+ dataset not in api.config['resources'].keys()]):
+
+ msg = 'Collection not found'
+ return api.get_exception(
+ HTTPStatus.NOT_FOUND, headers, request.format, 'NotFound', msg)
+
+ LOGGER.debug('Creating collection tiles')
+ LOGGER.debug('Loading provider')
+ try:
+ t = get_provider_by_type(
+ api.config['resources'][dataset]['providers'], 'tile')
+ p = load_plugin('provider', t)
+ except (KeyError, ProviderTypeError):
+ msg = 'Invalid collection tiles'
+ return api.get_exception(
+ HTTPStatus.BAD_REQUEST, headers, request.format,
+ 'InvalidParameterValue', msg)
+ except ProviderGenericError as err:
+ return api.get_exception(
+ err.http_status_code, headers, request.format,
+ err.ogc_exception_code, err.message)
+
+ tiles = {
+ 'links': [],
+ 'tilesets': []
+ }
+
+ tiles['links'].append({
+ 'type': FORMAT_TYPES[F_JSON],
+ 'rel': request.get_linkrel(F_JSON),
+ 'title': l10n.translate('This document as JSON', request.locale),
+ 'href': f'{api.get_collections_url()}/{dataset}/tiles?f={F_JSON}'
+ })
+ tiles['links'].append({
+ 'type': FORMAT_TYPES[F_JSONLD],
+ 'rel': request.get_linkrel(F_JSONLD),
+ 'title': l10n.translate('This document as RDF (JSON-LD)', request.locale), # noqa
+ 'href': f'{api.get_collections_url()}/{dataset}/tiles?f={F_JSONLD}'
+ })
+ tiles['links'].append({
+ 'type': FORMAT_TYPES[F_HTML],
+ 'rel': request.get_linkrel(F_HTML),
+ 'title': l10n.translate('This document as HTML', request.locale),
+ 'href': f'{api.get_collections_url()}/{dataset}/tiles?f={F_HTML}'
+ })
+
+ tile_services = p.get_tiles_service(
+ baseurl=api.base_url,
+ servicepath=f'{api.get_collections_url()}/{dataset}/tiles/{{tileMatrixSetId}}/{{tileMatrix}}/{{tileRow}}/{{tileCol}}?f={p.format_type}' # noqa
+ )
+
+ for service in tile_services['links']:
+ tiles['links'].append(service)
+
+ tiling_schemes = p.get_tiling_schemes()
+
+ datatype = 'vector'
+
+ if t['format']['mimetype'].startswith('image'):
+ datatype = 'map'
+
+ for matrix in tiling_schemes:
+ tile_matrix = {
+ 'title': dataset,
+ 'tileMatrixSetURI': matrix.tileMatrixSetURI,
+ 'crs': matrix.crs,
+ 'dataType': datatype,
+ 'links': []
+ }
+ tile_matrix['links'].append({
+ 'type': FORMAT_TYPES[F_JSON],
+ 'rel': 'http://www.opengis.net/def/rel/ogc/1.0/tiling-scheme',
+ 'title': l10n.translate('TileMatrixSet definition in JSON', request.locale), # noqa
+ 'href': f'{api.base_url}/TileMatrixSets/{matrix.tileMatrixSet}?f={F_JSON}' # noqa
+ })
+ tile_matrix['links'].append({
+ 'type': FORMAT_TYPES[F_JSON],
+ 'rel': request.get_linkrel(F_JSON),
+ 'title': f'{dataset} - {matrix.tileMatrixSet} - {F_JSON}',
+ 'href': f'{api.get_collections_url()}/{dataset}/tiles/{matrix.tileMatrixSet}?f={F_JSON}' # noqa
+ })
+ tile_matrix['links'].append({
+ 'type': FORMAT_TYPES[F_HTML],
+ 'rel': request.get_linkrel(F_HTML),
+ 'title': f'{dataset} - {matrix.tileMatrixSet} - {F_HTML}',
+ 'href': f'{api.get_collections_url()}/{dataset}/tiles/{matrix.tileMatrixSet}?f={F_HTML}' # noqa
+ })
+
+ tiles['tilesets'].append(tile_matrix)
+
+ if request.format == F_HTML: # render
+ tpl_config = api.get_dataset_templates(dataset)
+ tiles['id'] = dataset
+ tiles['title'] = l10n.translate(
+ api.config['resources'][dataset]['title'], SYSTEM_LOCALE)
+ tiles['tilesets'] = [
+ scheme.tileMatrixSet for scheme in p.get_tiling_schemes()]
+ tiles['bounds'] = \
+ api.config['resources'][dataset]['extents']['spatial']['bbox']
+ tiles['minzoom'] = p.options['zoom']['min']
+ tiles['maxzoom'] = p.options['zoom']['max']
+ tiles['collections_path'] = api.get_collections_url()
+ tiles['tile_type'] = p.tile_type
+
+ content = render_j2_template(api.tpl_config, tpl_config,
+ 'collections/tiles/index.html', tiles,
+ request.locale)
+
+ return headers, HTTPStatus.OK, content
+
+ return headers, HTTPStatus.OK, to_json(tiles, api.pretty_print)
+
+
+def get_collection_tiles_data(
+ api: API, request: APIRequest,
+ dataset=None, matrix_id=None,
+ z_idx=None, y_idx=None, x_idx=None) -> Tuple[dict, int, str]:
+ """
+ Get collection items tiles
+
+ :param request: A request object
+ :param dataset: dataset name
+ :param matrix_id: matrix identifier
+ :param z_idx: z index
+ :param y_idx: y index
+ :param x_idx: x index
+
+ :returns: tuple of headers, status code, content
+ """
+
+ format_ = request.format
+ if not format_:
+ return api.get_format_exception(request)
+ headers = request.get_response_headers(SYSTEM_LOCALE,
+ **api.api_headers)
+ LOGGER.debug('Processing tiles')
+
+ collections = filter_dict_by_key_value(api.config['resources'],
+ 'type', 'collection')
+
+ if dataset not in collections.keys():
+ msg = 'Collection not found'
+ return api.get_exception(
+ HTTPStatus.NOT_FOUND, headers, request.format, 'NotFound', msg)
+
+ LOGGER.debug('Loading tile provider')
+ try:
+ t = get_provider_by_type(
+ api.config['resources'][dataset]['providers'], 'tile')
+ p = load_plugin('provider', t)
+
+ format_ = p.format_type
+ headers['Content-Type'] = format_
+
+ LOGGER.debug(f'Fetching tileset id {matrix_id} and tile {z_idx}/{y_idx}/{x_idx}') # noqa
+ content = p.get_tiles(layer=p.get_layer(), tileset=matrix_id,
+ z=z_idx, y=y_idx, x=x_idx, format_=format_)
+ if content is None:
+ msg = 'identifier not found'
+ return api.get_exception(
+ HTTPStatus.NO_CONTENT, headers, format_, 'NocContent', msg)
+ else:
+ return headers, HTTPStatus.OK, content
+
+ # @TODO: figure out if the spec requires to return json errors
+ except KeyError:
+ msg = 'Invalid collection tiles'
+ return api.get_exception(
+ HTTPStatus.BAD_REQUEST, headers, format_,
+ 'InvalidParameterValue', msg)
+ except ProviderTileNotFoundError:
+ msg = 'Tile not found'
+ LOGGER.info(msg)
+ return headers, HTTPStatus.NOT_FOUND, msg
+ except ProviderGenericError as err:
+ return api.get_exception(
+ err.http_status_code, headers, request.format,
+ err.ogc_exception_code, err.message)
+
+
+def get_collection_tiles_metadata(
+ api: API, request: APIRequest,
+ dataset=None, matrix_id=None) -> Tuple[dict, int, str]:
+ """
+ Get collection items tiles
+
+ :param request: A request object
+ :param dataset: dataset name
+ :param matrix_id: matrix identifier
+
+ :returns: tuple of headers, status code, content
+ """
+
+ if not request.is_valid([TilesMetadataFormat.TILEJSON]):
+ return api.get_format_exception(request)
+ headers = request.get_response_headers(**api.api_headers)
+
+ if any([dataset is None,
+ dataset not in api.config['resources'].keys()]):
+
+ msg = 'Collection not found'
+ return api.get_exception(
+ HTTPStatus.NOT_FOUND, headers, request.format, 'NotFound', msg)
+
+ LOGGER.debug('Creating collection tiles')
+ LOGGER.debug('Loading provider')
+ try:
+ t = get_provider_by_type(
+ api.config['resources'][dataset]['providers'], 'tile')
+ p = load_plugin('provider', t)
+ except KeyError:
+ msg = 'Invalid collection tiles'
+ return api.get_exception(
+ HTTPStatus.BAD_REQUEST, headers, request.format,
+ 'InvalidParameterValue', msg)
+ except ProviderGenericError as err:
+ return api.get_exception(
+ err.http_status_code, headers, request.format,
+ err.ogc_exception_code, err.message)
+
+ # Get provider language (if any)
+ prv_locale = l10n.get_plugin_locale(t, request.raw_locale)
+
+ # Set response language to requested provider locale
+ # (if it supports language) and/or otherwise the requested pygeoapi
+ # locale (or fallback default locale)
+ l10n.set_response_language(headers, prv_locale, request.locale)
+
+ tiles_metadata = p.get_metadata(
+ dataset=dataset, server_url=api.base_url,
+ layer=p.get_layer(), tileset=matrix_id,
+ metadata_format=request._format, title=l10n.translate(
+ api.config['resources'][dataset]['title'],
+ request.locale),
+ description=l10n.translate(
+ api.config['resources'][dataset]['description'],
+ request.locale),
+ language=prv_locale)
+
+ if request.format == F_HTML: # render
+ tpl_config = api.get_dataset_templates(dataset)
+ content = render_j2_template(api.tpl_config, tpl_config,
+ 'collections/tiles/metadata.html',
+ tiles_metadata, request.locale)
+
+ else:
+ content = to_json(tiles_metadata, api.pretty_print)
+
+ return headers, HTTPStatus.OK, content
+
+
+def tilematrixsets(api: API,
+ request: APIRequest) -> Tuple[dict, int, str]:
+ """
+ Provide tileMatrixSets definition
+
+ :param request: A request object
+
+ :returns: tuple of headers, status code, content
+ """
+
+ headers = request.get_response_headers(**api.api_headers)
+
+ # Retrieve available TileMatrixSets
+ enums = [e.value for e in TileMatrixSetEnum]
+
+ tms = {"tileMatrixSets": []}
+
+ for e in enums:
+ tms['tileMatrixSets'].append({
+ "title": e.title,
+ "id": e.tileMatrixSet,
+ "uri": e.tileMatrixSetURI,
+ "links": [
+ {
+ "rel": "self",
+ "type": "text/html",
+ "title": f"The HTML representation of the {e.tileMatrixSet} tile matrix set", # noqa
+ "href": f"{api.base_url}/TileMatrixSets/{e.tileMatrixSet}?f=html" # noqa
+ },
+ {
+ "rel": "self",
+ "type": "application/json",
+ "title": f"The JSON representation of the {e.tileMatrixSet} tile matrix set", # noqa
+ "href": f"{api.base_url}/TileMatrixSets/{e.tileMatrixSet}?f=json" # noqa
+ }
+ ]
+ })
+
+ tms['links'] = [{
+ "rel": "alternate",
+ "type": "text/html",
+ "title": l10n.translate('This document as HTML', request.locale),
+ "href": f"{api.base_url}/tileMatrixSets?f=html"
+ }, {
+ "rel": "self",
+ "type": "application/json",
+ "title": l10n.translate('This document as JSON', request.locale),
+ "href": f"{api.base_url}/tileMatrixSets?f=json"
+ }]
+
+ if request.format == F_HTML: # render
+ content = render_j2_template(api.tpl_config, api.tpl_config,
+ 'tilematrixsets/index.html',
+ tms, request.locale)
+ return headers, HTTPStatus.OK, content
+
+ return headers, HTTPStatus.OK, to_json(tms, api.pretty_print)
+
+
+def tilematrixset(api: API,
+ request: APIRequest,
+ tileMatrixSetId) -> Tuple[dict,
+ int, str]:
+ """
+ Provide tile matrix definition
+
+ :param request: A request object
+
+ :returns: tuple of headers, status code, content
+ """
+
+ headers = request.get_response_headers(**api.api_headers)
+
+ # Retrieve relevant TileMatrixSet
+ enums = [e.value for e in TileMatrixSetEnum]
+ enum = None
+
+ try:
+ for e in enums:
+ if tileMatrixSetId == e.tileMatrixSet:
+ enum = e
+ if not enum:
+ raise ValueError('could not find this tilematrixset')
+ except ValueError as err:
+ return api.get_exception(
+ HTTPStatus.BAD_REQUEST, headers, request.format,
+ 'InvalidParameterValue', str(err))
+
+ tms = {
+ "title": enum.tileMatrixSet,
+ "crs": enum.crs,
+ "id": enum.tileMatrixSet,
+ "uri": enum.tileMatrixSetURI,
+ "orderedAxes": enum.orderedAxes,
+ "wellKnownScaleSet": enum.wellKnownScaleSet,
+ "tileMatrices": enum.tileMatrices
+ }
+
+ if request.format == F_HTML: # render
+ content = render_j2_template(api.tpl_config, api.tpl_config,
+ 'tilematrixsets/tilematrixset.html',
+ tms, request.locale)
+ return headers, HTTPStatus.OK, content
+
+ return headers, HTTPStatus.OK, to_json(tms, api.pretty_print)
+
+def get_oas_30(cfg: dict, locale: str) -> tuple[list[dict[str, str]], dict[str, dict]]: # noqa
+ """
+ Get OpenAPI fragments
+
+ :param cfg: `dict` of configuration
+ :param locale: `str` of locale
+
+ :returns: `tuple` of `list` of tag objects, and `dict` of path objects
+ """
+
+ from pygeoapi.openapi import OPENAPI_YAML, get_visible_collections
+
+ paths = {}
+
+ LOGGER.debug('setting up tiles endpoints')
+ collections = filter_dict_by_key_value(cfg['resources'],
+ 'type', 'collection')
+
+ for k, v in get_visible_collections(cfg).items():
+ tile_extension = filter_providers_by_type(
+ collections[k]['providers'], 'tile')
+
+ if tile_extension:
+ tp = load_plugin('provider', tile_extension)
+
+ tiles_path = f'/collections/{k}/tiles'
+ title = l10n.translate(v['title'], locale)
+ description = l10n.translate(v['description'], locale)
+
+ datatype = 'vector'
+
+ if tile_extension['format']['mimetype'].startswith('image'):
+ datatype = 'map'
+
+ paths[tiles_path] = {
+ 'get': {
+ 'summary': f'Fetch a {title} tiles description',
+ 'description': description,
+ 'tags': [k],
+ 'operationId': f'describe{k.capitalize()}.collection.{datatype}.getTileSetsList', # noqa
+ 'parameters': [
+ {'$ref': '#/components/parameters/f'},
+ {'$ref': '#/components/parameters/lang'}
+ ],
+ 'responses': {
+ '200': {'$ref': '#/components/responses/Tiles'},
+ '400': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/InvalidParameter"}, # noqa
+ '404': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/NotFound"}, # noqa
+ '500': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/ServerError"} # noqa
+ }
+ }
+ }
+
+ tiles_data_path = f'{tiles_path}/{{tileMatrixSetId}}/{{tileMatrix}}/{{tileRow}}/{{tileCol}}' # noqa
+
+ paths[tiles_data_path] = {
+ 'get': {
+ 'summary': f'Get a {title} tile',
+ 'description': description,
+ 'tags': [k],
+ 'operationId': f'get{k.capitalize()}.collection.{datatype}.getTile', # noqa
+ 'parameters': [
+ {'$ref': f"{OPENAPI_YAML['oapit']}#/components/parameters/tileMatrixSetId"}, # noqa
+ {'$ref': f"{OPENAPI_YAML['oapit']}#/components/parameters/tileMatrix"}, # noqa
+ {'$ref': f"{OPENAPI_YAML['oapit']}#/components/parameters/tileRow"}, # noqa
+ {'$ref': f"{OPENAPI_YAML['oapit']}#/components/parameters/tileCol"}, # noqa
+ {
+ 'name': 'f',
+ 'in': 'query',
+ 'description': 'The optional f parameter indicates the output format which the server shall provide as part of the response document.', # noqa
+ 'required': False,
+ 'schema': {
+ 'type': 'string',
+ 'enum': [tp.format_type],
+ 'default': tp.format_type
+ },
+ 'style': 'form',
+ 'explode': False
+ }
+ ],
+ 'responses': {
+ '400': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/InvalidParameter"}, # noqa
+ '404': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/NotFound"}, # noqa
+ '500': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/ServerError"} # noqa
+ }
+ }
+ }
+ mimetype = tile_extension['format']['mimetype']
+ paths[tiles_data_path]['get']['responses']['200'] = {
+ 'description': 'successful operation',
+ 'content': {
+ mimetype: {
+ 'schema': {
+ 'type': 'string',
+ 'format': 'binary'
+ }
+ }
+ }
+ }
+
+ return [{'name': 'tiles'}], {'paths': paths}
diff --git a/pygeoapi/config.py b/pygeoapi/config.py
index 07d861525..d1bf5c390 100644
--- a/pygeoapi/config.py
+++ b/pygeoapi/config.py
@@ -4,7 +4,7 @@
# Francesco Bartoli
#
# Copyright (c) 2022 Tom Kralidis
-# Copyright (c) 2023 Francesco Bartoli
+# Copyright (c) 2024 Francesco Bartoli
#
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation
diff --git a/pygeoapi/django_/urls.py b/pygeoapi/django_/urls.py
index faf8bf469..5736b2dea 100644
--- a/pygeoapi/django_/urls.py
+++ b/pygeoapi/django_/urls.py
@@ -8,7 +8,7 @@
# Copyright (c) 2022 Francesco Bartoli
# Copyright (c) 2022 Luca Delucchi
# Copyright (c) 2022 Krishna Lodha
-# Copyright (c) 2022 Tom Kralidis
+# Copyright (c) 2024 Tom Kralidis
#
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation
@@ -96,6 +96,11 @@ def apply_slash_rule(url: str):
views.collections,
name='collection-detail',
),
+ path(
+ apply_slash_rule('collections//schema/'),
+ views.collection_schema,
+ name='collection-schema',
+ ),
path(
apply_slash_rule('collections//queryables/'),
views.collection_queryables,
@@ -116,16 +121,6 @@ def apply_slash_rule(url: str):
views.collection_coverage,
name='collection-coverage',
),
- path(
- apply_slash_rule('collections//coverage/domainset/'), # noqa
- views.collection_coverage_domainset,
- name='collection-coverage-domainset',
- ),
- path(
- apply_slash_rule('collections//coverage/rangetype/'), # noqa
- views.collection_coverage_rangetype,
- name='collection-coverage-rangetype',
- ),
path(
'collections//map',
views.collection_map,
@@ -152,8 +147,8 @@ def apply_slash_rule(url: str):
name='collection-tiles-metadata',
),
path(
- 'collections//tiles/\
- ///',
+ 'collections//tiles/' +
+ '///',
views.collection_item_tiles,
name='collection-item-tiles',
),
@@ -187,6 +182,26 @@ def apply_slash_rule(url: str):
views.get_collection_edr_query,
name='collection-edr-corridor',
),
+ path(
+ 'collections//locations/',
+ views.get_collection_edr_query,
+ name='collection-edr-corridor',
+ ),
+ path(
+ 'collections//locations',
+ views.get_collection_edr_query,
+ name='collection-edr-corridor',
+ ),
+ path(
+ 'collections//instances/',
+ views.get_collection_edr_query,
+ name='collection-edr-instance',
+ ),
+ path(
+ 'collections//instances',
+ views.get_collection_edr_query,
+ name='collection-edr-instances',
+ ),
path(
'collections//instances//position',
views.get_collection_edr_query,
@@ -217,8 +232,20 @@ def apply_slash_rule(url: str):
views.get_collection_edr_query,
name='collection-edr-instance-corridor',
),
+ path(
+ 'collections//instances/locations/', # noqa
+ views.get_collection_edr_query,
+ name='collection-edr-corridor',
+ ),
+ path(
+ 'collections//instances/locations',
+ views.get_collection_edr_query,
+ name='collection-edr-corridor',
+ ),
path(apply_slash_rule('processes/'), views.processes, name='processes'),
path('processes/', views.processes, name='process-detail'),
+ path('processes//execution', views.process_execution,
+ name='process-execution'),
path(apply_slash_rule('jobs/'), views.jobs, name='jobs'),
path('jobs/', views.jobs, name='job'),
path(
diff --git a/pygeoapi/django_/views.py b/pygeoapi/django_/views.py
index 7f6896297..682ef51ce 100644
--- a/pygeoapi/django_/views.py
+++ b/pygeoapi/django_/views.py
@@ -8,7 +8,7 @@
# Copyright (c) 2022 Francesco Bartoli
# Copyright (c) 2022 Luca Delucchi
# Copyright (c) 2022 Krishna Lodha
-# Copyright (c) 2022 Tom Kralidis
+# Copyright (c) 2025 Tom Kralidis
#
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation
@@ -35,11 +35,23 @@
"""Integration module for Django"""
-from typing import Tuple, Dict, Mapping, Optional
+from typing import Optional, Union
+
from django.conf import settings
from django.http import HttpRequest, HttpResponse
-from pygeoapi.api import API
+from pygeoapi.api import API, APIRequest, apply_gzip
+import pygeoapi.api as core_api
+import pygeoapi.api.coverages as coverages_api
+import pygeoapi.api.environmental_data_retrieval as edr_api
+import pygeoapi.api.itemtypes as itemtypes_api
+import pygeoapi.api.maps as maps_api
+import pygeoapi.api.processes as processes_api
+import pygeoapi.api.stac as stac_api
+import pygeoapi.api.tiles as tiles_api
+
+if settings.PYGEOAPI_CONFIG['server'].get('admin'):
+ import pygeoapi.admin as admin_api
def landing_page(request: HttpRequest) -> HttpResponse:
@@ -51,10 +63,7 @@ def landing_page(request: HttpRequest) -> HttpResponse:
:returns: Django HTTP Response
"""
- response_ = _feed_response(request, 'landing_page')
- response = _to_django_response(*response_)
-
- return response
+ return execute_from_django(core_api.landing_page, request)
def openapi(request: HttpRequest) -> HttpResponse:
@@ -66,10 +75,7 @@ def openapi(request: HttpRequest) -> HttpResponse:
:returns: Django HTTP Response
"""
- response_ = _feed_response(request, 'openapi_')
- response = _to_django_response(*response_)
-
- return response
+ return execute_from_django(core_api.openapi_, request)
def conformance(request: HttpRequest) -> HttpResponse:
@@ -80,11 +86,7 @@ def conformance(request: HttpRequest) -> HttpResponse:
:returns: Django HTTP Response
"""
-
- response_ = _feed_response(request, 'conformance')
- response = _to_django_response(*response_)
-
- return response
+ return execute_from_django(core_api.conformance, request)
def tilematrixsets(request: HttpRequest,
@@ -98,15 +100,13 @@ def tilematrixsets(request: HttpRequest,
:returns: Django HTTP Response
"""
- response = None
-
if tilematrixset_id is None:
- response_ = _feed_response(request, 'tilematrixsets')
+ response_ = execute_from_django(tiles_api.tilematrixsets, request)
else:
- response_ = _feed_response(request, 'tilematrixset', tilematrixset_id)
- response = _to_django_response(*response_)
+ response_ = execute_from_django(tiles_api.tilematrixsets, request,
+ tilematrixset_id)
- return response
+ return response_
def collections(request: HttpRequest,
@@ -120,10 +120,23 @@ def collections(request: HttpRequest,
:returns: Django HTTP Response
"""
- response_ = _feed_response(request, 'describe_collections', collection_id)
- response = _to_django_response(*response_)
+ return execute_from_django(core_api.describe_collections, request,
+ collection_id)
- return response
+
+def collection_schema(request: HttpRequest,
+ collection_id: Optional[str] = None) -> HttpResponse:
+ """
+ OGC API collections schema endpoint
+
+ :request Django HTTP Request
+ :param collection_id: collection identifier
+
+ :returns: Django HTTP Response
+ """
+
+ return execute_from_django(core_api.get_collection_schema, request,
+ collection_id)
def collection_queryables(request: HttpRequest,
@@ -137,12 +150,9 @@ def collection_queryables(request: HttpRequest,
:returns: Django HTTP Response
"""
- response_ = _feed_response(
- request, 'get_collection_queryables', collection_id
+ return execute_from_django(
+ itemtypes_api.get_collection_queryables, request, collection_id
)
- response = _to_django_response(*response_)
-
- return response
def collection_items(request: HttpRequest, collection_id: str) -> HttpResponse:
@@ -156,26 +166,28 @@ def collection_items(request: HttpRequest, collection_id: str) -> HttpResponse:
"""
if request.method == 'GET':
- response_ = _feed_response(
+ response_ = execute_from_django(
+ itemtypes_api.get_collection_items,
request,
- 'get_collection_items',
collection_id,
+ skip_valid_check=True,
)
elif request.method == 'POST':
if request.content_type is not None:
if request.content_type == 'application/geo+json':
- response_ = _feed_response(request, 'manage_collection_item',
- request, 'create', collection_id)
+ response_ = execute_from_django(
+ itemtypes_api.manage_collection_item, request,
+ 'create', collection_id, skip_valid_check=True)
else:
- response_ = _feed_response(request, 'post_collection_items',
- request, collection_id)
+ response_ = execute_from_django(
+ itemtypes_api.get_collection_items,
+ request, collection_id, skip_valid_check=True,)
elif request.method == 'OPTIONS':
- response_ = _feed_response(request, 'manage_collection_item',
- request, 'options', collection_id)
+ response_ = execute_from_django(itemtypes_api.manage_collection_item,
+ request, 'options', collection_id,
+ skip_valid_check=True)
- response = _to_django_response(*response_)
-
- return response
+ return response_
def collection_map(request: HttpRequest, collection_id: str):
@@ -187,11 +199,9 @@ def collection_map(request: HttpRequest, collection_id: str):
:returns: HTTP response
"""
- response_ = _feed_response(request, 'get_collection_map', collection_id)
-
- response = _to_django_response(*response_)
-
- return response
+ return execute_from_django(
+ maps_api.get_collection_map, request, collection_id
+ )
def collection_style_map(request: HttpRequest, collection_id: str,
@@ -205,13 +215,9 @@ def collection_style_map(request: HttpRequest, collection_id: str,
:returns: HTTP response
"""
- response_ = _feed_response(request, 'get_collection_map',
+ return execute_from_django(maps_api.get_collection_map, request,
collection_id, style_id)
- response = _to_django_response(*response_)
-
- return response
-
def collection_item(request: HttpRequest,
collection_id: str, item_id: str) -> HttpResponse:
@@ -226,27 +232,22 @@ def collection_item(request: HttpRequest,
"""
if request.method == 'GET':
- response_ = _feed_response(
- request, 'get_collection_item', collection_id, item_id
- )
+ response_ = execute_from_django(itemtypes_api.get_collection_item,
+ request, collection_id, item_id)
elif request.method == 'PUT':
- response_ = _feed_response(
- request, 'manage_collection_item', request, 'update',
- collection_id, item_id
- )
+ response_ = execute_from_django(itemtypes_api.manage_collection_item,
+ request, 'update', collection_id,
+ item_id, skip_valid_check=True)
elif request.method == 'DELETE':
- response_ = _feed_response(
- request, 'manage_collection_item', request, 'delete',
- collection_id, item_id
- )
+ response_ = execute_from_django(itemtypes_api.manage_collection_item,
+ request, 'delete', collection_id,
+ item_id, skip_valid_check=True)
elif request.method == 'OPTIONS':
- response_ = _feed_response(
- request, 'manage_collection_item', request, 'options',
- collection_id, item_id)
+ response_ = execute_from_django(itemtypes_api.manage_collection_item,
+ request, 'options', collection_id,
+ item_id, skip_valid_check=True)
- response = _to_django_response(*response_)
-
- return response
+ return response_
def collection_coverage(request: HttpRequest,
@@ -260,50 +261,10 @@ def collection_coverage(request: HttpRequest,
:returns: Django HTTP response
"""
- response_ = _feed_response(
- request, 'get_collection_coverage', collection_id
+ return execute_from_django(
+ coverages_api.get_collection_coverage, request, collection_id,
+ skip_valid_check=True
)
- response = _to_django_response(*response_)
-
- return response
-
-
-def collection_coverage_domainset(request: HttpRequest,
- collection_id: str) -> HttpResponse:
- """
- OGC API - Coverages coverage domainset endpoint
-
- :request Django HTTP Request
- :param collection_id: collection identifier
-
- :returns: Django HTTP response
- """
-
- response_ = _feed_response(
- request, 'get_collection_coverage_domainset', collection_id
- )
- response = _to_django_response(*response_)
-
- return response
-
-
-def collection_coverage_rangetype(request: HttpRequest,
- collection_id: str) -> HttpResponse:
- """
- OGC API - Coverages coverage rangetype endpoint
-
- :request Django HTTP Request
- :param collection_id: collection identifier
-
- :returns: Django HTTP response
- """
-
- response_ = _feed_response(
- request, 'get_collection_coverage_rangetype', collection_id
- )
- response = _to_django_response(*response_)
-
- return response
def collection_tiles(request: HttpRequest, collection_id: str) -> HttpResponse:
@@ -316,10 +277,8 @@ def collection_tiles(request: HttpRequest, collection_id: str) -> HttpResponse:
:returns: Django HTTP response
"""
- response_ = _feed_response(request, 'get_collection_tiles', collection_id)
- response = _to_django_response(*response_)
-
- return response
+ return execute_from_django(tiles_api.get_collection_tiles, request,
+ collection_id)
def collection_tiles_metadata(request: HttpRequest, collection_id: str,
@@ -334,20 +293,16 @@ def collection_tiles_metadata(request: HttpRequest, collection_id: str,
:returns: Django HTTP response
"""
- response_ = _feed_response(
- request,
- 'get_collection_tiles_metadata',
- collection_id,
- tileMatrixSetId,
+ return execute_from_django(
+ tiles_api.get_collection_tiles_metadata,
+ request, collection_id, tileMatrixSetId,
+ skip_valid_check=True
)
- response = _to_django_response(*response_)
-
- return response
def collection_item_tiles(request: HttpRequest, collection_id: str,
tileMatrixSetId: str, tileMatrix: str,
- tileRow: str, tileCol: str,) -> HttpResponse:
+ tileRow: str, tileCol: str) -> HttpResponse:
"""
OGC API - Tiles collection tiles data endpoint
@@ -361,18 +316,16 @@ def collection_item_tiles(request: HttpRequest, collection_id: str,
:returns: Django HTTP response
"""
- response_ = _feed_response(
+ return execute_from_django(
+ tiles_api.get_collection_tiles_data,
request,
- 'get_collection_tiles_metadata',
collection_id,
tileMatrixSetId,
tileMatrix,
tileRow,
tileCol,
+ skip_valid_check=True
)
- response = _to_django_response(*response_)
-
- return response
def processes(request: HttpRequest,
@@ -386,10 +339,22 @@ def processes(request: HttpRequest,
:returns: Django HTTP response
"""
- response_ = _feed_response(request, 'describe_processes', process_id)
- response = _to_django_response(*response_)
+ return execute_from_django(processes_api.describe_processes, request,
+ process_id)
- return response
+
+def process_execution(request: HttpRequest, process_id: str) -> HttpResponse:
+ """
+ OGC API - Processes execution endpoint
+
+ :request Django HTTP Request
+ :param process_id: process identifier
+
+ :returns: Django HTTP response
+ """
+
+ return execute_from_django(processes_api.execute_process, request,
+ process_id)
def jobs(request: HttpRequest, job_id: Optional[str] = None) -> HttpResponse:
@@ -403,10 +368,17 @@ def jobs(request: HttpRequest, job_id: Optional[str] = None) -> HttpResponse:
:returns: Django HTTP response
"""
- response_ = _feed_response(request, 'get_jobs', job_id)
- response = _to_django_response(*response_)
+ if job_id is None:
+ response_ = execute_from_django(processes_api.get_jobs, request)
+ else:
+ if request.method == 'DELETE': # dismiss job
+ response_ = execute_from_django(processes_api.delete_job, request,
+ job_id)
+ else: # Return status of a specific job
+ response_ = execute_from_django(processes_api.get_jobs, request,
+ job_id)
- return response
+ return response_
def job_results(request: HttpRequest,
@@ -420,10 +392,7 @@ def job_results(request: HttpRequest,
:returns: Django HTTP response
"""
- response_ = _feed_response(request, 'get_job_result', job_id)
- response = _to_django_response(*response_)
-
- return response
+ return execute_from_django(processes_api.get_job_result, request, job_id)
def job_results_resource(request: HttpRequest, process_id: str, job_id: str,
@@ -438,40 +407,49 @@ def job_results_resource(request: HttpRequest, process_id: str, job_id: str,
:returns: Django HTTP response
"""
- response_ = _feed_response(
- request,
- 'get_job_result_resource',
- job_id,
- resource
- )
- response = _to_django_response(*response_)
-
- return response
+ # TODO: this api method does not exist
+ return execute_from_django(processes_api.get_job_result_resource,
+ request, job_id, resource)
-def get_collection_edr_query(request: HttpRequest, collection_id: str,
- instance_id: str) -> HttpResponse:
+def get_collection_edr_query(
+ request: HttpRequest, collection_id: str,
+ instance_id: Optional[str] = None,
+ location_id: Optional[str] = None
+) -> HttpResponse:
"""
OGC API - EDR endpoint
- :request Django HTTP Request
- :param job_id: job identifier
- :param resource: job resource
+ :param request: Django HTTP Request
+ :param collection_id: collection identifier
+ :param instance_id: optional instance identifier. default is None
+ :param location_id: optional location identifier. default is None
:returns: Django HTTP response
"""
- query_type = request.path.split('/')[-1]
- response_ = _feed_response(
+ if (request.path.endswith('instances') or
+ (instance_id is not None and
+ request.path.endswith(instance_id))):
+ return execute_from_django(
+ edr_api.get_collection_edr_instances, request, collection_id,
+ instance_id
+ )
+
+ if location_id:
+ query_type = 'locations'
+ else:
+ query_type = request.path.split('/')[-1]
+
+ return execute_from_django(
+ edr_api.get_collection_edr_query,
request,
- 'get_collection_edr_query',
collection_id,
instance_id,
- query_type
+ query_type,
+ location_id,
+ skip_valid_check=True
)
- response = _to_django_response(*response_)
-
- return response
def stac_catalog_root(request: HttpRequest) -> HttpResponse:
@@ -483,10 +461,7 @@ def stac_catalog_root(request: HttpRequest) -> HttpResponse:
:returns: Django HTTP response
"""
- response_ = _feed_response(request, 'get_stac_root')
- response = _to_django_response(*response_)
-
- return response
+ return execute_from_django(stac_api.get_stac_root, request)
def stac_catalog_path(request: HttpRequest, path: str) -> HttpResponse:
@@ -499,10 +474,7 @@ def stac_catalog_path(request: HttpRequest, path: str) -> HttpResponse:
:returns: Django HTTP response
"""
- response_ = _feed_response(request, 'get_stac_path', path)
- response = _to_django_response(*response_)
-
- return response
+ return execute_from_django(stac_api.get_stac_path, request, path)
def admin_config(request: HttpRequest) -> HttpResponse:
@@ -513,13 +485,13 @@ def admin_config(request: HttpRequest) -> HttpResponse:
"""
if request.method == 'GET':
- return _feed_response(request, 'get_admin_config')
+ return execute_from_django(admin_api.get_config_, request)
elif request.method == 'PUT':
- return _feed_response(request, 'put_admin_config')
+ return execute_from_django(admin_api.put_config, request)
elif request.method == 'PATCH':
- return _feed_response(request, 'patch_admin_config')
+ return execute_from_django(admin_api.patch_config, request)
def admin_config_resources(request: HttpRequest) -> HttpResponse:
@@ -530,10 +502,10 @@ def admin_config_resources(request: HttpRequest) -> HttpResponse:
"""
if request.method == 'GET':
- return _feed_response(request, 'get_admin_config_resources')
+ return execute_from_django(admin_api.get_resources, request)
elif request.method == 'POST':
- return _feed_response(request, 'put_admin_config_resources')
+ return execute_from_django(admin_api.put_resource, request)
def admin_config_resource(request: HttpRequest,
@@ -545,42 +517,43 @@ def admin_config_resource(request: HttpRequest,
"""
if request.method == 'GET':
- return _feed_response(request, 'put_admin_config_resource',
- resource_id)
+ return execute_from_django(admin_api.get_resource, request,
+ resource_id)
elif request.method == 'DELETE':
- return _feed_response(request, 'delete_admin_config_resource',
- resource_id)
+ return execute_from_django(admin_api.delete_resource, request,
+ resource_id)
elif request.method == 'PUT':
- return _feed_response(request, 'put_admin_config_resource',
- resource_id)
+ return execute_from_django(admin_api.put_resource, request,
+ resource_id)
elif request.method == 'PATCH':
- return _feed_response(request, 'patch_admin_config_resource',
- resource_id)
+ return execute_from_django(admin_api.patch_resource, request,
+ resource_id)
-def _feed_response(request: HttpRequest, api_definition: str,
- *args, **kwargs) -> Tuple[Dict, int, str]:
- """Use pygeoapi api to process the input request"""
+def execute_from_django(api_function, request: HttpRequest, *args,
+ skip_valid_check=False) -> HttpResponse:
- if 'admin' in api_definition and settings.PYGEOAPI_CONFIG['server'].get('admin'): # noqa
+ api_: API | "Admin"
+ if settings.PYGEOAPI_CONFIG['server'].get('admin'): # noqa
from pygeoapi.admin import Admin
api_ = Admin(settings.PYGEOAPI_CONFIG, settings.OPENAPI_DOCUMENT)
else:
api_ = API(settings.PYGEOAPI_CONFIG, settings.OPENAPI_DOCUMENT)
- api = getattr(api_, api_definition)
-
- return api(request, *args, **kwargs)
-
+ api_request = APIRequest.from_django(request, api_.locales)
+ content: Union[str, bytes]
+ if not skip_valid_check and not api_request.is_valid():
+ headers, status, content = api_.get_format_exception(api_request)
+ else:
-def _to_django_response(headers: Mapping, status_code: int,
- content: str) -> HttpResponse:
- """Convert API payload to a django response"""
+ headers, status, content = api_function(api_, api_request, *args)
+ content = apply_gzip(headers, content)
- response = HttpResponse(content, status=status_code)
+ # Convert API payload to a django response
+ response = HttpResponse(content, status=status)
for key, value in headers.items():
response[key] = value
diff --git a/pygeoapi/flask_app.py b/pygeoapi/flask_app.py
index ac5946268..ab661a356 100644
--- a/pygeoapi/flask_app.py
+++ b/pygeoapi/flask_app.py
@@ -3,7 +3,7 @@
# Authors: Tom Kralidis
# Norman Barker
#
-# Copyright (c) 2023 Tom Kralidis
+# Copyright (c) 2024 Tom Kralidis
#
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation
@@ -31,12 +31,21 @@
"""Flask module providing the route paths to the api"""
import os
+from typing import Union
import click
-
-from flask import Flask, Blueprint, make_response, request, send_from_directory
-
-from pygeoapi.api import API
+from flask import (Flask, Blueprint, make_response, request,
+ send_from_directory, Response, Request)
+
+from pygeoapi.api import API, APIRequest, apply_gzip
+import pygeoapi.api as core_api
+import pygeoapi.api.coverages as coverages_api
+import pygeoapi.api.environmental_data_retrieval as edr_api
+import pygeoapi.api.itemtypes as itemtypes_api
+import pygeoapi.api.maps as maps_api
+import pygeoapi.api.processes as processes_api
+import pygeoapi.api.stac as stac_api
+import pygeoapi.api.tiles as tiles_api
from pygeoapi.openapi import load_openapi_document
from pygeoapi.config import get_config
from pygeoapi.util import get_mimetype, get_api_rules
@@ -48,6 +57,7 @@
API_RULES = get_api_rules(CONFIG)
if CONFIG['server'].get('admin'):
+ import pygeoapi.admin as admin_api
from pygeoapi.admin import Admin
STATIC_FOLDER = 'static'
@@ -63,13 +73,18 @@
static_folder=STATIC_FOLDER,
url_prefix=API_RULES.get_url_prefix('flask')
)
-ADMIN_BLUEPRINT = Blueprint('admin', __name__, static_folder=STATIC_FOLDER)
+ADMIN_BLUEPRINT = Blueprint(
+ 'admin',
+ __name__,
+ static_folder=STATIC_FOLDER,
+ url_prefix=API_RULES.get_url_prefix('flask')
+)
# CORS: optionally enable from config.
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')
@@ -101,23 +116,43 @@ def schemas(path):
dirname_ = os.path.dirname(full_filepath)
basename_ = os.path.basename(full_filepath)
- # TODO: better sanitization?
- path_ = dirname_.replace('..', '').replace('//', '')
+ path_ = dirname_.replace('..', '').replace('//', '').replace('./', '')
+
+ if '..' in path_:
+ return 'Invalid path', 400
+
return send_from_directory(path_, basename_,
mimetype=get_mimetype(basename_))
-def get_response(result: tuple):
+def execute_from_flask(api_function, request: Request, *args,
+ skip_valid_check=False,
+ alternative_api=None
+ ) -> Response:
"""
- Creates a Flask Response object and updates matching headers.
+ Executes API function from Flask
- :param result: The result of the API call.
- This should be a tuple of (headers, status, content).
+ :param api_function: API function
+ :param request: request object
+ :param *args: variable length additional arguments
+ :param skip_validity_check: bool
+ :param alternative_api: specify custom api instance such as Admin
- :returns: A Response instance.
+ :returns: A Response instance
"""
- headers, status, content = result
+ actual_api = api_ if alternative_api is None else alternative_api
+
+ api_request = APIRequest.from_flask(request, actual_api.locales)
+
+ content: Union[str, bytes]
+
+ if not skip_valid_check and not api_request.is_valid():
+ headers, status, content = actual_api.get_format_exception(api_request)
+ else:
+ headers, status, content = api_function(actual_api, api_request, *args)
+ content = apply_gzip(headers, content)
+
response = make_response(content, status)
if headers:
@@ -132,7 +167,7 @@ def landing_page():
:returns: HTTP response
"""
- return get_response(api_.landing_page(request))
+ return execute_from_flask(core_api.landing_page, request)
@BLUEPRINT.route('/openapi')
@@ -142,7 +177,8 @@ def openapi():
:returns: HTTP response
"""
- return get_response(api_.openapi_(request))
+
+ return execute_from_flask(core_api.openapi_, request)
@BLUEPRINT.route('/conformance')
@@ -152,7 +188,8 @@ def conformance():
:returns: HTTP response
"""
- return get_response(api_.conformance(request))
+
+ return execute_from_flask(core_api.conformance, request)
@BLUEPRINT.route('/TileMatrixSets/')
@@ -161,9 +198,12 @@ def get_tilematrix_set(tileMatrixSetId=None):
OGC API TileMatrixSet endpoint
:param tileMatrixSetId: identifier of tile matrix set
+
:returns: HTTP response
"""
- return get_response(api_.tilematrixset(request, tileMatrixSetId))
+
+ return execute_from_flask(tiles_api.tilematrixset, request,
+ tileMatrixSetId)
@BLUEPRINT.route('/TileMatrixSets')
@@ -173,7 +213,8 @@ def get_tilematrix_sets():
:returns: HTTP response
"""
- return get_response(api_.tilematrixsets(request))
+
+ return execute_from_flask(tiles_api.tilematrixsets, request)
@BLUEPRINT.route('/collections')
@@ -186,19 +227,37 @@ def collections(collection_id=None):
:returns: HTTP response
"""
- return get_response(api_.describe_collections(request, collection_id))
+
+ return execute_from_flask(core_api.describe_collections, request,
+ collection_id)
+
+
+@BLUEPRINT.route('/collections//schema')
+def collection_schema(collection_id):
+ """
+ OGC API - collections schema endpoint
+
+ :param collection_id: collection identifier
+
+ :returns: HTTP response
+ """
+
+ return execute_from_flask(core_api.get_collection_schema, request,
+ collection_id)
@BLUEPRINT.route('/collections//queryables')
def collection_queryables(collection_id=None):
"""
- OGC API collections querybles endpoint
+ OGC API collections queryables endpoint
:param collection_id: collection identifier
:returns: HTTP response
"""
- return get_response(api_.get_collection_queryables(request, collection_id))
+
+ return execute_from_flask(itemtypes_api.get_collection_queryables, request,
+ collection_id)
@BLUEPRINT.route('/collections//items',
@@ -218,37 +277,41 @@ def collection_items(collection_id, item_id=None):
"""
if item_id is None:
- if request.method == 'GET': # list items
- return get_response(
- api_.get_collection_items(request, collection_id))
- 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 get_response(
- api_.manage_collection_item(request, 'create',
- collection_id))
+ return execute_from_flask(
+ itemtypes_api.manage_collection_item,
+ request, 'create', collection_id,
+ skip_valid_check=True)
else:
- return get_response(
- api_.post_collection_items(request, collection_id))
+ return execute_from_flask(
+ itemtypes_api.get_collection_items, request,
+ collection_id, skip_valid_check=True)
elif request.method == 'OPTIONS':
- return get_response(
- api_.manage_collection_item(request, 'options', collection_id))
+ 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 get_response(
- api_.manage_collection_item(request, 'delete',
- collection_id, item_id))
+ return execute_from_flask(itemtypes_api.manage_collection_item,
+ request, 'delete', collection_id, item_id,
+ skip_valid_check=True)
elif request.method == 'PUT':
- return get_response(
- api_.manage_collection_item(request, 'update',
- collection_id, item_id))
+ return execute_from_flask(itemtypes_api.manage_collection_item,
+ request, 'update', collection_id, item_id,
+ skip_valid_check=True)
elif request.method == 'OPTIONS':
- return get_response(
- api_.manage_collection_item(request, 'options',
- collection_id, item_id))
+ return execute_from_flask(itemtypes_api.manage_collection_item,
+ request, 'options', collection_id, item_id,
+ skip_valid_check=True)
else:
- return get_response(
- api_.get_collection_item(request, collection_id, item_id))
+ return execute_from_flask(itemtypes_api.get_collection_item, request,
+ collection_id, item_id)
@BLUEPRINT.route('/collections//coverage')
@@ -260,33 +323,9 @@ def collection_coverage(collection_id):
:returns: HTTP response
"""
- return get_response(api_.get_collection_coverage(request, collection_id))
-
-
-@BLUEPRINT.route('/collections//coverage/domainset')
-def collection_coverage_domainset(collection_id):
- """
- OGC API - Coverages coverage domainset endpoint
-
- :param collection_id: collection identifier
-
- :returns: HTTP response
- """
- return get_response(api_.get_collection_coverage_domainset(
- request, collection_id))
-
-@BLUEPRINT.route('/collections//coverage/rangetype')
-def collection_coverage_rangetype(collection_id):
- """
- OGC API - Coverages coverage rangetype endpoint
-
- :param collection_id: collection identifier
-
- :returns: HTTP response
- """
- return get_response(api_.get_collection_coverage_rangetype(
- request, collection_id))
+ return execute_from_flask(coverages_api.get_collection_coverage, request,
+ collection_id, skip_valid_check=True)
@BLUEPRINT.route('/collections//tiles')
@@ -298,8 +337,9 @@ def get_collection_tiles(collection_id=None):
:returns: HTTP response
"""
- return get_response(api_.get_collection_tiles(
- request, collection_id))
+
+ return execute_from_flask(tiles_api.get_collection_tiles, request,
+ collection_id)
@BLUEPRINT.route('/collections//tiles/')
@@ -313,8 +353,10 @@ def get_collection_tiles_metadata(collection_id=None, tileMatrixSetId=None):
:returns: HTTP response
"""
- return get_response(api_.get_collection_tiles_metadata(
- request, collection_id, tileMatrixSetId))
+
+ return execute_from_flask(tiles_api.get_collection_tiles_metadata,
+ request, collection_id, tileMatrixSetId,
+ skip_valid_check=True)
@BLUEPRINT.route('/collections//tiles/\
@@ -332,8 +374,12 @@ def get_collection_tiles_data(collection_id=None, tileMatrixSetId=None,
:returns: HTTP response
"""
- return get_response(api_.get_collection_tiles_data(
- request, collection_id, tileMatrixSetId, tileMatrix, tileRow, tileCol))
+
+ return execute_from_flask(
+ tiles_api.get_collection_tiles_data,
+ request, collection_id, tileMatrixSetId, tileMatrix, tileRow, tileCol,
+ skip_valid_check=True,
+ )
@BLUEPRINT.route('/collections//map')
@@ -348,15 +394,9 @@ def collection_map(collection_id, style_id=None):
:returns: HTTP response
"""
- headers, status_code, content = api_.get_collection_map(
- request, collection_id, style_id)
-
- response = make_response(content, status_code)
-
- if headers:
- response.headers = headers
-
- return response
+ return execute_from_flask(
+ maps_api.get_collection_map, request, collection_id, style_id
+ )
@BLUEPRINT.route('/processes')
@@ -369,7 +409,9 @@ def get_processes(process_id=None):
:returns: HTTP response
"""
- return get_response(api_.describe_processes(request, process_id))
+
+ return execute_from_flask(processes_api.describe_processes, request,
+ process_id)
@BLUEPRINT.route('/jobs')
@@ -385,12 +427,13 @@ def get_jobs(job_id=None):
"""
if job_id is None:
- return get_response(api_.get_jobs(request))
+ return execute_from_flask(processes_api.get_jobs, request)
else:
if request.method == 'DELETE': # dismiss job
- return get_response(api_.delete_job(request, job_id))
+ return execute_from_flask(processes_api.delete_job, request,
+ job_id)
else: # Return status of a specific job
- return get_response(api_.get_jobs(request, job_id))
+ return execute_from_flask(processes_api.get_jobs, request, job_id)
@BLUEPRINT.route('/processes//execution', methods=['POST'])
@@ -403,7 +446,8 @@ def execute_process_jobs(process_id):
:returns: HTTP response
"""
- return get_response(api_.execute_process(request, process_id))
+ return execute_from_flask(processes_api.execute_process, request,
+ process_id)
@BLUEPRINT.route('/jobs//results',
@@ -416,22 +460,8 @@ def get_job_result(job_id=None):
:returns: HTTP response
"""
- return get_response(api_.get_job_result(request, job_id))
-
-@BLUEPRINT.route('/jobs//results/',
- methods=['GET'])
-def get_job_result_resource(job_id, resource):
- """
- OGC API - Processes job result resource endpoint
-
- :param job_id: job identifier
- :param resource: job resource
-
- :returns: HTTP response
- """
- return get_response(api_.get_job_result_resource(
- request, job_id, resource))
+ return execute_from_flask(processes_api.get_job_result, request, job_id)
@BLUEPRINT.route('/collections//position')
@@ -440,24 +470,47 @@ def get_job_result_resource(job_id, resource):
@BLUEPRINT.route('/collections//radius')
@BLUEPRINT.route('/collections//trajectory')
@BLUEPRINT.route('/collections//corridor')
+@BLUEPRINT.route('/collections//locations/')
+@BLUEPRINT.route('/collections//locations')
@BLUEPRINT.route('/collections//instances//position') # noqa
@BLUEPRINT.route('/collections//instances//area') # noqa
@BLUEPRINT.route('/collections//instances//cube') # noqa
@BLUEPRINT.route('/collections//instances//radius') # noqa
@BLUEPRINT.route('/collections//instances//trajectory') # noqa
@BLUEPRINT.route('/collections//instances//corridor') # noqa
-def get_collection_edr_query(collection_id, instance_id=None):
+@BLUEPRINT.route('/collections//instances//locations/') # noqa
+@BLUEPRINT.route('/collections//instances//locations') # noqa
+@BLUEPRINT.route('/collections//instances/')
+@BLUEPRINT.route('/collections//instances')
+def get_collection_edr_query(collection_id, instance_id=None,
+ location_id=None):
"""
OGC EDR API endpoints
:param collection_id: collection identifier
:param instance_id: instance identifier
+ :param location_id: location id of a /locations/ query
:returns: HTTP response
"""
- query_type = request.path.split('/')[-1]
- return get_response(api_.get_collection_edr_query(request, collection_id,
- instance_id, query_type))
+
+ if (request.path.endswith('instances') or
+ (instance_id is not None and
+ request.path.endswith(instance_id))):
+ return execute_from_flask(
+ edr_api.get_collection_edr_instances, request, collection_id,
+ instance_id
+ )
+
+ if location_id:
+ query_type = 'locations'
+ else:
+ query_type = request.path.split('/')[-1]
+
+ return execute_from_flask(
+ edr_api.get_collection_edr_query, request, collection_id, instance_id,
+ query_type, location_id, skip_valid_check=True
+ )
@BLUEPRINT.route('/stac')
@@ -467,7 +520,8 @@ def stac_catalog_root():
:returns: HTTP response
"""
- return get_response(api_.get_stac_root(request))
+
+ return execute_from_flask(stac_api.get_stac_root, request)
@BLUEPRINT.route('/stac/')
@@ -479,7 +533,8 @@ def stac_catalog_path(path):
:returns: HTTP response
"""
- return get_response(api_.get_stac_path(request, path))
+
+ return execute_from_flask(stac_api.get_stac_path, request, path)
@ADMIN_BLUEPRINT.route('/admin/config', methods=['GET', 'PUT', 'PATCH'])
@@ -491,13 +546,16 @@ def admin_config():
"""
if request.method == 'GET':
- return get_response(admin_.get_config(request))
+ return execute_from_flask(admin_api.get_config_, request,
+ alternative_api=admin_)
elif request.method == 'PUT':
- return get_response(admin_.put_config(request))
+ return execute_from_flask(admin_api.put_config, request,
+ alternative_api=admin_)
elif request.method == 'PATCH':
- return get_response(admin_.patch_config(request))
+ return execute_from_flask(admin_api.patch_config, request,
+ alternative_api=admin_)
@ADMIN_BLUEPRINT.route('/admin/config/resources', methods=['GET', 'POST'])
@@ -509,10 +567,12 @@ def admin_config_resources():
"""
if request.method == 'GET':
- return get_response(admin_.get_resources(request))
+ return execute_from_flask(admin_api.get_resources, request,
+ alternative_api=admin_)
elif request.method == 'POST':
- return get_response(admin_.post_resource(request))
+ return execute_from_flask(admin_api.post_resource, request,
+ alternative_api=admin_)
@ADMIN_BLUEPRINT.route(
@@ -526,16 +586,24 @@ def admin_config_resource(resource_id):
"""
if request.method == 'GET':
- return get_response(admin_.get_resource(request, resource_id))
+ return execute_from_flask(admin_api.get_resource, request,
+ resource_id,
+ alternative_api=admin_)
elif request.method == 'DELETE':
- return get_response(admin_.delete_resource(request, resource_id))
+ return execute_from_flask(admin_api.delete_resource, request,
+ resource_id,
+ alternative_api=admin_)
elif request.method == 'PUT':
- return get_response(admin_.put_resource(request, resource_id))
+ return execute_from_flask(admin_api.put_resource, request,
+ resource_id,
+ alternative_api=admin_)
elif request.method == 'PATCH':
- return get_response(admin_.patch_resource(request, resource_id))
+ return execute_from_flask(admin_api.patch_resource, request,
+ resource_id,
+ alternative_api=admin_)
APP.register_blueprint(BLUEPRINT)
diff --git a/pygeoapi/formatter/csv_.py b/pygeoapi/formatter/csv_.py
index 664bc8807..51a6ded17 100644
--- a/pygeoapi/formatter/csv_.py
+++ b/pygeoapi/formatter/csv_.py
@@ -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 @@ def write(self, options: dict = {}, data: dict = None) -> str:
# 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 @@ def write(self, options: dict = {}, data: dict = None) -> str:
LOGGER.error(err)
raise FormatterSerializationError('Error writing CSV output')
- return output.getvalue()
+ return output.getvalue().encode('utf-8')
def __repr__(self):
return f' {self.name}'
diff --git a/pygeoapi/l10n.py b/pygeoapi/l10n.py
index f363abd4c..908888e45 100644
--- a/pygeoapi/l10n.py
+++ b/pygeoapi/l10n.py
@@ -66,7 +66,7 @@ def str2locale(value, silent: bool = False) -> Union[Locale, None]:
:returns: babel.core.Locale or None
- :raises: LocaleError
+ :raises LocaleError:
"""
if isinstance(value, Locale):
@@ -103,7 +103,7 @@ def locale2str(value: Locale) -> str:
:returns: A string containing a web locale (e.g. 'fr-CH')
or language tag (e.g. 'de').
- :raises: LocaleError
+ :raises LocaleError:
"""
if not isinstance(value, Locale):
@@ -145,7 +145,7 @@ def best_match(accept_languages, available_locales) -> Locale:
:returns: babel.core.Locale
- :raises: LocaleError
+ :raises LocaleError:
"""
def get_match(locale_, available_locales_):
@@ -254,7 +254,7 @@ def translate(value, language: Union[Locale, str]):
:returns: A translated string or the original value.
- :raises: LocaleError
+ :raises LocaleError:
"""
nested_dicts = isinstance(value, dict) and any(isinstance(v, dict)
@@ -386,7 +386,7 @@ def set_response_language(headers: dict, *locale_: Locale):
Multiple locales can be set for this header.
Note that duplicates will be removed.
- :raises: LocaleError if no valid Babel Locale was found.
+ :raises LocaleError: if no valid Babel Locale was found.
"""
if not hasattr(headers, '__setitem__'):
@@ -422,7 +422,7 @@ def add_locale(url, locale_) -> str:
:returns: A new URL with a 'lang=' query parameter.
- :raises: requests.exceptions.MissingSchema
+ :raises requests.exceptions.MissingSchema:
"""
loc = str2locale(locale_, True)
diff --git a/pygeoapi/linked_data.py b/pygeoapi/linked_data.py
index b79a72aaf..83bad1e2a 100644
--- a/pygeoapi/linked_data.py
+++ b/pygeoapi/linked_data.py
@@ -31,11 +31,10 @@
Returns content as linked data representations
"""
-import json
import logging
from typing import Callable
-from pygeoapi.util import is_url, render_j2_template
+from pygeoapi.util import is_url, render_j2_template, url_join
from pygeoapi import l10n
from shapely.geometry import shape
from shapely.ops import unary_union
@@ -189,30 +188,22 @@ def geojson2jsonld(cls, data: dict, dataset: str,
:returns: string of rendered JSON (GeoJSON-LD)
"""
- LOGGER.debug('Fetching context and template from resource configuration')
- jsonld = cls.config['resources'][dataset].get('linked-data', {})
- ds_url = f"{cls.get_collections_url()}/{dataset}"
-
- context = jsonld.get('context', []).copy()
- template = jsonld.get('item_template', None)
+ LOGGER.debug('Fetching context from resource configuration')
+ context = cls.config['resources'][dataset].get('context', []).copy()
+ templates = cls.get_dataset_templates(dataset)
defaultVocabulary = {
'schema': 'https://schema.org/',
+ 'gsp': 'http://www.opengis.net/ont/geosparql#',
'type': '@type'
}
if identifier:
- # Single jsonld
- defaultVocabulary.update({
- 'gsp': 'http://www.opengis.net/ont/geosparql#'
- })
-
# Expand properties block
data.update(data.pop('properties'))
# Include multiple geometry encodings
if (data.get('geometry') is not None):
- data['type'] = 'schema:Place'
jsonldify_geometry(data)
data['@id'] = identifier
@@ -224,6 +215,7 @@ def geojson2jsonld(cls, data: dict, dataset: str,
'FeatureCollection': 'schema:itemList'
})
+ ds_url = url_join(cls.get_collections_url(), dataset)
data['@id'] = ds_url
for i, feature in enumerate(data['features']):
@@ -233,9 +225,15 @@ def geojson2jsonld(cls, data: dict, dataset: str,
if not is_url(str(identifier_)):
identifier_ = f"{ds_url}/items/{feature['id']}" # noqa
+ # Include multiple geometry encodings
+ if feature.get('geometry') is not None:
+ jsonldify_geometry(feature)
+
data['features'][i] = {
'@id': identifier_,
- 'type': 'schema:Place'
+ 'type': 'schema:Place',
+ **feature.pop('properties'),
+ **feature
}
if data.get('timeStamp', False):
@@ -248,14 +246,21 @@ def geojson2jsonld(cls, data: dict, dataset: str,
**data
}
- if None in (template, identifier):
- return ldjsonData
+ if identifier:
+ # Render jsonld template for single item
+ LOGGER.debug('Rendering JSON-LD item template')
+ content = render_j2_template(
+ cls.tpl_config, templates,
+ 'collections/items/item.jsonld', ldjsonData)
+
else:
- # Render jsonld template for single item with template configured
- LOGGER.debug(f'Rendering JSON-LD template: {template}')
- content = render_j2_template(cls.config, template, ldjsonData)
- ldjsonData = json.loads(content)
- return ldjsonData
+ # Render jsonld template for /items
+ LOGGER.debug('Rendering JSON-LD items template')
+ content = render_j2_template(
+ cls.tpl_config, templates,
+ 'collections/items/index.jsonld', ldjsonData)
+
+ return content
def jsonldify_geometry(feature: dict) -> None:
@@ -268,6 +273,8 @@ def jsonldify_geometry(feature: dict) -> None:
:returns: None
"""
+ feature['type'] = 'schema:Place'
+
geo = feature.get('geometry')
geom = shape(geo)
@@ -284,7 +291,11 @@ def jsonldify_geometry(feature: dict) -> None:
}
# Schema geometry
- feature['schema:geo'] = geom2schemageo(geom)
+ try:
+ feature['schema:geo'] = geom2schemageo(geom)
+ except AttributeError:
+ msg = f'Unable to parse schema geometry for {feature["id"]}'
+ LOGGER.warning(msg)
def geom2schemageo(geom: shape) -> dict:
diff --git a/pygeoapi/models/config.py b/pygeoapi/models/config.py
index d527ba14f..2aa6717b7 100644
--- a/pygeoapi/models/config.py
+++ b/pygeoapi/models/config.py
@@ -3,8 +3,10 @@
# =================================================================
#
# Authors: Sander Schaminee
+# Francesco Bartoli
#
# Copyright (c) 2023 Sander Schaminee
+# Copyright (c) 2024 Francesco Bartoli
#
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation
@@ -34,7 +36,7 @@
class APIRules(BaseModel):
""" Pydantic model for API design rules that must be adhered to. """
- api_version: str = Field(pattern=r'^\d+\.\d+\..+$',
+ api_version: str = Field(regex=r'^\d+\.\d+\..+$',
description="Semantic API version number.")
url_prefix: str = Field(
"",
@@ -60,11 +62,11 @@ def create(**rules_config) -> 'APIRules':
""" Returns a new APIRules instance for the current API version
and configured rules. """
obj = {
- k: v for k, v in rules_config.items() if k in APIRules.model_fields
+ k: v for k, v in rules_config.items() if k in APIRules.__fields__
}
# Validation will fail if required `api_version` is missing
# or if `api_version` is not a semantic version number
- return APIRules.model_validate(obj)
+ return APIRules.parse_obj(obj)
@property
def response_headers(self) -> dict:
diff --git a/pygeoapi/models/cql.py b/pygeoapi/models/cql.py
deleted file mode 100644
index c5838fc9e..000000000
--- a/pygeoapi/models/cql.py
+++ /dev/null
@@ -1,507 +0,0 @@
-# ****************************** -*-
-# flake8: noqa
-# generated by datamodel-codegen:
-# filename: cql-schema.json
-# timestamp: 2021-03-13T21:05:20+00:00
-# =================================================================
-#
-# Authors: Francesco Bartoli
-#
-# Copyright (c) 2021 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 datetime import date, datetime
-from typing import Any, List, Optional, Union
-from typing_extensions import Literal
-
-from pydantic import BaseModel, Field, RootModel
-
-
-class CQLModel(RootModel):
- root: 'Union[\n ComparisonPredicate,\n SpatialPredicate,\n TemporalPredicate,\n AndExpression\n ]'
-
-
-class AndExpression(BaseModel):
- and_: 'List[ComparisonPredicate]' = Field(..., alias='and')
-
-
-class NotExpression(BaseModel):
- not_: 'List[Any]' = Field(..., alias='not')
-
-
-class OrExpression(BaseModel):
- or_: 'List[Any]' = Field(..., alias='or')
-
-
-class PropertyRef(BaseModel):
- property: 'Optional[str]' = None
-
-
-class ScalarLiteral(RootModel):
- root: 'Union[str, float, bool]'
-
-
-class Bbox(RootModel):
- root: 'List[float]'
-
-
-class LinestringCoordinate(RootModel):
- root: 'List[Any]'
-
-
-class Linestring(BaseModel):
- type: Literal['LineString']
- coordinates: 'List[LinestringCoordinate]' = Field(...)
- bbox: 'Optional[List[float]]' = Field(None)
-
-
-class MultilineStringCoordinate(RootModel):
- root: 'List[Any]'
-
-
-class Multilinestring(BaseModel):
- type: Literal['MultiLineString']
- coordinates: 'List[List[MultilineStringCoordinate]]'
- bbox: 'Optional[List[float]]' = Field(None)
-
-
-class Multipoint(BaseModel):
- type: Literal['MultiPoint']
- coordinates: 'List[List[float]]'
- bbox: 'Optional[List[float]]' = Field(None)
-
-
-class MultipolygonCoordinateItem(RootModel):
- root: 'List[Any]'
-
-
-class Multipolygon(BaseModel):
- type: Literal['MultiPolygon']
- coordinates: 'List[List[List[MultipolygonCoordinateItem]]]'
- bbox: 'Optional[List[float]]' = Field(None)
-
-
-class Point(BaseModel):
- type: Literal['Point']
- coordinates: 'List[float]' = Field(...)
- bbox: 'Optional[List[float]]' = Field(None)
-
-
-class PolygonCoordinatesItem(RootModel):
- root: 'List[Any]'
-
-
-class Polygon(BaseModel):
- type: Literal['Polygon']
- coordinates: 'List[List[PolygonCoordinatesItem]]'
- bbox: 'Optional[List[float]]' = Field(None)
-
-
-class TimeString(RootModel):
- root: 'Union[date, datetime]'
-
-
-class EnvelopeLiteral(BaseModel):
- bbox: 'Bbox'
-
-
-class GeometryLiteral(RootModel):
- root: 'Union[\n Point, Linestring, Polygon, Multipoint, Multilinestring, Multipolygon\n ]'
-
-
-class TypedTimeString(BaseModel):
- datetime: 'TimeString'
-
-
-class PeriodString(RootModel):
- root: 'List[Union[TimeString, str]]' = Field(...)
-
-
-class SpatialLiteral(RootModel):
- root: 'Union[GeometryLiteral, EnvelopeLiteral]'
-
-
-class TemporalLiteral(RootModel):
- root: 'Union[TimeString, PeriodString]'
-
-
-class TypedPeriodString(BaseModel):
- datetime: 'PeriodString'
-
-
-class TypedTemporalLiteral(RootModel):
- root: 'Union[TypedTimeString, TypedPeriodString]'
-
-
-class ArrayPredicate(RootModel):
- root: 'Union[\n AequalsExpression,\n AcontainsExpression,\n AcontainedByExpression,\n AoverlapsExpression,\n ]'
-
-
-class ComparisonPredicate(RootModel):
- root: 'Union[\n BinaryComparisonPredicate,\n IsLikePredicate,\n IsBetweenPredicate,\n IsInListPredicate,\n IsNullPredicate,\n ]'
-
-
-class SpatialPredicate(RootModel):
- root: 'Union[\n IntersectsExpression,\n EqualsExpression,\n DisjointExpression,\n TouchesExpression,\n WithinExpression,\n OverlapsExpression,\n CrossesExpression,\n ContainsExpression,\n ]'
-
-
-class TemporalPredicate(RootModel):
- root: 'Union[\n BeforeExpression,\n AfterExpression,\n MeetsExpression,\n MetbyExpression,\n ToverlapsExpression,\n OverlappedbyExpression,\n BeginsExpression,\n BegunbyExpression,\n DuringExpression,\n TcontainsExpression,\n EndsExpression,\n EndedbyExpression,\n TequalsExpression,\n AnyinteractsExpression,\n ]'
-
-
-class AcontainedByExpression(BaseModel):
- acontainedBy: 'ArrayExpression'
-
-
-class AcontainsExpression(BaseModel):
- acontains: 'ArrayExpression'
-
-
-class AequalsExpression(BaseModel):
- aequals: 'ArrayExpression'
-
-
-class AfterExpression(BaseModel):
- after: 'TemporalOperands'
-
-
-class AnyinteractsExpression(BaseModel):
- anyinteracts: 'TemporalOperands'
-
-
-class AoverlapsExpression(BaseModel):
- aoverlaps: 'ArrayExpression'
-
-
-class BeforeExpression(BaseModel):
- before: 'TemporalOperands'
-
-
-class BeginsExpression(BaseModel):
- begins: 'TemporalOperands'
-
-
-class BegunbyExpression(BaseModel):
- begunby: 'TemporalOperands'
-
-
-class BinaryComparisonPredicate(RootModel):
- root: 'Union[\n EqExpression, LtExpression, GtExpression, LteExpression, GteExpression\n ]'
-
-
-class ContainsExpression(BaseModel):
- contains: 'SpatialOperands'
-
-
-class CrossesExpression(BaseModel):
- crosses: 'SpatialOperands'
-
-
-class DisjointExpression(BaseModel):
- disjoint: 'SpatialOperands'
-
-
-class DuringExpression(BaseModel):
- during: 'TemporalOperands'
-
-
-class EndedbyExpression(BaseModel):
- endedby: 'TemporalOperands'
-
-
-class EndsExpression(BaseModel):
- ends: 'TemporalOperands'
-
-
-class EqualsExpression(BaseModel):
- equals: 'SpatialOperands'
-
-
-class IntersectsExpression(BaseModel):
- intersects: 'SpatialOperands'
-
-
-class Between(BaseModel):
- value: 'ValueExpression'
- lower: 'Optional[ScalarExpression]' = Field(None)
- upper: 'Optional[ScalarExpression]' = Field(None)
-
-
-class IsBetweenPredicate(BaseModel):
- between: 'Between'
-
-
-class In(BaseModel):
- value: 'ValueExpression'
- list: 'List[ValueExpression]'
- nocase: 'Optional[bool]' = True
-
-
-class IsInListPredicate(BaseModel):
- in_: 'In' = Field(..., alias='in')
-
-
-class IsLikePredicate(BaseModel):
- like: 'ScalarOperands'
- wildcard: 'Optional[str]' = '%'
- singleChar: 'Optional[str]' = '.'
- escapeChar: 'Optional[str]' = '\\'
- nocase: 'Optional[bool]' = True
-
-
-class IsNullPredicate(BaseModel):
- isNull: 'ScalarExpression'
-
-
-class MeetsExpression(BaseModel):
- meets: 'TemporalOperands'
-
-
-class MetbyExpression(BaseModel):
- metby: 'TemporalOperands'
-
-
-class OverlappedbyExpression(BaseModel):
- overlappedby: 'TemporalOperands'
-
-
-class OverlapsExpression(BaseModel):
- overlaps: 'SpatialOperands'
-
-
-class TcontainsExpression(BaseModel):
- tcontains: 'TemporalOperands'
-
-
-class TequalsExpression(BaseModel):
- tequals: 'TemporalOperands'
-
-
-class TouchesExpression(BaseModel):
- touches: 'SpatialOperands'
-
-
-class ToverlapsExpression(BaseModel):
- toverlaps: 'TemporalOperands'
-
-
-class WithinExpression(BaseModel):
- within: 'SpatialOperands'
-
-
-class ArrayExpression(RootModel):
- root: 'List[Union[PropertyRef, FunctionRef, ArrayLiteral]]' = Field(
- ... # , max_items=2, min_items=2
- )
-
-
-class EqExpression(BaseModel):
- eq: 'ScalarOperands'
-
-
-class GtExpression(BaseModel):
- gt: 'ScalarOperands'
-
-
-class GteExpression(BaseModel):
- gte: 'ScalarOperands'
-
-
-class LtExpression(BaseModel):
- lt: 'ScalarOperands'
-
-
-class LteExpression(BaseModel):
- lte: 'ScalarOperands'
-
-
-class ScalarExpression(RootModel):
- root: 'Union[ScalarLiteral, PropertyRef,\n FunctionRef, ArithmeticExpression]'
-
-
-class ScalarOperands(RootModel):
- root: 'List[ScalarExpression]' = Field(...)
-
-
-class SpatialOperands(RootModel):
- root: 'List[GeomExpression]' = Field(...)
-
-
-class TemporalOperands(RootModel):
- root: 'List[TemporalExpression]' = Field(...)
- # , max_items=2, min_items=2)
-
-
-class ValueExpression(RootModel):
- root: 'Union[ScalarExpression, SpatialLiteral, TypedTemporalLiteral]'
-
-
-class ArithmeticExpression(RootModel):
- root: 'Union[AddExpression, SubExpression, MulExpression, DivExpression]'
-
-
-class ArrayLiteral(RootModel):
- root: 'List[\n Union[\n ScalarLiteral,\n SpatialLiteral,\n TypedTemporalLiteral,\n PropertyRef,\n FunctionRef,\n ArithmeticExpression,\n ArrayLiteral,\n ]\n ]'
-
-
-class FunctionRef(BaseModel):
- function: 'Function'
-
-
-class GeomExpression(RootModel):
- root: 'Union[SpatialLiteral, PropertyRef, FunctionRef]'
-
-
-class TemporalExpression(RootModel):
- root: 'Union[TemporalLiteral, PropertyRef, FunctionRef]'
-
-
-# here
-class AddExpression(BaseModel):
- add_: 'ArithmeticOperands' = Field(..., alias='+')
-
-
-# here
-class DivExpression(BaseModel):
- div_: 'Optional[ArithmeticOperands]' = Field(None, alias='/')
-
-
-class Function(BaseModel):
- name: 'str'
- arguments: 'Optional[\n List[\n Union[\n ScalarLiteral,\n SpatialLiteral,\n TypedTemporalLiteral,\n PropertyRef,\n FunctionRef,\n ArithmeticExpression,\n ArrayLiteral,\n ]\n ]\n ]' = None
-
-
-# here
-class MulExpression(BaseModel):
- mul_: 'ArithmeticOperands' = Field(..., alias='*')
-
-
-# here
-class SubExpression(BaseModel):
- sub_: 'ArithmeticOperands' = Field(..., alias='-')
-
-
-class ArithmeticOperands(RootModel):
- root: 'List[\n Union[ArithmeticExpression, PropertyRef, FunctionRef, float]\n ]' = Field(...)
-
-
-CQLModel.model_rebuild()
-AndExpression.model_rebuild()
-ArrayPredicate.model_rebuild()
-ComparisonPredicate.model_rebuild()
-SpatialPredicate.model_rebuild()
-TemporalPredicate.model_rebuild()
-AcontainedByExpression.model_rebuild()
-AcontainsExpression.model_rebuild()
-AequalsExpression.model_rebuild()
-AfterExpression.model_rebuild()
-AnyinteractsExpression.model_rebuild()
-AoverlapsExpression.model_rebuild()
-BeforeExpression.model_rebuild()
-BeginsExpression.model_rebuild()
-BegunbyExpression.model_rebuild()
-BinaryComparisonPredicate.model_rebuild()
-ContainsExpression.model_rebuild()
-CrossesExpression.model_rebuild()
-DisjointExpression.model_rebuild()
-DuringExpression.model_rebuild()
-EndedbyExpression.model_rebuild()
-EndsExpression.model_rebuild()
-EqualsExpression.model_rebuild()
-IntersectsExpression.model_rebuild()
-Between.model_rebuild()
-In.model_rebuild()
-IsBetweenPredicate.model_rebuild()
-IsLikePredicate.model_rebuild()
-IsNullPredicate.model_rebuild()
-ValueExpression.model_rebuild()
-MeetsExpression.model_rebuild()
-MetbyExpression.model_rebuild()
-OverlappedbyExpression.model_rebuild()
-OverlapsExpression.model_rebuild()
-TcontainsExpression.model_rebuild()
-TequalsExpression.model_rebuild()
-TouchesExpression.model_rebuild()
-ToverlapsExpression.model_rebuild()
-WithinExpression.model_rebuild()
-ArrayExpression.model_rebuild()
-EqExpression.model_rebuild()
-GtExpression.model_rebuild()
-GteExpression.model_rebuild()
-LtExpression.model_rebuild()
-LteExpression.model_rebuild()
-ScalarExpression.model_rebuild()
-ScalarOperands.model_rebuild()
-SpatialOperands.model_rebuild()
-TemporalOperands.model_rebuild()
-ArithmeticExpression.model_rebuild()
-ArrayLiteral.model_rebuild()
-ScalarLiteral.model_rebuild()
-PropertyRef.model_rebuild()
-FunctionRef.model_rebuild()
-AddExpression.model_rebuild()
-DivExpression.model_rebuild()
-MulExpression.model_rebuild()
-SubExpression.model_rebuild()
-
-
-def get_next_node(obj):
- logical_op = None
- if obj.__repr_name__() == 'AndExpression':
- next_node = obj.and_
- logical_op = 'and'
- elif obj.__repr_name__() == 'OrExpression':
- next_node = obj.or_
- logical_op = 'or'
- elif obj.__repr_name__() == 'NotExpression':
- next_node = obj.not_
- logical_op = 'not'
- elif obj.__repr_name__() == 'ComparisonPredicate':
- next_node = obj.root
- elif obj.__repr_name__() == 'SpatialPredicate':
- next_node = obj.root
- elif obj.__repr_name__() == 'TemporalPredicate':
- next_node = obj.root
- elif obj.__repr_name__() == 'IsBetweenPredicate':
- next_node = obj.between
- elif obj.__repr_name__() == 'Between':
- next_node = obj.value
- elif obj.__repr_name__() == 'ValueExpression':
- next_node = obj.root or obj.lower or obj.upper
- elif obj.__repr_name__() == 'ScalarExpression':
- next_node = obj.root
- elif obj.__repr_name__() == 'ScalarLiteral':
- next_node = obj.root
- elif obj.__repr_name__() == 'PropertyRef':
- next_node = obj.property
- elif obj.__repr_name__() == 'BinaryComparisonPredicate':
- next_node = obj.root
- elif obj.__repr_name__() == 'EqExpression':
- next_node = obj.eq
- logical_op = 'eq'
- else:
- raise ValueError("Object not valid")
-
- return (logical_op, next_node)
diff --git a/pygeoapi/models/openapi.py b/pygeoapi/models/openapi.py
index ffe587f04..d07923612 100644
--- a/pygeoapi/models/openapi.py
+++ b/pygeoapi/models/openapi.py
@@ -4,7 +4,7 @@
#
# Authors: Francesco Bartoli
#
-# Copyright (c) 2022 Francesco Bartoli
+# Copyright (c) 2024 Francesco Bartoli
#
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation
@@ -31,7 +31,7 @@
from enum import Enum
-from pydantic import RootModel
+from pydantic import BaseModel
class SupportedFormats(Enum):
@@ -39,5 +39,5 @@ class SupportedFormats(Enum):
YAML = "yaml"
-class OAPIFormat(RootModel):
- root: SupportedFormats = SupportedFormats.YAML
+class OAPIFormat(BaseModel):
+ __root__: SupportedFormats = SupportedFormats.YAML
diff --git a/pygeoapi/openapi.py b/pygeoapi/openapi.py
index d697df206..bd4fe1775 100644
--- a/pygeoapi/openapi.py
+++ b/pygeoapi/openapi.py
@@ -4,7 +4,7 @@
# Authors: Francesco Bartoli
# Authors: Ricardo Garcia Silva
#
-# Copyright (c) 2023 Tom Kralidis
+# Copyright (c) 2024 Tom Kralidis
# Copyright (c) 2022 Francesco Bartoli
# Copyright (c) 2023 Ricardo Garcia Silva
#
@@ -44,12 +44,9 @@
import yaml
from pygeoapi import l10n
+from pygeoapi.api import all_apis
from pygeoapi.models.openapi import OAPIFormat
-from pygeoapi.plugin import load_plugin
-from pygeoapi.process.manager.base import get_manager
-from pygeoapi.provider.base import ProviderTypeError, SchemaType
-from pygeoapi.util import (filter_dict_by_key_value, get_provider_by_type,
- filter_providers_by_type, to_json, yaml_load,
+from pygeoapi.util import (filter_dict_by_key_value, to_json, yaml_load,
get_api_rules, get_base_url)
LOGGER = logging.getLogger(__name__)
@@ -68,7 +65,14 @@
THISDIR = os.path.dirname(os.path.realpath(__file__))
-def get_ogc_schemas_location(server_config):
+def get_ogc_schemas_location(server_config: dict) -> str:
+ """
+ Determine OGC schemas location
+
+ :param server_config: `dict` of server configuration
+
+ :returns: `str` of OGC schemas location
+ """
osl = server_config.get('ogc_schemas_location')
@@ -85,7 +89,7 @@ def get_ogc_schemas_location(server_config):
# TODO: remove this function once OGC API - Processing is final
-def gen_media_type_object(media_type, api_type, path):
+def gen_media_type_object(media_type: str, api_type: str, path: str) -> dict:
"""
Generates an OpenAPI Media Type Object
@@ -110,7 +114,8 @@ def gen_media_type_object(media_type, api_type, path):
# TODO: remove this function once OGC API - Processing is final
-def gen_response_object(description, media_type, api_type, path):
+def gen_response_object(description: str, media_type: str,
+ api_type: str, path: str) -> dict:
"""
Generates an OpenAPI Response Object
@@ -129,20 +134,110 @@ def gen_response_object(description, media_type, api_type, path):
return response
-def get_oas_30(cfg):
+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
+ """
+
+ has_addresses = False
+ has_phones = False
+
+ contact = {
+ 'name': cfg['metadata']['provider']['name']
+ }
+
+ for key in ['url', 'email']:
+ if key in cfg['metadata']['provider']:
+ contact[key] = cfg['metadata']['provider'][key]
+
+ contact['x-ogc-serviceContact'] = {
+ 'name': cfg['metadata']['contact']['name'],
+ 'addresses': []
+ }
+
+ if 'position' in cfg['metadata']['contact']:
+ contact['x-ogc-serviceContact']['position'] = cfg['metadata']['contact']['position'] # noqa
+
+ if any(address in ['address', 'city', 'stateorprovince', 'postalcode', 'country'] for address in cfg['metadata']['contact']): # noqa
+ has_addresses = True
+
+ if has_addresses:
+ address = {}
+ if 'address' in cfg['metadata']['contact']:
+ address['deliveryPoint'] = [cfg['metadata']['contact']['address']]
+
+ if 'city' in cfg['metadata']['contact']:
+ address['city'] = cfg['metadata']['contact']['city']
+
+ if 'stateorprovince' in cfg['metadata']['contact']:
+ address['administrativeArea'] = cfg['metadata']['contact']['stateorprovince'] # noqa
+
+ if 'postalCode' in cfg['metadata']['contact']:
+ address['administrativeArea'] = cfg['metadata']['contact']['postalCode'] # noqa
+
+ if 'country' in cfg['metadata']['contact']:
+ address['administrativeArea'] = cfg['metadata']['contact']['country'] # noqa
+
+ contact['x-ogc-serviceContact']['addresses'].append(address)
+
+ if any(phone in ['phone', 'fax'] for phone in cfg['metadata']['contact']):
+ has_phones = True
+ contact['x-ogc-serviceContact']['phones'] = []
+
+ if has_phones:
+ if 'phone' in cfg['metadata']['contact']:
+ contact['x-ogc-serviceContact']['phones'].append({
+ 'type': 'main', 'value': cfg['metadata']['contact']['phone']
+ })
+
+ if 'fax' in cfg['metadata']['contact']:
+ contact['x-ogc-serviceContact']['phones'].append({
+ 'type': 'fax', 'value': cfg['metadata']['contact']['fax']
+ })
+
+ if 'email' in cfg['metadata']['contact']:
+ contact['x-ogc-serviceContact']['emails'] = [{
+ 'value': cfg['metadata']['contact']['email']
+ }]
+
+ if 'url' in cfg['metadata']['contact']:
+ contact['x-ogc-serviceContact']['links'] = [{
+ 'type': 'text/html',
+ 'href': cfg['metadata']['contact']['url']
+ }]
+
+ if 'instructions' in cfg['metadata']['contact']:
+ contact['x-ogc-serviceContact']['contactInstructions'] = cfg['metadata']['contact']['instructions'] # noqa
+
+ if 'hours' in cfg['metadata']['contact']:
+ contact['x-ogc-serviceContact']['hoursOfService'] = cfg['metadata']['contact']['hours'] # noqa
+
+ if 'role' in cfg['metadata']['contact']:
+ contact['x-ogc-serviceContact']['hoursOfService'] = cfg['metadata']['contact']['role'] # noqa
+
+ return contact
+
+
+def get_oas_30(cfg: dict, fail_on_invalid_collection: bool = True) -> dict:
"""
Generates an OpenAPI 3.0 Document
:param cfg: configuration object
+ :param fail_on_invalid_collection: `bool` of whether to fail on an invalid
+ collection
- :returns: OpenAPI definition YAML dict
+ :returns: dict of OpenAPI definition
"""
paths = {}
# TODO: make openapi multilingual (default language only for now)
- server_locales = l10n.get_locales(cfg)
- locale_ = server_locales[0]
+ locale_ = l10n.get_locales(cfg)[0]
api_rules = get_api_rules(cfg)
@@ -161,11 +256,7 @@ def get_oas_30(cfg):
'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']
@@ -272,11 +363,6 @@ def get_oas_30(cfg):
'url': cfg['metadata']['identification']['url']}
}
)
- oas['tags'].append({
- 'name': 'stac',
- 'description': 'SpatioTemporal Asset Catalog'
- }
- )
oas['components'] = {
'responses': {
@@ -299,149 +385,7 @@ def get_oas_30(cfg):
}
}
},
- 'parameters': {
- 'f': {
- 'name': 'f',
- 'in': 'query',
- 'description': 'The optional f parameter indicates the output format which the server shall provide as part of the response document. The default format is GeoJSON.', # noqa
- 'required': False,
- 'schema': {
- 'type': 'string',
- 'enum': ['json', 'html', 'jsonld'],
- 'default': 'json'
- },
- 'style': 'form',
- 'explode': False
- },
- 'lang': {
- 'name': 'lang',
- 'in': 'query',
- 'description': 'The optional lang parameter instructs the server return a response in a certain language, if supported. If the language is not among the available values, the Accept-Language header language will be used if it is supported. If the header is missing, the default server language is used. Note that providers may only support a single language (or often no language at all), that can be different from the server language. Language strings can be written in a complex (e.g. "fr-CA,fr;q=0.9,en-US;q=0.8,en;q=0.7"), simple (e.g. "de") or locale-like (e.g. "de-CH" or "fr_BE") fashion.', # noqa
- 'required': False,
- 'schema': {
- 'type': 'string',
- 'enum': [l10n.locale2str(sl) for sl in server_locales],
- 'default': l10n.locale2str(locale_)
- }
- },
- 'properties': {
- 'name': 'properties',
- 'in': 'query',
- 'description': 'The properties that should be included for each feature. The parameter value is a comma-separated list of property names.', # noqa
- 'required': False,
- 'style': 'form',
- 'explode': False,
- 'schema': {
- 'type': 'array',
- 'items': {
- 'type': 'string'
- }
- }
- },
- 'skipGeometry': {
- 'name': 'skipGeometry',
- 'in': 'query',
- 'description': 'This option can be used to skip response geometries for each feature.', # noqa
- 'required': False,
- 'style': 'form',
- 'explode': False,
- 'schema': {
- 'type': 'boolean',
- 'default': False
- }
- },
- 'crs': {
- 'name': 'crs',
- 'in': 'query',
- 'description': 'Indicates the coordinate reference system for the results.', # noqa
- 'style': 'form',
- 'required': False,
- 'explode': False,
- 'schema': {
- 'format': 'uri',
- 'type': 'string'
- }
- },
- 'bbox': {
- 'name': 'bbox',
- 'in': 'query',
- 'description': 'Only features that have a geometry that intersects the bounding box are selected.' # noqa
- 'The bounding box is provided as four or six numbers, depending on whether the ' # noqa
- 'coordinate reference system includes a vertical axis (height or depth).', # noqa
- 'required': False,
- 'style': 'form',
- 'explode': False,
- 'schema': {
- 'type': 'array',
- 'minItems': 4,
- 'maxItems': 6,
- 'items': {
- 'type': 'number'
- }
- }
- },
- 'bbox-crs': {
- 'name': 'bbox-crs',
- 'in': 'query',
- 'description': 'Indicates the coordinate reference system for the given bbox coordinates.', # noqa
- 'style': 'form',
- 'required': False,
- 'explode': False,
- 'schema': {
- 'format': 'uri',
- 'type': 'string'
- }
- },
- # FIXME: This is not compatible with the bbox-crs definition in
- # OGCAPI Features Part 2!
- # We need to change the mapscript provider and
- # get_collection_map() method in the API!
- # So this is for de map-provider only.
- 'bbox-crs-epsg': {
- 'name': 'bbox-crs',
- 'in': 'query',
- 'description': 'Indicates the EPSG for the given bbox coordinates.', # noqa
- 'required': False,
- 'style': 'form',
- 'explode': False,
- 'schema': {
- 'type': 'integer',
- 'default': 4326
- }
- },
- 'offset': {
- 'name': 'offset',
- 'in': 'query',
- 'description': 'The optional offset parameter indicates the index within the result set from which the server shall begin presenting results in the response document. The first element has an index of 0 (default).', # noqa
- 'required': False,
- 'schema': {
- 'type': 'integer',
- 'minimum': 0,
- 'default': 0
- },
- 'style': 'form',
- 'explode': False
- },
- 'vendorSpecificParameters': {
- 'name': 'vendorSpecificParameters',
- 'in': 'query',
- 'description': 'Additional "free-form" parameters that are not explicitly defined', # noqa
- 'schema': {
- 'type': 'object',
- 'additionalProperties': True
- },
- 'style': 'form'
- },
- 'resourceId': {
- 'name': 'resourceId',
- 'in': 'path',
- 'description': 'Configuration resource identifier',
- 'required': True,
- 'schema': {
- 'type': 'string'
- }
- }
- },
+ 'parameters': get_oas_30_parameters(cfg=cfg, locale_=locale_),
'schemas': {
# TODO: change this schema once OGC will definitively publish it
'queryable': {
@@ -498,16 +442,10 @@ def get_oas_30(cfg):
items_f = deepcopy(oas['components']['parameters']['f'])
items_f['schema']['enum'].append('csv')
- items_l = deepcopy(oas['components']['parameters']['lang'])
LOGGER.debug('setting up datasets')
- collections = filter_dict_by_key_value(cfg['resources'],
- 'type', 'collection')
- for k, v in collections.items():
- if v.get('visibility', 'default') == 'hidden':
- LOGGER.debug(f'Skipping hidden layer: {k}')
- continue
+ for k, v in get_visible_collections(cfg).items():
name = l10n.translate(k, locale_)
title = l10n.translate(v['title'], locale_)
desc = l10n.translate(v['description'], locale_)
@@ -546,782 +484,227 @@ def get_oas_30(cfg):
}
}
- LOGGER.debug('setting up collection endpoints')
- try:
- ptype = None
-
- if filter_providers_by_type(
- collections[k]['providers'], 'feature'):
- ptype = 'feature'
-
- if filter_providers_by_type(
- collections[k]['providers'], 'record'):
- ptype = 'record'
-
- p = load_plugin('provider', get_provider_by_type(
- collections[k]['providers'], ptype))
-
- items_path = f'{collection_name_path}/items'
-
- coll_properties = deepcopy(oas['components']['parameters']['properties']) # noqa
-
- coll_properties['schema']['items']['enum'] = list(p.fields.keys())
-
- paths[items_path] = {
- 'get': {
- 'summary': f'Get {title} items',
- 'description': desc,
- 'tags': [name],
- 'operationId': f'get{name.capitalize()}Features',
- 'parameters': [
- items_f,
- items_l,
- {'$ref': '#/components/parameters/bbox'},
- {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/parameters/limit"}, # noqa
- {'$ref': '#/components/parameters/crs'}, # noqa
- {'$ref': '#/components/parameters/bbox-crs'}, # noqa
- coll_properties,
- {'$ref': '#/components/parameters/vendorSpecificParameters'}, # noqa
- {'$ref': '#/components/parameters/skipGeometry'},
- {'$ref': f"{OPENAPI_YAML['oapir']}/parameters/sortby.yaml"}, # noqa
- {'$ref': '#/components/parameters/offset'},
- ],
- 'responses': {
- '200': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/Features"}, # noqa
- '400': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/InvalidParameter"}, # noqa
- '404': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/NotFound"}, # noqa
- '500': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/ServerError"} # noqa
- }
- },
- 'options': {
- 'summary': f'Options for {title} items',
- 'description': desc,
- 'tags': [name],
- 'operationId': f'options{name.capitalize()}Features',
- 'responses': {
- '200': {'description': 'options response'}
- }
- }
- }
-
- if p.editable:
- LOGGER.debug('Provider is editable; adding post')
-
- paths[items_path]['post'] = {
- 'summary': f'Add {title} items',
- 'description': desc,
- 'tags': [name],
- 'operationId': f'add{name.capitalize()}Features',
- 'requestBody': {
- 'description': 'Adds item to collection',
- 'content': {
- 'application/geo+json': {
- 'schema': {}
+ oas['components']['responses'].update({
+ 'Tiles': {
+ 'description': 'Retrieves the tiles description for this collection', # noqa
+ 'content': {
+ 'application/json': {
+ 'schema': {
+ '$ref': '#/components/schemas/tiles'
}
- },
- 'required': True
- },
- 'responses': {
- '201': {'description': 'Successful creation'},
- '400': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/InvalidParameter"}, # noqa
- '500': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/ServerError"} # noqa
- }
- }
-
- try:
- schema_ref = p.get_schema(SchemaType.create)
- paths[items_path]['post']['requestBody']['content'][schema_ref[0]] = { # noqa
- 'schema': schema_ref[1]
- }
- except Exception as err:
- LOGGER.debug(err)
-
- if ptype == 'record':
- paths[items_path]['get']['parameters'].append(
- {'$ref': f"{OPENAPI_YAML['oapir']}/parameters/q.yaml"})
- if p.fields:
- queryables_path = f'{collection_name_path}/queryables'
-
- paths[queryables_path] = {
- 'get': {
- 'summary': f'Get {title} queryables',
- 'description': desc,
- 'tags': [name],
- 'operationId': f'get{name.capitalize()}Queryables',
- 'parameters': [
- items_f,
- items_l
- ],
- 'responses': {
- '200': {'$ref': '#/components/responses/Queryables'}, # noqa
- '400': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/InvalidParameter"}, # noqa
- '404': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/NotFound"}, # noqa
- '500': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/ServerError"}, # noqa
}
}
}
-
- if p.time_field is not None:
- paths[items_path]['get']['parameters'].append(
- {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/parameters/datetime"}) # noqa
-
- for field, type_ in p.fields.items():
-
- if p.properties and field not in p.properties:
- LOGGER.debug('Provider specified not to advertise property') # noqa
- continue
-
- if field == 'q' and ptype == 'record':
- LOGGER.debug('q parameter already declared, skipping')
- continue
-
- if type_ == 'date':
- schema = {
- 'type': 'string',
- 'format': 'date'
- }
- elif type_ == 'float':
- schema = {
- 'type': 'number',
- 'format': 'float'
- }
- elif type_ == 'long':
- schema = {
- 'type': 'integer',
- 'format': 'int64'
- }
- else:
- schema = type_
-
- path_ = f'{collection_name_path}/items'
- paths[path_]['get']['parameters'].append({
- 'name': field,
- 'in': 'query',
- 'required': False,
- 'schema': schema,
- 'style': 'form',
- 'explode': False
- })
-
- paths[f'{collection_name_path}/items/{{featureId}}'] = {
- 'get': {
- 'summary': f'Get {title} item by id',
- 'description': desc,
- 'tags': [name],
- 'operationId': f'get{name.capitalize()}Feature',
- 'parameters': [
- {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/parameters/featureId"}, # noqa
- {'$ref': '#/components/parameters/crs'}, # noqa
- {'$ref': '#/components/parameters/f'},
- {'$ref': '#/components/parameters/lang'}
- ],
- 'responses': {
- '200': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/Feature"}, # noqa
- '400': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/InvalidParameter"}, # noqa
- '404': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/NotFound"}, # noqa
- '500': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/ServerError"} # noqa
- }
- },
- 'options': {
- 'summary': f'Options for {title} item by id',
- 'description': desc,
- 'tags': [name],
- 'operationId': f'options{name.capitalize()}Feature',
- 'parameters': [
- {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/parameters/featureId"} # noqa
- ],
- 'responses': {
- '200': {'description': 'options response'}
- }
- }
}
+ )
- try:
- schema_ref = p.get_schema()
- paths[f'{collection_name_path}/items/{{featureId}}']['get']['responses']['200'] = { # noqa
- 'content': {
- schema_ref[0]: {
- 'schema': schema_ref[1]
+ oas['components']['schemas'].update({
+ 'tilematrixsetlink': {
+ 'type': 'object',
+ 'required': ['tileMatrixSet'],
+ 'properties': {
+ 'tileMatrixSet': {
+ 'type': 'string'
+ },
+ 'tileMatrixSetURI': {
+ 'type': 'string'
}
}
- }
- except Exception as err:
- LOGGER.debug(err)
-
- if p.editable:
- LOGGER.debug('Provider is editable; adding put/delete')
- put_path = f'{collection_name_path}/items/{{featureId}}' # noqa
- paths[put_path]['put'] = { # noqa
- 'summary': f'Update {title} items',
- 'description': desc,
- 'tags': [name],
- 'operationId': f'update{name.capitalize()}Features',
- 'parameters': [
- {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/parameters/featureId"} # noqa
+ },
+ 'tiles': {
+ 'type': 'object',
+ 'required': [
+ 'tileMatrixSetLinks',
+ 'links'
],
- 'requestBody': {
- 'description': 'Updates item in collection',
- 'content': {
- 'application/geo+json': {
- 'schema': {}
+ 'properties': {
+ 'tileMatrixSetLinks': {
+ 'type': 'array',
+ 'items': {
+ '$ref': '#/components/schemas/tilematrixsetlink' # noqa
}
},
- 'required': True
- },
- 'responses': {
- '204': {'$ref': '#/components/responses/204'},
- '400': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/InvalidParameter"}, # noqa
- '500': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/ServerError"} # noqa
+ 'links': {
+ 'type': 'array',
+ 'items': {'$ref': f"{OPENAPI_YAML['oapit']}#/components/schemas/link"} # noqa
+ }
}
}
+ }
+ )
- try:
- schema_ref = p.get_schema(SchemaType.replace)
- paths[put_path]['put']['requestBody']['content'][schema_ref[0]] = { # noqa
- 'schema': schema_ref[1]
- }
- except Exception as err:
- LOGGER.debug(err)
-
- paths[f'{collection_name_path}/items/{{featureId}}']['delete'] = { # noqa
- 'summary': f'Delete {title} items',
- 'description': desc,
- 'tags': [name],
- 'operationId': f'delete{name.capitalize()}Features',
- 'parameters': [
- {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/parameters/featureId"}, # noqa
- ],
- 'responses': {
- '200': {'description': 'Successful delete'},
- '400': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/InvalidParameter"}, # noqa
- '500': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/ServerError"} # noqa
- }
- }
+ oas['paths'] = paths
- except ProviderTypeError:
- LOGGER.debug('collection is not feature based')
+ for api_name, api_module in all_apis().items():
+ LOGGER.debug(f'Adding OpenAPI definitions for {api_name}')
- LOGGER.debug('setting up coverage endpoints')
try:
- load_plugin('provider', get_provider_by_type(
- collections[k]['providers'], 'coverage'))
-
- coverage_path = f'{collection_name_path}/coverage'
-
- paths[coverage_path] = {
- 'get': {
- 'summary': f'Get {title} coverage',
- 'description': desc,
- 'tags': [name],
- 'operationId': f'get{name.capitalize()}Coverage',
- 'parameters': [
- items_f,
- items_l,
- {'$ref': '#/components/parameters/bbox'},
- {'$ref': '#/components/parameters/bbox-crs'}, # noqa
- ],
- 'responses': {
- '200': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/Features"}, # noqa
- '400': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/InvalidParameter"}, # noqa
- '404': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/NotFound"}, # noqa
- '500': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/ServerError"} # noqa
- }
- }
- }
-
- coverage_domainset_path = f'{collection_name_path}/coverage/domainset' # noqa
-
- paths[coverage_domainset_path] = {
- 'get': {
- 'summary': f'Get {title} coverage domain set',
- 'description': desc,
- 'tags': [name],
- 'operationId': f'get{name.capitalize()}CoverageDomainSet',
- 'parameters': [
- items_f,
- items_l
- ],
- 'responses': {
- '200': {'$ref': f"{OPENAPI_YAML['oacov']}/schemas/cis_1.1/domainSet.yaml"}, # noqa
- '400': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/InvalidParameter"}, # noqa
- '404': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/NotFound"}, # noqa
- '500': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/ServerError"} # noqa
- }
- }
- }
+ sub_tags, sub_paths = api_module.get_oas_30(cfg, locale_)
+ oas['paths'].update(sub_paths['paths'])
+ oas['tags'].extend(sub_tags)
+ except Exception as err:
+ if fail_on_invalid_collection:
+ raise
+ else:
+ LOGGER.warning(f'Resource not added to OpenAPI: {err}')
- coverage_rangetype_path = f'{collection_name_path}/coverage/rangetype' # noqa
-
- paths[coverage_rangetype_path] = {
- 'get': {
- 'summary': f'Get {title} coverage range type',
- 'description': desc,
- 'tags': [name],
- 'operationId': f'get{name.capitalize()}CoverageRangeType',
- 'parameters': [
- items_f,
- items_l
- ],
- 'responses': {
- '200': {'$ref': f"{OPENAPI_YAML['oacov']}/schemas/cis_1.1/rangeType.yaml"}, # noqa
- '400': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/InvalidParameter"}, # noqa
- '404': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/NotFound"}, # noqa
- '500': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/ServerError"} # noqa
- }
- }
- }
- except ProviderTypeError:
- LOGGER.debug('collection is not coverage based')
-
- LOGGER.debug('setting up tiles endpoints')
- tile_extension = filter_providers_by_type(
- collections[k]['providers'], 'tile')
-
- if tile_extension:
- tp = load_plugin('provider', tile_extension)
- oas['components']['responses'].update({
- 'Tiles': {
- 'description': 'Retrieves the tiles description for this collection', # noqa
- 'content': {
- 'application/json': {
- 'schema': {
- '$ref': '#/components/schemas/tiles'
- }
- }
- }
- }
- }
- )
-
- oas['components']['schemas'].update({
- 'tilematrixsetlink': {
- 'type': 'object',
- 'required': ['tileMatrixSet'],
- 'properties': {
- 'tileMatrixSet': {
- 'type': 'string'
- },
- 'tileMatrixSetURI': {
- 'type': 'string'
- }
- }
- },
- 'tiles': {
- 'type': 'object',
- 'required': [
- 'tileMatrixSetLinks',
- 'links'
- ],
- 'properties': {
- 'tileMatrixSetLinks': {
- 'type': 'array',
- 'items': {
- '$ref': '#/components/schemas/tilematrixsetlink' # noqa
- }
- },
- 'links': {
- 'type': 'array',
- 'items': {'$ref': f"{OPENAPI_YAML['oapit']}#/components/schemas/link"} # noqa
- }
- }
- }
- }
- )
-
- tiles_path = f'{collection_name_path}/tiles'
-
- paths[tiles_path] = {
- 'get': {
- 'summary': f'Fetch a {title} tiles description',
- 'description': desc,
- 'tags': [name],
- 'operationId': f'describe{name.capitalize()}Tiles',
- 'parameters': [
- items_f,
- # items_l TODO: is this useful?
- ],
- 'responses': {
- '200': {'$ref': '#/components/responses/Tiles'},
- '400': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/InvalidParameter"}, # noqa
- '404': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/NotFound"}, # noqa
- '500': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/ServerError"} # noqa
- }
- }
- }
+ if cfg['server'].get('admin', False):
+ schema_dict = get_config_schema()
+ oas['definitions'] = schema_dict['definitions']
+ LOGGER.debug('Adding admin endpoints')
+ oas['paths'].update(get_admin(cfg))
- tiles_data_path = f'{collection_name_path}/tiles/{{tileMatrixSetId}}/{{tileMatrix}}/{{tileRow}}/{{tileCol}}' # noqa
-
- paths[tiles_data_path] = {
- 'get': {
- 'summary': f'Get a {title} tile',
- 'description': desc,
- 'tags': [name],
- 'operationId': f'get{name.capitalize()}Tiles',
- 'parameters': [
- {'$ref': f"{OPENAPI_YAML['oapit']}#/components/parameters/tileMatrixSetId"}, # noqa
- {'$ref': f"{OPENAPI_YAML['oapit']}#/components/parameters/tileMatrix"}, # noqa
- {'$ref': f"{OPENAPI_YAML['oapit']}#/components/parameters/tileRow"}, # noqa
- {'$ref': f"{OPENAPI_YAML['oapit']}#/components/parameters/tileCol"}, # noqa
- {
- 'name': 'f',
- 'in': 'query',
- 'description': 'The optional f parameter indicates the output format which the server shall provide as part of the response document.', # noqa
- 'required': False,
- 'schema': {
- 'type': 'string',
- 'enum': [tp.format_type],
- 'default': tp.format_type
- },
- 'style': 'form',
- 'explode': False
- }
- ],
- 'responses': {
- '400': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/InvalidParameter"}, # noqa
- '404': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/NotFound"}, # noqa
- '500': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/ServerError"} # noqa
- }
- }
- }
- mimetype = tile_extension['format']['mimetype']
- paths[tiles_data_path]['get']['responses']['200'] = {
- 'description': 'successful operation',
- 'content': {
- mimetype: {
- 'schema': {
- 'type': 'string',
- 'format': 'binary'
- }
- }
- }
- }
+ return oas
- LOGGER.debug('setting up edr endpoints')
- edr_extension = filter_providers_by_type(
- collections[k]['providers'], 'edr')
-
- if edr_extension:
- ep = load_plugin('provider', edr_extension)
-
- edr_query_endpoints = []
-
- for qt in ep.get_query_types():
- edr_query_endpoints.append({
- 'path': f'{collection_name_path}/{qt}',
- 'qt': qt,
- 'op_id': f'query{qt.capitalize()}{k.capitalize()}'
- })
- if ep.instances:
- edr_query_endpoints.append({
- 'path': f'{collection_name_path}/instances/{{instanceId}}/{qt}', # noqa
- 'qt': qt,
- 'op_id': f'query{qt.capitalize()}Instance{k.capitalize()}' # noqa
- })
-
- for eqe in edr_query_endpoints:
- if eqe['qt'] == 'cube':
- spatial_parameter = 'bbox'
- else:
- spatial_parameter = f"{eqe['qt']}Coords"
- paths[eqe['path']] = {
- 'get': {
- 'summary': f"query {v['description']} by {eqe['qt']}", # noqa
- 'description': v['description'],
- 'tags': [k],
- 'operationId': eqe['op_id'],
- 'parameters': [
- {'$ref': f"{OPENAPI_YAML['oaedr']}/parameters/{spatial_parameter}.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': {
- '200': {
- 'description': 'Response',
- 'content': {
- 'application/prs.coverage+json': {
- 'schema': {
- '$ref': f"{OPENAPI_YAML['oaedr']}/schemas/coverageJSON.yaml" # noqa
- }
- }
- }
- }
- }
- }
- }
- LOGGER.debug('setting up maps endpoints')
- map_extension = filter_providers_by_type(
- collections[k]['providers'], 'map')
-
- if map_extension:
- mp = load_plugin('provider', map_extension)
-
- map_f = deepcopy(oas['components']['parameters']['f'])
- map_f['schema']['enum'] = [map_extension['format']['name']]
- map_f['schema']['default'] = map_extension['format']['name']
-
- pth = f'/collections/{k}/map'
- paths[pth] = {
- 'get': {
- 'summary': 'Get map',
- 'description': f"{v['description']} map",
- 'tags': [k],
- 'operationId': 'getMap',
- 'parameters': [
- {'$ref': '#/components/parameters/bbox'},
- {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/parameters/datetime"}, # noqa
- {
- 'name': 'width',
- 'in': 'query',
- 'description': 'Response image width',
- 'required': False,
- 'schema': {
- 'type': 'integer',
- },
- 'style': 'form',
- 'explode': False
- },
- {
- 'name': 'height',
- 'in': 'query',
- 'description': 'Response image height',
- 'required': False,
- 'schema': {
- 'type': 'integer',
- },
- 'style': 'form',
- 'explode': False
- },
- {
- 'name': 'transparent',
- 'in': 'query',
- 'description': 'Background transparency of map (default=true).', # noqa
- 'required': False,
- 'schema': {
- 'type': 'boolean',
- 'default': True,
- },
- 'style': 'form',
- 'explode': False
- },
- {'$ref': '#/components/parameters/bbox-crs-epsg'},
- map_f
- ],
- 'responses': {
- '200': {
- 'description': 'Response',
- 'content': {
- 'application/json': {}
- }
- },
- '400': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/InvalidParameter"}, # noqa
- '500': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/ServerError"}, # noqa
- }
- }
- }
- if mp.time_field is not None:
- paths[pth]['get']['parameters'].append(
- {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/parameters/datetime"}) # noqa
-
- LOGGER.debug('setting up STAC')
- stac_collections = filter_dict_by_key_value(cfg['resources'],
- 'type', 'stac-collection')
- if stac_collections:
- paths['/stac'] = {
- 'get': {
- 'summary': 'SpatioTemporal Asset Catalog',
- 'description': 'SpatioTemporal Asset Catalog',
- 'tags': ['stac'],
- 'operationId': 'getStacCatalog',
- 'parameters': [],
- 'responses': {
- '200': {'$ref': '#/components/responses/200'},
- 'default': {'$ref': '#/components/responses/default'}
- }
- }
- }
-
- process_manager = get_manager(cfg)
+def get_oas_30_parameters(cfg: dict, locale_: str):
+ server_locales = l10n.get_locales(cfg)
- if len(process_manager.processes) > 0:
- paths['/processes'] = {
- 'get': {
- 'summary': 'Processes',
- 'description': 'Processes',
- 'tags': ['server'],
- 'operationId': 'getProcesses',
- 'parameters': [
- {'$ref': '#/components/parameters/f'}
- ],
- 'responses': {
- '200': {'$ref': f"{OPENAPI_YAML['oapip']}/responses/ProcessList.yaml"}, # noqa
- 'default': {'$ref': '#/components/responses/default'}
- }
+ oas_30_parameters = {
+ 'f': {
+ 'name': 'f',
+ 'in': 'query',
+ 'description': 'The optional f parameter indicates the output format which the server shall provide as part of the response document. The default format is GeoJSON.', # noqa
+ 'required': False,
+ 'schema': {
+ 'type': 'string',
+ 'enum': ['json', 'html', 'jsonld'],
+ 'default': 'json'
+ },
+ 'style': 'form',
+ 'explode': False
+ },
+ 'lang': {
+ 'name': 'lang',
+ 'in': 'query',
+ 'description': 'The optional lang parameter instructs the server return a response in a certain language, if supported. If the language is not among the available values, the Accept-Language header language will be used if it is supported. If the header is missing, the default server language is used. Note that providers may only support a single language (or often no language at all), that can be different from the server language. Language strings can be written in a complex (e.g. "fr-CA,fr;q=0.9,en-US;q=0.8,en;q=0.7"), simple (e.g. "de") or locale-like (e.g. "de-CH" or "fr_BE") fashion.', # noqa
+ 'required': False,
+ 'schema': {
+ 'type': 'string',
+ 'enum': [l10n.locale2str(sl) for sl in server_locales],
+ 'default': l10n.locale2str(locale_)
}
- }
- LOGGER.debug('setting up processes')
-
- for k, v in process_manager.processes.items():
- if k.startswith('_'):
- LOGGER.debug(f'Skipping hidden layer: {k}')
- continue
- name = l10n.translate(k, locale_)
- p = process_manager.get_processor(k)
- md_desc = l10n.translate(p.metadata['description'], locale_)
- process_name_path = f'/processes/{name}'
- tag = {
- 'name': name,
- 'description': md_desc, # noqa
- 'externalDocs': {}
+ },
+ 'skipGeometry': {
+ 'name': 'skipGeometry',
+ 'in': 'query',
+ 'description': 'This option can be used to skip response geometries for each feature.', # noqa
+ 'required': False,
+ 'style': 'form',
+ 'explode': False,
+ 'schema': {
+ 'type': 'boolean',
+ 'default': False
}
- for link in p.metadata.get('links', []):
- if link['type'] == 'information':
- translated_link = l10n.translate(link, locale_)
- tag['externalDocs']['description'] = translated_link[
- 'type']
- tag['externalDocs']['url'] = translated_link['url']
- break
- if len(tag['externalDocs']) == 0:
- del tag['externalDocs']
-
- oas['tags'].append(tag)
-
- paths[process_name_path] = {
- 'get': {
- 'summary': 'Get process metadata',
- 'description': md_desc,
- 'tags': [name],
- 'operationId': f'describe{name.capitalize()}Process',
- 'parameters': [
- {'$ref': '#/components/parameters/f'}
- ],
- 'responses': {
- '200': {'$ref': '#/components/responses/200'},
- 'default': {'$ref': '#/components/responses/default'}
- }
- }
+ },
+ 'crs': {
+ 'name': 'crs',
+ 'in': 'query',
+ 'description': 'Indicates the coordinate reference system for the results.', # noqa
+ 'style': 'form',
+ 'required': False,
+ 'explode': False,
+ 'schema': {
+ 'format': 'uri',
+ 'type': 'string'
}
-
- paths[f'{process_name_path}/execution'] = {
- 'post': {
- 'summary': f"Process {l10n.translate(p.metadata['title'], locale_)} execution", # noqa
- 'description': md_desc,
- 'tags': [name],
- 'operationId': f'execute{name.capitalize()}Job',
- 'responses': {
- '200': {'$ref': '#/components/responses/200'},
- '201': {'$ref': f"{OPENAPI_YAML['oapip']}/responses/ExecuteAsync.yaml"}, # noqa
- '404': {'$ref': f"{OPENAPI_YAML['oapip']}/responses/NotFound.yaml"}, # noqa
- '500': {'$ref': f"{OPENAPI_YAML['oapip']}/responses/ServerError.yaml"}, # noqa
- 'default': {'$ref': '#/components/responses/default'}
- },
- 'requestBody': {
- 'description': 'Mandatory execute request JSON',
- 'required': True,
- 'content': {
- 'application/json': {
- 'schema': {
- '$ref': f"{OPENAPI_YAML['oapip']}/schemas/execute.yaml" # noqa
- }
- }
- }
- }
+ },
+ 'bbox': {
+ 'name': 'bbox',
+ 'in': 'query',
+ 'description': 'Only features that have a geometry that intersects the bounding box are selected.' # noqa
+ 'The bounding box is provided as four or six numbers, depending on whether the ' # noqa
+ 'coordinate reference system includes a vertical axis (height or depth).', # noqa
+ 'required': False,
+ 'style': 'form',
+ 'explode': False,
+ 'schema': {
+ 'type': 'array',
+ 'minItems': 4,
+ 'maxItems': 6,
+ 'items': {
+ 'type': 'number'
}
}
- if 'example' in p.metadata:
- paths[f'{process_name_path}/execution']['post']['requestBody']['content']['application/json']['example'] = p.metadata['example'] # noqa
-
- name_in_path = {
- 'name': 'jobId',
- 'in': 'path',
- 'description': 'job identifier',
- 'required': True,
- 'schema': {
- 'type': 'string'
- }
+ },
+ 'bbox-crs': {
+ 'name': 'bbox-crs',
+ 'in': 'query',
+ 'description': 'Indicates the coordinate reference system for the given bbox coordinates.', # noqa
+ 'style': 'form',
+ 'required': False,
+ 'explode': False,
+ 'schema': {
+ 'format': 'uri',
+ 'type': 'string'
}
-
- paths['/jobs'] = {
- 'get': {
- 'summary': 'Retrieve jobs list',
- 'description': 'Retrieve a list of jobs',
- 'tags': ['jobs'],
- 'operationId': 'getJobs',
- 'responses': {
- '200': {'$ref': '#/components/responses/200'},
- '404': {'$ref': f"{OPENAPI_YAML['oapip']}/responses/NotFound.yaml"}, # noqa
- 'default': {'$ref': '#/components/responses/default'}
- }
+ },
+ # FIXME: This is not compatible with the bbox-crs definition in
+ # OGCAPI Features Part 2!
+ # We need to change the mapscript provider and
+ # get_collection_map() method in the API!
+ # So this is for de map-provider only.
+ 'bbox-crs-epsg': {
+ 'name': 'bbox-crs',
+ 'in': 'query',
+ 'description': 'Indicates the EPSG for the given bbox coordinates.', # noqa
+ 'required': False,
+ 'style': 'form',
+ 'explode': False,
+ 'schema': {
+ 'type': 'integer',
+ 'default': 4326
}
- }
-
- paths['/jobs/{jobId}'] = {
- 'get': {
- 'summary': 'Retrieve job details',
- 'description': 'Retrieve job details',
- 'tags': ['jobs'],
- 'parameters': [
- name_in_path,
- {'$ref': '#/components/parameters/f'}
- ],
- 'operationId': 'getJob',
- 'responses': {
- '200': {'$ref': '#/components/responses/200'},
- '404': {'$ref': f"{OPENAPI_YAML['oapip']}/responses/NotFound.yaml"}, # noqa
- 'default': {'$ref': '#/components/responses/default'} # noqa
- }
+ },
+ 'offset': {
+ 'name': 'offset',
+ 'in': 'query',
+ 'description': 'The optional offset parameter indicates the index within the result set from which the server shall begin presenting results in the response document. The first element has an index of 0 (default).', # noqa
+ 'required': False,
+ 'schema': {
+ 'type': 'integer',
+ 'minimum': 0,
+ 'default': 0
},
- 'delete': {
- 'summary': 'Cancel / delete job',
- 'description': 'Cancel / delete job',
- 'tags': ['jobs'],
- 'parameters': [
- name_in_path
- ],
- 'operationId': 'deleteJob',
- 'responses': {
- '204': {'$ref': '#/components/responses/204'},
- '404': {'$ref': f"{OPENAPI_YAML['oapip']}/responses/NotFound.yaml"}, # noqa
- 'default': {'$ref': '#/components/responses/default'} # noqa
- }
+ 'style': 'form',
+ 'explode': False
+ },
+ 'vendorSpecificParameters': {
+ 'name': 'vendorSpecificParameters',
+ 'in': 'query',
+ 'description': 'Additional "free-form" parameters that are not explicitly defined', # noqa
+ 'schema': {
+ 'type': 'object',
+ 'additionalProperties': True
},
+ 'style': 'form'
+ },
+ 'resourceId': {
+ 'name': 'resourceId',
+ 'in': 'path',
+ 'description': 'Configuration resource identifier',
+ 'required': True,
+ 'schema': {
+ 'type': 'string'
+ }
}
+ }
+ if len(list(cfg['resources'].keys())) > 0:
+ oas_30_parameters['resourceId']['schema']['default'] = list(
+ cfg['resources'].keys()
+ )[0]
+ return oas_30_parameters
- paths['/jobs/{jobId}/results'] = {
- 'get': {
- 'summary': 'Retrieve job results',
- 'description': 'Retrive job resiults',
- 'tags': ['jobs'],
- 'parameters': [
- name_in_path,
- {'$ref': '#/components/parameters/f'}
- ],
- 'operationId': 'getJobResults',
- 'responses': {
- '200': {'$ref': '#/components/responses/200'},
- '404': {'$ref': f"{OPENAPI_YAML['oapip']}/responses/NotFound.yaml"}, # noqa
- 'default': {'$ref': '#/components/responses/default'} # noqa
- }
- }
- }
-
- tag = {
- 'name': 'jobs',
- 'description': 'Process jobs',
- }
- oas['tags'].insert(1, tag)
-
- oas['paths'] = paths
- if cfg['server'].get('admin', False):
- schema_dict = get_config_schema()
- oas['definitions'] = schema_dict['definitions']
- LOGGER.debug('Adding admin endpoints')
- oas['paths'].update(get_admin())
+def get_visible_collections(cfg: dict) -> dict:
+ collections = filter_dict_by_key_value(cfg['resources'],
+ 'type', 'collection')
- return oas
+ return {
+ k: v
+ for k, v in collections.items()
+ if v.get('visibility', 'default') != 'hidden'
+ }
def get_config_schema():
@@ -1332,12 +715,25 @@ def get_config_schema():
return yaml_load(fh2)
-def get_admin():
+def get_admin(cfg: dict) -> dict:
schema_dict = get_config_schema()
paths = {}
+ res_eg_key = next(iter(cfg['resources']))
+ res_eg = {
+ res_eg_key: cfg['resources'][res_eg_key]
+ }
+ if 'extents' in res_eg[res_eg_key]:
+ res_eg_eg_key = 'extents'
+ elif 'type' in res_eg[res_eg_key]:
+ res_eg_eg_key = 'type'
+
+ res_eg[res_eg_key]['patch_example'] = {
+ res_eg_eg_key: res_eg[res_eg_key][res_eg_eg_key]
+ }
+
paths['/admin/config'] = {
'get': {
'summary': 'Get admin configuration',
@@ -1350,6 +746,7 @@ def get_admin():
],
'responses': {
'200': {
+ 'description': 'Successful response',
'content': {
'application/json': {
'schema': schema_dict
@@ -1367,6 +764,7 @@ def get_admin():
'description': 'Updates admin configuration',
'content': {
'application/json': {
+ 'example': cfg,
'schema': schema_dict
}
},
@@ -1387,6 +785,7 @@ def get_admin():
'description': 'Updates admin configuration',
'content': {
'application/json': {
+ 'example': {'metadata': cfg['metadata']},
'schema': schema_dict
}
},
@@ -1411,6 +810,7 @@ def get_admin():
],
'responses': {
'200': {
+ 'description': 'Successful response',
'content': {
'application/json': {
'schema': schema_dict['properties']['resources']['patternProperties']['^.*$'] # noqa
@@ -1428,6 +828,7 @@ def get_admin():
'description': 'Adds resource to configuration',
'content': {
'application/json': {
+ 'example': {'new-collection': cfg['resources'][res_eg_key]}, # noqa
'schema': schema_dict['properties']['resources']['patternProperties']['^.*$'] # noqa
}
},
@@ -1453,6 +854,7 @@ def get_admin():
],
'responses': {
'200': {
+ 'description': 'Successful response',
'content': {
'application/json': {
'schema': schema_dict['properties']['resources']['patternProperties']['^.*$'] # noqa
@@ -1473,6 +875,7 @@ def get_admin():
'description': 'Updates admin configuration resource',
'content': {
'application/json': {
+ 'example': res_eg[res_eg_key],
'schema': schema_dict['properties']['resources']['patternProperties']['^.*$'] # noqa
}
},
@@ -1496,6 +899,7 @@ def get_admin():
'description': 'Updates admin configuration resource',
'content': {
'application/json': {
+ 'example': res_eg[res_eg_key]['patch_example'],
'schema': schema_dict['properties']['resources']['patternProperties']['^.*$'] # noqa
}
},
@@ -1526,23 +930,27 @@ def get_admin():
return paths
-def get_oas(cfg, version='3.0'):
+def get_oas(cfg: dict, fail_on_invalid_collection: bool = True,
+ version='3.0') -> dict:
"""
Stub to generate OpenAPI Document
- :param cfg: configuration object
+ :param cfg: `dict` configuration
+ :param fail_on_invalid_collection: `bool` of whether to fail on an
+ invalid collection
:param version: version of OpenAPI (default 3.0)
- :returns: OpenAPI definition YAML dict
+ :returns: `dict` of OpenAPI definition
"""
if version == '3.0':
- return get_oas_30(cfg)
+ return get_oas_30(
+ cfg, fail_on_invalid_collection=fail_on_invalid_collection)
else:
raise RuntimeError('OpenAPI version not supported')
-def validate_openapi_document(instance_dict):
+def validate_openapi_document(instance_dict: dict) -> bool:
"""
Validate an OpenAPI document against the OpenAPI schema
@@ -1562,27 +970,36 @@ def validate_openapi_document(instance_dict):
def generate_openapi_document(cfg_file: Union[Path, io.TextIOWrapper],
- output_format: OAPIFormat):
+ output_format: OAPIFormat,
+ fail_on_invalid_collection: bool = True) -> str:
"""
Generate an OpenAPI document from the configuration file
- :param cfg_file: configuration Path instance
+ :param cfg_file: configuration Path instance (`str` of filepath
+ or parsed `dict`)
:param output_format: output format for OpenAPI document
+ :param fail_on_invalid_collection: `bool` of whether to fail on an
+ invalid collection
- :returns: content of the OpenAPI document in the output
- format requested
+ :returns: `str` of the OpenAPI document in the output format requested
"""
+
+ LOGGER.debug(f'Loading configuration {cfg_file}')
+
if isinstance(cfg_file, Path):
with cfg_file.open(mode="r") as cf:
s = yaml_load(cf)
else:
s = yaml_load(cfg_file)
+
pretty_print = s['server'].get('pretty_print', False)
+ oas = get_oas(s, fail_on_invalid_collection=fail_on_invalid_collection)
+
if output_format == 'yaml':
- content = yaml.safe_dump(get_oas(s), default_flow_style=False)
+ content = yaml.safe_dump(oas, default_flow_style=False)
else:
- content = to_json(get_oas(s), pretty=pretty_print)
+ content = to_json(oas, pretty=pretty_print)
return content
@@ -1595,6 +1012,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)
@@ -1613,17 +1041,21 @@ def openapi():
@click.command()
@click.pass_context
@click.argument('config_file', type=click.File(encoding='utf-8'))
+@click.option('--fail-on-invalid-collection/--no-fail-on-invalid-collection',
+ '-fic', default=True, help='Fail on invalid collection')
@click.option('--format', '-f', 'format_', type=click.Choice(['json', 'yaml']),
default='yaml', help='output format (json|yaml)')
@click.option('--output-file', '-of', type=click.File('w', encoding='utf-8'),
help='Name of output file')
-def generate(ctx, config_file, output_file, format_='yaml'):
+def generate(ctx, config_file, output_file, format_='yaml',
+ fail_on_invalid_collection=True):
"""Generate OpenAPI Document"""
if config_file is None:
raise click.ClickException('--config/-c required')
- content = generate_openapi_document(config_file, format_)
+ content = generate_openapi_document(
+ config_file, format_, fail_on_invalid_collection)
if output_file is None:
click.echo(content)
diff --git a/pygeoapi/plugin.py b/pygeoapi/plugin.py
index 312017a12..08324cf64 100644
--- a/pygeoapi/plugin.py
+++ b/pygeoapi/plugin.py
@@ -2,7 +2,7 @@
#
# Authors: Tom Kralidis
#
-# Copyright (c) 2023 Tom Kralidis
+# Copyright (c) 2024 Tom Kralidis
#
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation
@@ -51,16 +51,22 @@
'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-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',
+ 'SensorThingsEDR': 'pygeoapi.provider.sensorthings_edr.SensorThingsEDRProvider', # noqa: E501
'SQLiteGPKG': 'pygeoapi.provider.sqlite.SQLiteGPKGProvider',
'Socrata': 'pygeoapi.provider.socrata.SODAServiceProvider',
+ 'TinyDB': 'pygeoapi.provider.tinydb_.TinyDBProvider',
'TinyDBCatalogue': 'pygeoapi.provider.tinydb_.TinyDBCatalogueProvider',
'WMSFacade': 'pygeoapi.provider.wms_facade.WMSFacadeProvider',
+ 'WMTSFacade': 'pygeoapi.provider.wmts_facade.WMTSFacadeProvider',
'xarray': 'pygeoapi.provider.xarray_.XarrayProvider',
'xarray-edr': 'pygeoapi.provider.xarray_edr.XarrayEDRProvider'
},
@@ -68,12 +74,15 @@
'CSV': 'pygeoapi.formatter.csv_.CSVFormatter'
},
'process': {
- 'HelloWorld': 'pygeoapi.process.hello_world.HelloWorldProcessor'
+ 'HelloWorld': 'pygeoapi.process.hello_world.HelloWorldProcessor',
+ 'ShapelyFunctions': 'pygeoapi.process.shapely_functions.ShapelyFunctionsProcessor', # noqa: E501
+ 'Echo': 'pygeoapi.process.echo.EchoProcessor'
},
'process_manager': {
'Dummy': 'pygeoapi.process.manager.dummy.DummyManager',
'MongoDB': 'pygeoapi.process.manager.mongodb_.MongoDBManager',
- 'TinyDB': 'pygeoapi.process.manager.tinydb_.TinyDBManager'
+ 'TinyDB': 'pygeoapi.process.manager.tinydb_.TinyDBManager',
+ 'PostgreSQL': 'pygeoapi.process.manager.postgresql.PostgreSQLManager'
}
}
diff --git a/pygeoapi/process/base.py b/pygeoapi/process/base.py
index 2403d1712..87c05a3cd 100644
--- a/pygeoapi/process/base.py
+++ b/pygeoapi/process/base.py
@@ -1,8 +1,10 @@
# =================================================================
#
# Authors: Tom Kralidis
+# Francesco Martinelli
#
# Copyright (c) 2022 Tom Kralidis
+# Copyright (c) 2024 Francesco Martinelli
#
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation
@@ -28,7 +30,7 @@
# =================================================================
import logging
-from typing import Any, Tuple
+from typing import Any, Tuple, Optional
from pygeoapi.error import GenericError
@@ -47,17 +49,40 @@ def __init__(self, processor_def: dict, process_metadata: dict):
:returns: pygeoapi.processor.base.BaseProvider
"""
+
self.name = processor_def['name']
self.metadata = process_metadata
+ self.supports_outputs = False
+
+ def set_job_id(self, job_id: str) -> None:
+ """
+ Set the job_id within the processor
+ To be implemented by derived classes where required.
+
+ :param job_id: the job_id assigned to the request by the Manager.
+ The function should be called by the Manager upon
+ assigning the job_id. The job_id is intended to be used
+ by derived classes, e.g. to write temporary files where
+ filenames contains the string job_id.
- def execute(self, data: dict) -> Tuple[str, Any]:
+ :returns: `None`
+ """
+
+ pass
+
+ def execute(self, data: dict, outputs: Optional[dict] = None
+ ) -> Tuple[str, Any]:
"""
execute the process
:param data: Dict with the input data that the process needs in order
to execute
-
+ :param outputs: `dict` or `list` to 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`.
:returns: tuple of MIME type and process response
+ (string or bytes, or dict)
"""
raise NotImplementedError()
diff --git a/pygeoapi/process/echo.py b/pygeoapi/process/echo.py
index 8c3f04a38..eff009087 100644
--- a/pygeoapi/process/echo.py
+++ b/pygeoapi/process/echo.py
@@ -1,8 +1,10 @@
# =================================================================
#
# Authors: Alexander Pilz
+# Tom Kralidis
#
# Copyright (c) 2023 Alexander Pilz
+# Copyright (c) 2023 Tom Kralidis
#
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation
@@ -26,6 +28,7 @@
# OTHER DEALINGS IN THE SOFTWARE.
#
# =================================================================
+
import logging
import time
@@ -35,40 +38,40 @@
#: Process metadata and description
PROCESS_METADATA = {
- "id": "echo",
- "title": "Echo Process",
- "description": "Testable Echo process.",
- "version": "1.0.0",
- "jobControlOptions": [
- "async-execute",
- "sync-execute"
+ 'id': 'echo',
+ 'title': 'Echo Process',
+ 'description': 'Testable Echo process.',
+ 'version': '1.0.0',
+ 'jobControlOptions': [
+ 'async-execute',
+ 'sync-execute'
],
- "outputTransmission": [
- "value",
- "reference"
+ 'outputTransmission': [
+ 'value',
+ 'reference'
],
- "inputs": {
- "echoInput": {
- "title": "Echo value",
- "description": "Value to be echoed back.",
- "minOccurs": 1,
- "maxOccurs": 1,
- "schema": {
- "type": "string",
- "enum": [
- "Echo",
- "Test",
- "42"
+ 'inputs': {
+ 'echoInput': {
+ 'title': 'Echo value',
+ 'description': 'Value to be echoed back.',
+ 'minOccurs': 1,
+ 'maxOccurs': 1,
+ 'schema': {
+ 'type': 'string',
+ 'enum': [
+ 'Echo',
+ 'Test',
+ '42'
]
}},
- "pause": {
- "title": "Pause value",
- "description": "Value to control the processing time.",
- "minOccurs": 1,
- "maxOccurs": 1,
- "schema": {
- "type": "float",
- "enum": [
+ 'pause': {
+ 'title': 'Pause value',
+ 'description': 'Value to control the processing time.',
+ 'minOccurs': 1,
+ 'maxOccurs': 1,
+ 'schema': {
+ 'type': 'number',
+ 'enum': [
5.5,
10.25,
42.0
@@ -76,14 +79,14 @@
}
}
},
- "outputs": {
- "echoOutput": {
- "schema": {
- "type": "string"
+ 'outputs': {
+ 'echoOutput': {
+ 'schema': {
+ 'type': 'string'
}
}
},
- "links": [{
+ 'links': [{
'type': 'text/html',
'rel': 'about',
'title': 'information',
@@ -99,7 +102,7 @@
}
-class echoProcessor(BaseProcessor):
+class EchoProcessor(BaseProcessor):
"""Echo Processor example"""
def __init__(self, processor_def):
"""
@@ -107,33 +110,35 @@ def __init__(self, processor_def):
:param processor_def: provider definition
- :returns: pygeoapi.process.echo.echoProcessor
+ :returns: pygeoapi.process.echo.EchoProcessor
"""
super().__init__(processor_def, PROCESS_METADATA)
- def execute(self, data):
+ def execute(self, data, outputs=None):
mimetype = 'application/json'
- echo = data.get('echoInput', None)
- pause = data.get('pause', None)
+ echo = data.get('echoInput')
+ pause = data.get('pause')
if echo is None:
raise ProcessorExecuteError(
'Cannot run process without echo value')
+
if not isinstance(echo, str):
raise ProcessorExecuteError(
- 'Cannot run process with echo not of type String')
+ 'Cannot run process with echo not of type string')
outputs = {
'id': 'echoOutput',
'value': echo
}
+
if pause is not None and isinstance(pause, float):
time.sleep(pause)
return mimetype, outputs
def __repr__(self):
- return ' {}'.format(self.name)
+ return f' {self.name}'
diff --git a/pygeoapi/process/hello_world.py b/pygeoapi/process/hello_world.py
index 9d1962916..4577929aa 100644
--- a/pygeoapi/process/hello_world.py
+++ b/pygeoapi/process/hello_world.py
@@ -1,8 +1,10 @@
# =================================================================
#
# Authors: Tom Kralidis
+# Francesco Martinelli
#
# Copyright (c) 2022 Tom Kralidis
+# Copyright (c) 2024 Francesco Martinelli
#
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation
@@ -69,7 +71,6 @@
},
'minOccurs': 1,
'maxOccurs': 1,
- 'metadata': None, # TODO how to use?
'keywords': ['full name', 'personal']
},
'message': {
@@ -80,7 +81,6 @@
},
'minOccurs': 0,
'maxOccurs': 1,
- 'metadata': None,
'keywords': ['message']
}
},
@@ -117,9 +117,9 @@ def __init__(self, processor_def):
"""
super().__init__(processor_def, PROCESS_METADATA)
+ self.supports_outputs = True
- def execute(self, data):
-
+ def execute(self, data, outputs=None):
mimetype = 'application/json'
name = data.get('name')
@@ -129,12 +129,14 @@ def execute(self, data):
message = data.get('message', '')
value = f'Hello {name}! {message}'.strip()
- outputs = {
- 'id': 'echo',
- 'value': value
- }
+ produced_outputs = {}
+ if not bool(outputs) or 'echo' in outputs:
+ produced_outputs = {
+ 'id': 'echo',
+ 'value': value
+ }
- return mimetype, outputs
+ return mimetype, produced_outputs
def __repr__(self):
return f' {self.name}'
diff --git a/pygeoapi/process/manager/__init__.py b/pygeoapi/process/manager/__init__.py
index 2019a54b7..565d98e85 100644
--- a/pygeoapi/process/manager/__init__.py
+++ b/pygeoapi/process/manager/__init__.py
@@ -29,35 +29,4 @@
#
# =================================================================
-import logging
-from typing import Dict
-
-from pygeoapi.plugin import load_plugin
-from pygeoapi.process.manager.base import BaseManager
-
-LOGGER = logging.getLogger(__name__)
-
-
-def get_manager(config: Dict) -> BaseManager:
- """Instantiate process manager from the supplied configuration.
-
- :param config: pygeoapi configuration
-
- :returns: The pygeoapi process manager object
- """
- manager_conf = config.get('server', {}).get(
- 'manager',
- {
- 'name': 'Dummy',
- 'connection': None,
- 'output_dir': None
- }
- )
- processes_conf = {}
- for id_, resource_conf in config.get('resources', {}).items():
- if resource_conf.get('type') == 'process':
- processes_conf[id_] = resource_conf
- manager_conf['processes'] = processes_conf
- if manager_conf.get('name') == 'Dummy':
- LOGGER.info('Starting dummy manager')
- return load_plugin('process_manager', manager_conf)
+"""OGC process manager package"""
diff --git a/pygeoapi/process/manager/base.py b/pygeoapi/process/manager/base.py
index d0d782470..d3d3285b0 100644
--- a/pygeoapi/process/manager/base.py
+++ b/pygeoapi/process/manager/base.py
@@ -2,9 +2,11 @@
#
# Authors: Tom Kralidis
# Ricardo Garcia Silva
+# Francesco Martinelli
#
-# Copyright (c) 2022 Tom Kralidis
+# Copyright (c) 2024 Tom Kralidis
# (c) 2023 Ricardo Garcia Silva
+# (c) 2024 Francesco Martinelli
#
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation
@@ -30,7 +32,6 @@
# =================================================================
import collections
-from datetime import datetime
import json
import logging
from multiprocessing import dummy
@@ -38,6 +39,8 @@
from typing import Any, Dict, Tuple, Optional, OrderedDict
import uuid
+import requests
+
from pygeoapi.plugin import load_plugin
from pygeoapi.process.base import (
BaseProcessor,
@@ -46,10 +49,12 @@
UnknownProcessError,
)
from pygeoapi.util import (
- DATETIME_FORMAT,
+ get_current_datetime,
JobStatus,
ProcessExecutionMode,
RequestedProcessExecutionMode,
+ RequestedResponse,
+ Subscriber
)
LOGGER = logging.getLogger(__name__)
@@ -70,6 +75,7 @@ def __init__(self, manager_def: dict):
self.name = manager_def['name']
self.is_async = False
+ self.supports_subscribing = False
self.connection = manager_def.get('connection')
self.output_dir = manager_def.get('output_dir')
@@ -85,12 +91,12 @@ def __init__(self, manager_def: dict):
for id_, process_conf in manager_def.get('processes', {}).items():
self.processes[id_] = dict(process_conf)
- def get_processor(self, process_id: str) -> Optional[BaseProcessor]:
+ def get_processor(self, process_id: str) -> BaseProcessor:
"""Instantiate a processor.
:param process_id: Identifier of the process
- :raise UnknownProcessError: if the processor cannot be created
+ :raises UnknownProcessError: if the processor cannot be created
:returns: instance of the processor
"""
@@ -101,14 +107,21 @@ def get_processor(self, process_id: str) -> Optional[BaseProcessor]:
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()
@@ -142,8 +155,8 @@ def get_job(self, job_id: str) -> dict:
:param job_id: job identifier
- :raises: JobNotFoundError: if the job_id does not correspond to a
- known job
+ :raises JobNotFoundError: if the job_id does not correspond to a
+ known job
:returns: `dict` of job result
"""
@@ -155,10 +168,10 @@ def get_job_result(self, job_id: str) -> Tuple[str, Any]:
:param job_id: job identifier
- :raises: JobNotFoundError: if the job_id does not correspond to a
- known job
- :raises: JobResultNotFoundError: if the job-related result cannot
- be returned
+ :raises JobNotFoundError: if the job_id does not correspond to a
+ known job
+ :raises JobResultNotFoundError: if the job-related result cannot
+ be returned
:returns: `tuple` of mimetype and raw output
"""
@@ -170,15 +183,19 @@ def delete_job(self, job_id: str) -> bool:
:param job_id: job identifier
- :raises: JobNotFoundError: if the job_id does not correspond to a
- known job
+ :raises JobNotFoundError: if the job_id does not correspond to a
+ known job
:returns: `bool` of status result
"""
raise JobNotFoundError()
def _execute_handler_async(self, p: BaseProcessor, job_id: str,
- data_dict: dict) -> Tuple[str, None, JobStatus]:
+ 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
thread using `multiprocessing.dummy`
@@ -188,19 +205,35 @@ def _execute_handler_async(self, p: BaseProcessor, job_id: str,
:param p: `pygeoapi.process` object
:param job_id: job identifier
:param data_dict: `dict` of data parameters
+ :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)
- )
+
+ 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) -> Tuple[str, Any, JobStatus]:
+ 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
@@ -211,27 +244,28 @@ def _execute_handler_sync(self, p: BaseProcessor, job_id: str,
:param p: `pygeoapi.process` object
:param job_id: job identifier
:param data_dict: `dict` of data parameters
+ :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
"""
- process_id = p.metadata['id']
- current_status = JobStatus.accepted
+ extra_execute_parameters = {}
- job_metadata = {
- 'identifier': job_id,
- 'process_id': process_id,
- 'job_start_datetime': datetime.utcnow().strftime(
- DATETIME_FORMAT),
- 'job_end_datetime': None,
- 'status': current_status.value,
- 'location': None,
- 'mimetype': None,
- 'message': 'Job accepted and ready for execution',
- 'progress': 5
- }
+ # only pass requested_outputs if supported,
+ # otherwise this breaks existing processes
+ if p.supports_outputs:
+ extra_execute_parameters['outputs'] = requested_outputs
- self.add_job(job_metadata)
+ self._send_in_progress_notification(subscriber)
try:
if self.output_dir is not None:
@@ -241,9 +275,15 @@ def _execute_handler_sync(self, p: BaseProcessor, job_id: str,
job_filename = None
current_status = JobStatus.running
- jfmt, outputs = p.execute(data_dict)
+ jfmt, outputs = p.execute(data_dict, **extra_execute_parameters)
+
+ if requested_response == RequestedResponse.document.value:
+ outputs = {
+ 'outputs': [outputs]
+ }
self.update_job(job_id, {
+ 'updated': get_current_datetime(),
'status': current_status.value,
'message': 'Writing job output',
'progress': 95
@@ -251,7 +291,7 @@ def _execute_handler_sync(self, p: BaseProcessor, job_id: str,
if self.output_dir is not None:
LOGGER.debug(f'writing output to {job_filename}')
- if isinstance(outputs, dict):
+ if isinstance(outputs, (dict, list)):
mode = 'w'
data = json.dumps(outputs, sort_keys=True, indent=4)
encoding = 'utf-8'
@@ -265,8 +305,8 @@ def _execute_handler_sync(self, p: BaseProcessor, job_id: str,
current_status = JobStatus.successful
job_update_metadata = {
- 'job_end_datetime': datetime.utcnow().strftime(
- DATETIME_FORMAT),
+ 'finished': get_current_datetime(),
+ 'updated': get_current_datetime(),
'status': current_status.value,
'location': str(job_filename),
'mimetype': jfmt,
@@ -275,11 +315,12 @@ def _execute_handler_sync(self, p: BaseProcessor, job_id: str,
}
self.update_job(job_id, job_update_metadata)
+ self._send_success_notification(subscriber, outputs=outputs)
except Exception as err:
# TODO assess correct exception type and description to help users
# NOTE, the /results endpoint should return the error HTTP status
- # for jobs that failed, ths specification says that failing jobs
+ # for jobs that failed, the specification says that failing jobs
# must still be able to be retrieved with their error message
# intact, and the correct HTTP error status at the /results
# endpoint, even if the /result endpoint correctly returns the
@@ -289,22 +330,24 @@ def _execute_handler_sync(self, p: BaseProcessor, job_id: str,
current_status = JobStatus.failed
code = 'InvalidParameterValue'
outputs = {
+ 'type': code,
'code': code,
- 'description': 'Error updating job'
+ 'description': f'Error executing process: {err}'
}
- LOGGER.error(err)
+ LOGGER.exception(err)
job_metadata = {
- 'job_end_datetime': datetime.utcnow().strftime(
- DATETIME_FORMAT),
+ 'finished': get_current_datetime(),
+ 'updated': get_current_datetime(),
'status': current_status.value,
'location': None,
- 'mimetype': None,
+ 'mimetype': 'application/octet-stream',
'message': f'{code}: {outputs["description"]}'
}
jfmt = 'application/json'
self.update_job(job_id, job_metadata)
+ self._send_failed_notification(subscriber)
return jfmt, outputs, current_status
@@ -312,7 +355,10 @@ def execute_process(
self,
process_id: str,
data_dict: dict,
- execution_mode: Optional[RequestedProcessExecutionMode] = None
+ execution_mode: Optional[RequestedProcessExecutionMode] = None,
+ requested_outputs: Optional[dict] = 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
@@ -320,10 +366,21 @@ def execute_process(
:param process_id: process identifier
:param data_dict: `dict` of data parameters
:param execution_mode: `str` optionally specifying sync or async
- processing.
-
- :raises: UnknownProcessError if the input process_id does not
- correspond to a known process
+ processing.
+ :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
:returns: tuple of job_id, MIME type, response payload, status and
optionally additional HTTP headers to include in the final
response
@@ -331,6 +388,11 @@ def execute_process(
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(
'jobControlOptions', [])
@@ -363,11 +425,64 @@ def execute_process(
LOGGER.debug('Synchronous execution')
handler = self._execute_handler_sync
response_headers = None
+
+ # Add Job before returning any response.
+ current_status = JobStatus.accepted
+ job_metadata = {
+ 'type': 'process',
+ 'identifier': job_id,
+ 'process_id': process_id,
+ 'created': get_current_datetime(),
+ 'started': get_current_datetime(),
+ 'updated': get_current_datetime(),
+ 'finished': None,
+ 'status': current_status.value,
+ 'location': None,
+ 'mimetype': 'application/octet-stream',
+ 'message': 'Job accepted and ready for execution',
+ 'progress': 5
+ }
+ 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(processor, job_id, data_dict)
+ mime_type, outputs, status = handler(
+ processor,
+ job_id,
+ data_dict,
+ requested_outputs,
+ **extra_execute_handler_parameters)
+
return job_id, mime_type, outputs, status, response_headers
+ def _send_in_progress_notification(self, subscriber: Optional[Subscriber]):
+ if subscriber and subscriber.in_progress_uri:
+ response = requests.post(subscriber.in_progress_uri, json={})
+ LOGGER.debug(
+ f'In progress notification response: {response.status_code}'
+ )
+
+ def _send_success_notification(
+ self, subscriber: Optional[Subscriber], outputs: Any
+ ):
+ if subscriber:
+ response = requests.post(subscriber.success_uri, json=outputs)
+ LOGGER.debug(
+ f'Success notification response: {response.status_code}'
+ )
+
+ def _send_failed_notification(self, subscriber: Optional[Subscriber]):
+ if subscriber and subscriber.failed_uri:
+ response = requests.post(subscriber.failed_uri, json={})
+ LOGGER.debug(
+ f'Failed notification response: {response.status_code}'
+ )
+
def __repr__(self):
return f' {self.name}'
diff --git a/pygeoapi/process/manager/dummy.py b/pygeoapi/process/manager/dummy.py
index b27507b7d..6528f53a7 100644
--- a/pygeoapi/process/manager/dummy.py
+++ b/pygeoapi/process/manager/dummy.py
@@ -2,7 +2,7 @@
#
# Authors: Tom Kralidis
#
-# Copyright (c) 2022 Tom Kralidis
+# Copyright (c) 2024 Tom Kralidis
#
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation
@@ -33,8 +33,10 @@
from pygeoapi.process.manager.base import BaseManager
from pygeoapi.util import (
- RequestedProcessExecutionMode,
JobStatus,
+ RequestedProcessExecutionMode,
+ RequestedResponse,
+ Subscriber
)
LOGGER = logging.getLogger(__name__)
@@ -54,23 +56,30 @@ def __init__(self, manager_def: dict):
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,
process_id: str,
data_dict: dict,
- execution_mode: Optional[RequestedProcessExecutionMode] = None
+ execution_mode: Optional[RequestedProcessExecutionMode] = None,
+ requested_outputs: Optional[dict] = 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
@@ -78,9 +87,19 @@ def execute_process(
: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
"""
@@ -94,17 +113,27 @@ def execute_process(
LOGGER.debug('Dummy manager does not support asynchronous')
LOGGER.debug('Forcing synchronous execution')
+ 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
- except Exception:
+ self._send_success_notification(subscriber, outputs)
+ except Exception as err:
outputs = {
'code': 'InvalidParameterValue',
- 'description': 'Error updating job'
+ 'description': f'Error executing process: {err}'
}
current_status = JobStatus.failed
- LOGGER.exception('Process 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
diff --git a/pygeoapi/process/manager/mongodb_.py b/pygeoapi/process/manager/mongodb_.py
index 7e26aa770..44bce6dbe 100644
--- a/pygeoapi/process/manager/mongodb_.py
+++ b/pygeoapi/process/manager/mongodb_.py
@@ -32,6 +32,7 @@
from pymongo import MongoClient
+from pygeoapi.api import FORMAT_TYPES, F_JSON, F_JSONLD
from pygeoapi.process.base import (
JobNotFoundError,
JobResultNotFoundError,
@@ -45,6 +46,7 @@ class MongoDBManager(BaseManager):
def __init__(self, manager_def):
super().__init__(manager_def)
self.is_async = True
+ self.supports_subscribing = True
def _connect(self):
try:
@@ -69,7 +71,7 @@ def destroy(self):
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
@@ -79,7 +81,10 @@ def get_jobs(self, status=None):
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))
@@ -147,8 +152,16 @@ def get_job_result(self, job_id):
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:
diff --git a/pygeoapi/process/manager/postgresql.py b/pygeoapi/process/manager/postgresql.py
new file mode 100644
index 000000000..16d25ab8f
--- /dev/null
+++ b/pygeoapi/process/manager/postgresql.py
@@ -0,0 +1,309 @@
+# =================================================================
+#
+# Authors: Francesco Martinelli
+#
+# Copyright (c) 2024 Francesco Martinelli
+#
+# 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.
+#
+# =================================================================
+
+# Requires postgresql database structure.
+# Create the database:
+# e.g.
+# CREATE DATABASE test
+# WITH TEMPLATE = template0
+# ENCODING = 'UTF8'
+# LOCALE = 'en_US.UTF-8';
+# ALTER DATABASE test OWNER TO postgres;
+#
+# Import dump:
+# psql -U postgres -h 127.0.0.1 -p 5432 test <
+# tests/data/postgres_manager_full_structure.backup.sql
+
+import json
+import logging
+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,
+ ProcessorGenericError
+)
+from pygeoapi.process.manager.base import BaseManager
+from pygeoapi.provider.postgresql import get_engine, get_table_model
+from pygeoapi.util import JobStatus
+
+
+LOGGER = logging.getLogger(__name__)
+
+
+class PostgreSQLManager(BaseManager):
+ """PostgreSQL Manager"""
+
+ def __init__(self, manager_def: dict):
+ """
+ Initialize object
+
+ :param manager_def: manager definition
+
+ :returns: `pygeoapi.process.manager.postgresqs.PostgreSQLManager`
+ """
+
+ super().__init__(manager_def)
+ self.is_async = True
+ self.id_field = 'identifier'
+ self.supports_subscribing = True
+ self.connection = manager_def['connection']
+
+ try:
+ self.db_search_path = tuple(self.connection.get('search_path',
+ ['public']))
+ except Exception:
+ self.db_search_path = ('public',)
+
+ try:
+ LOGGER.debug('Connecting to database')
+ if isinstance(self.connection, str):
+ _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:
+ msg = 'Test connecting to DB failed'
+ LOGGER.error(f'{msg}: {err}')
+ raise ProcessorGenericError(msg)
+
+ try:
+ LOGGER.debug('Getting table model')
+ self.table_model = get_table_model(
+ 'jobs',
+ self.id_field,
+ self.db_search_path,
+ self._engine
+ )
+ except Exception as err:
+ msg = 'Table model fetch failed'
+ LOGGER.error(f'{msg}: {err}')
+ raise ProcessorGenericError(msg)
+
+ 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: dict of list of jobs (identifier, status, process identifier)
+ and numberMatched
+ """
+
+ LOGGER.debug('Querying for jobs')
+ with Session(self._engine) as session:
+ results = session.query(self.table_model)
+ if status is not None:
+ column = getattr(self.table_model, 'status')
+ results = results.filter(column == status.value)
+
+ jobs = [r.__dict__ for r in results.all()]
+ return {
+ 'jobs': jobs,
+ 'numberMatched': len(jobs)
+ }
+
+ def add_job(self, job_metadata: dict) -> str:
+ """
+ Add a job
+
+ :param job_metadata: `dict` of job metadata
+
+ :returns: identifier of added job
+ """
+
+ LOGGER.debug('Adding job')
+ with Session(self._engine) as session:
+ try:
+ session.execute(insert(self.table_model)
+ .values(**job_metadata))
+ session.commit()
+ except Exception as err:
+ session.rollback()
+ msg = 'Insert failed'
+ LOGGER.error(f'{msg}: {err}')
+ raise ProcessorGenericError(msg)
+
+ return job_metadata['identifier']
+
+ def update_job(self, job_id: str, update_dict: dict) -> bool:
+ """
+ Updates a job
+
+ :param job_id: job identifier
+ :param update_dict: `dict` of property updates
+
+ :returns: `bool` of status result
+ """
+
+ rowcount = 0
+
+ LOGGER.debug('Updating job')
+ with Session(self._engine) as session:
+ try:
+ column = getattr(self.table_model, self.id_field)
+ stmt = (
+ update(self.table_model)
+ .where(column == job_id)
+ .values(**update_dict)
+ )
+ result = session.execute(stmt)
+ session.commit()
+ rowcount = result.rowcount
+ except Exception as err:
+ session.rollback()
+ msg = 'Update failed'
+ LOGGER.error(f'{msg}: {err}')
+ raise ProcessorGenericError(msg)
+
+ return rowcount == 1
+
+ def get_job(self, job_id: str) -> dict:
+ """
+ Get a single job
+
+ :param job_id: job identifier
+
+ :raises JobNotFoundError: if the job_id does not correspond to a
+ known job
+ :returns: `dict` # `pygeoapi.process.manager.Job`
+ """
+
+ LOGGER.debug('Querying for job')
+ with Session(self._engine) as session:
+ results = session.query(self.table_model)
+ column = getattr(self.table_model, self.id_field)
+ results = session.query(self.table_model).filter(column == job_id)
+
+ first = results.first()
+
+ if first is not None:
+ return first.__dict__
+ else:
+ raise JobNotFoundError()
+
+ def delete_job(self, job_id: str) -> bool:
+ """
+ Deletes a job
+
+ :param job_id: job identifier
+
+ :raises JobNotFoundError: if the job_id does not correspond to a
+ known job
+ :return `bool` of status result
+ """
+
+ rowcount = 0
+
+ # get result file if present for deletion
+ job_result = self.get_job(job_id)
+ location = job_result.get('location')
+
+ LOGGER.debug('Deleting job')
+ with Session(self._engine) as session:
+ try:
+ column = getattr(self.table_model, self.id_field)
+ stmt = (
+ delete(self.table_model)
+ .where(column == job_id)
+ )
+ result = session.execute(stmt)
+ session.commit()
+ rowcount = result.rowcount
+ except Exception as err:
+ session.rollback()
+ msg = 'Delete failed'
+ LOGGER.error(f'{msg}: {err}')
+ raise ProcessorGenericError(msg)
+
+ # delete result file if present
+ if None not in [location, self.output_dir]:
+ try:
+ Path(location).unlink()
+ except FileNotFoundError:
+ pass
+
+ return rowcount == 1
+
+ def get_job_result(self, job_id: str) -> Tuple[str, Any]:
+ """
+ Get a job's status, and actual output of executing the process
+
+ :param job_id: job identifier
+
+ :raises JobNotFoundError: if the job_id does not correspond to a
+ known job
+ :raises JobResultNotFoundError: if the job-related result cannot
+ be returned
+ :returns: `tuple` of mimetype and raw output
+ """
+
+ job_result = self.get_job(job_id)
+ location = job_result.get('location')
+ mimetype = job_result.get('mimetype')
+ job_status = JobStatus[job_result['status']]
+
+ if job_status != JobStatus.successful:
+ # Job is incomplete
+ return (None,)
+ if not location:
+ LOGGER.warning(f'job {job_id!r} - unknown result location')
+ raise JobResultNotFoundError()
+ else:
+ try:
+ location = Path(location)
+ 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:
+ return mimetype, result
+
+ def __repr__(self):
+ return f' {self.name}'
diff --git a/pygeoapi/process/manager/tinydb_.py b/pygeoapi/process/manager/tinydb_.py
index c55b7916f..b04d29a49 100644
--- a/pygeoapi/process/manager/tinydb_.py
+++ b/pygeoapi/process/manager/tinydb_.py
@@ -37,6 +37,7 @@
import tinydb
from filelock import FileLock
+from pygeoapi.api import FORMAT_TYPES, F_JSON, F_JSONLD
from pygeoapi.process.base import (
JobNotFoundError,
JobResultNotFoundError,
@@ -61,6 +62,7 @@ def __init__(self, manager_def: dict):
super().__init__(manager_def)
self.is_async = True
+ self.supports_subscribing = True
@contextmanager
def _db(self):
@@ -81,20 +83,35 @@ def destroy(self) -> bool:
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:
"""
@@ -131,8 +148,8 @@ def delete_job(self, job_id: str) -> bool:
:param job_id: job identifier
- :raises: JobNotFoundError: if the job_id does not correspond to a
- known job
+ :raises JobNotFoundError: if the job_id does not correspond to a
+ known job
:return `bool` of status result
"""
# delete result file if present
@@ -152,8 +169,8 @@ def get_job(self, job_id: str) -> dict:
:param job_id: job identifier
- :raises: JobNotFoundError: if the job_id does not correspond to a
- known job
+ :raises JobNotFoundError: if the job_id does not correspond to a
+ known job
:returns: `dict` # `pygeoapi.process.manager.Job`
"""
@@ -174,10 +191,10 @@ def get_job_result(self, job_id: str) -> Tuple[str, Any]:
:param job_id: job identifier
- :raises: JobNotFoundError: if the job_id does not correspond to a
- known job
- :raises: JobResultNotFoundError: if the job-related result cannot
- be returned
+ :raises JobNotFoundError: if the job_id does not correspond to a
+ known job
+ :raises JobResultNotFoundError: if the job-related result cannot
+ be returned
:returns: `tuple` of mimetype and raw output
"""
@@ -195,8 +212,13 @@ def get_job_result(self, job_id: str) -> Tuple[str, Any]:
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:
diff --git a/pygeoapi/process/shapely_functions.py b/pygeoapi/process/shapely_functions.py
new file mode 100644
index 000000000..e9047cc9a
--- /dev/null
+++ b/pygeoapi/process/shapely_functions.py
@@ -0,0 +1,412 @@
+# =================================================================
+#
+# Authors: Tom Kralidis
+# Emmanuel Jolaiya
+#
+# Copyright (c) 2024 Tom Kralidis
+# Copyright (c) 2024 Emmanuel Jolaiya
+#
+# 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 enum
+import logging
+
+from typing import Any, Dict, List, Tuple, Union
+
+from shapely.geometry import mapping, shape
+from shapely.geometry.base import BaseGeometry
+from shapely.prepared import prep
+from shapely.wkt import loads
+
+from pygeoapi.process.base import BaseProcessor, ProcessorExecuteError
+
+
+LOGGER = logging.getLogger(__name__)
+
+
+class SupportedShapelyOperations(enum.Enum):
+ """
+ Enum for the supported shapely operations
+ """
+
+ # Measurement operations.
+ # Ref: https://shapely.readthedocs.io/en/stable/measurement.html
+
+ MEASUREMENT_AREA = "measurement:area"
+ MEASUREMENT_DISTANCE = "measurement:distance"
+ MEASUREMENT_BOUNDS = "measurement:bounds"
+
+ # Predicate operations.
+ # Ref: https://shapely.readthedocs.io/en/stable/predicates.html
+
+ PREDICATES_COVERS = "predicates:covers"
+ PREDICATES_WITHIN = "predicates:within"
+
+ # Set operations.
+ # Ref: https://shapely.readthedocs.io/en/stable/set_operations.html
+
+ SET_DIFFERENCE = "set:difference"
+ SET_UNION = "set:union"
+
+ # Constructive operations.
+ # Ref: https://shapely.readthedocs.io/en/stable/constructive.html
+
+ CONSTRUCTIVE_BUFFER = "constructive:buffer"
+ CONSTRUCTIVE_CENTROID = "constructive:centroid"
+
+
+class SupportedFormats(enum.Enum):
+ WKT = "wkt"
+ """Well Known Text"""
+ GeoJSON_GEOMETRY = "geojson"
+ """GeoJSON Geometry"""
+
+
+class SupportedGeometryTypes(enum.Enum):
+ POINT = "Point"
+ POLYGON = "Polygon"
+ MULTI_POLYGON = "MultiPolygon"
+
+
+#: Process metadata and description
+PROCESS_METADATA = {
+ "version": "0.2.0",
+ "id": "shapely-functions",
+ "title": {
+ "en": "Shapely Functions",
+ "fr": "Fonctions galbées",
+ "es": "Funciones bien formadas"
+ },
+ "description": {
+ "en": "An example process that takes one or more input geometry "
+ "(WKT or GeoJSON geometry), applies the specified shapely"
+ " operation on the input, and returns the result "
+ "in the specified output_format.",
+ "fr": "Un exemple de processus qui prend une ou plusieurs géométries"
+ " d'entrée (géométrie WKT ou GeoJSON) applique l'opération "
+ "galbée spécifiée sur l'entrée et renvoie le "
+ "résultat dans le format de sortie spécifié.",
+ "es": "Un proceso de ejemplo que toma una o más geometrías"
+ "de entrada (geometría WKT o GeoJSON) aplica la operación"
+ "de forma especificada en la entrada y devuelve el resultado"
+ "en el formato de salida especificado."
+ },
+ "jobControlOptions": ["sync-execute", "async-execute"],
+ "keywords": [
+ "shapely functions",
+ "measurement",
+ "predicates",
+ "set operations",
+ "constructive ops"
+ ],
+ "links": [
+ {
+ "type": "text/html",
+ "rel": "about",
+ "title": "information",
+ "href": "https://shapely.readthedocs.io",
+ "hreflang": "en-US"
+ },
+ ],
+ "inputs": {
+ "operation": {
+ "title": "Shapely operation",
+ "description": "The shapely operation to perform. Namespace of the"
+ "function category is used to avoid mixup in function names.",
+ "schema": {
+ "type": "string"
+ },
+ "minOccurs": 1,
+ "maxOccurs": 1,
+ "enum": [ops.value for ops in SupportedShapelyOperations]
+ },
+ "geoms": {
+ "title": "Input geometry",
+ "description": "The representation of the object"
+ " as GeoJSON geometry or a WKT.",
+ "schema": {
+ "type": "array",
+ "description": "An array of the geometries. "
+ "The order of the geometries matter. The first "
+ "element is considered geom A, second element as geom B, "
+ "in that order.",
+ "minItems": 1,
+ "maxItems": 2,
+ "items": {
+ "oneOf": [
+ # Can be a WKT string
+ {
+ "type": "string",
+ },
+ # or a GeoJSON geometry
+ {
+ "oneOf": [
+ {"format": "geojson-geometry"},
+ {
+ "$ref": "http://schemas.opengis.net/"
+ "ogcapi/features/part1/1.0/openapi/"
+ "schemas/geometryGeoJSON.yaml"
+ },
+ ]
+ },
+ ]
+ },
+ },
+ "minOccurs": 1,
+ "maxOccurs": 1,
+ },
+ "output_format": {
+ "title": "The output format of the geometry",
+ "description": "The output format of the process result. "
+ "If the shapely operation does not return a geometry, then "
+ " this is ignored.",
+ "schema": {
+ "type": "string"
+ },
+ "minOccurs": 0,
+ "maxOccurs": 1,
+ "enum": [v.value for v in SupportedFormats]
+ },
+ },
+ "outputs": {
+ "result": {
+ "title": "Shapely operation result",
+ "description": "The result of the shapely operation "
+ "performed in the process.",
+ "schema": {
+ "type": "object",
+ "contentMediaType": "application/json"
+ },
+ },
+ },
+ "example": {
+ "inputs": {
+ "operation": "predicates:within",
+ "geoms": [
+ {
+ "coordinates": [
+ [
+ [80.58983993887352, 24.07996699713047],
+ [80.58983993887352, 20.291958215654446],
+ [85.04987314197012, 20.291958215654446],
+ [85.04987314197012, 24.07996699713047],
+ [80.58983993887352, 24.07996699713047],
+ ]
+ ],
+ "type": "Polygon",
+ },
+ {
+ "coordinates": [
+ [
+ [80.58983993887352, 24.07996699713047],
+ [80.58983993887352, 20.291958215654446],
+ [85.04987314197012, 20.291958215654446],
+ [85.04987314197012, 24.07996699713047],
+ [80.58983993887352, 24.07996699713047],
+ ]
+ ],
+ "type": "Polygon"
+ },
+ ],
+ }
+ },
+}
+
+
+class ShapelyFunctionsProcessor(BaseProcessor):
+ """Shapely Functions Processor example"""
+
+ def __init__(self, processor_def):
+ """
+ Initialize object
+
+ :param processor_def: provider definition
+
+ :returns: pygeoapi.process.shapely_functions.ShapelyFunctionsProcessor
+ """
+ self.shapely_operations = [ops.value for ops in SupportedShapelyOperations] # noqa: E501
+ self.supported_formats = [fmt.value for fmt in SupportedFormats]
+ super().__init__(processor_def, PROCESS_METADATA)
+
+ def execute(self, data, outputs=None) -> Tuple[str, Dict[str, Any]]:
+ mimetype = "application/json"
+ operation = data.get("operation")
+ output_format = data.get("output_format")
+ geometries = data.get("geoms")
+
+ # validations
+ if geometries is None:
+ raise ProcessorExecuteError("Cannot process without valid geometries.") # noqa: E501
+
+ if not isinstance(geometries, list):
+ raise ProcessorExecuteError(
+ """Cannot process without valid geometries.
+ `geoms` must be an array of WKT
+ or GeoJSON geometry objects."""
+ )
+
+ # output_format could be optional,
+ # because some operations return only value and not geometry
+ nongeom_operations = {
+ SupportedShapelyOperations.MEASUREMENT_AREA.value,
+ SupportedShapelyOperations.MEASUREMENT_DISTANCE.value,
+ SupportedShapelyOperations.MEASUREMENT_BOUNDS.value,
+ SupportedShapelyOperations.PREDICATES_COVERS.value,
+ SupportedShapelyOperations.PREDICATES_WITHIN.value
+ }
+ if output_format is None and operation not in nongeom_operations:
+ raise ProcessorExecuteError("Cannot process without an `output_format`.") # noqa: E501
+
+ if (
+ output_format is not None
+ and output_format.lower() not in self.supported_formats
+ ):
+ raise ProcessorExecuteError(
+ f"""Invalid `output_format` provided.
+ Supported options are {self.supported_formats},
+ but got {output_format}."""
+ )
+
+ self.output_format = output_format
+
+ if operation is None:
+ raise ProcessorExecuteError("Cannot process without a valid operation.") # noqa: E501
+
+ if operation not in self.shapely_operations:
+ raise ProcessorExecuteError(
+ f"""Invalid shapely operation provided.
+ Supported operations are : {self.shapely_operations}"""
+ )
+
+ requires_single_geom = {
+ SupportedShapelyOperations.MEASUREMENT_AREA.value,
+ SupportedShapelyOperations.MEASUREMENT_BOUNDS.value,
+ SupportedShapelyOperations.CONSTRUCTIVE_BUFFER.value,
+ SupportedShapelyOperations.CONSTRUCTIVE_CENTROID.value
+ }
+
+ if operation in requires_single_geom and len(geometries) > 1:
+ raise ProcessorExecuteError(
+ f"""Too many geometries. The {operation}
+ operation requires only one geometry,
+ but {len(geometries)} was provided."""
+ )
+
+ if operation not in requires_single_geom and len(geometries) < 2:
+ raise ProcessorExecuteError(
+ f"""Too few geometries. The {operation} operation
+ requires at least two geometry,
+ but {len(geometries)} was provided."""
+ )
+
+ parsed_geoms = []
+
+ for geom in geometries:
+ if isinstance(geom, str):
+ parsed_geoms.append(loads(geom))
+ else:
+ parsed_geoms.append(shape(geom))
+
+ result = self.perform_operation(parsed_geoms, operation)
+
+ return mimetype, result
+
+ def parse_result(self, geom: BaseGeometry) -> Union[str, Dict[str, Any]]:
+ """
+ Convert a shapely geometry into the specified format by the client.
+
+ Args:
+ geom (BaseGeometry): The shapely geometry to convert.
+
+ Returns:
+ Union[str,Dict[str,Any]]: The resulting geometry
+ in the specified format.
+ """
+ return (
+ geom.wkt
+ if self.output_format == SupportedFormats.WKT.value
+ else mapping(geom)
+ )
+
+ def perform_operation(self, parsed_geoms: List[BaseGeometry], operation: str): # noqa: E501
+ """
+ Perform the exact shapely operation specified by the client.
+
+ Args:
+ parsed_geoms (List[BaseGeometry]): An array of shapely geometries.
+ operation (str): The exact shapely operation to perform.
+
+ Raises:
+ ProcessorExecuteError: Server error if given an invalid parameter.
+
+ Returns:
+ Dict[str,Any]: The response object with the operation result,
+ to be returned to the client.
+ """
+ result = {"operation": operation, "result": None}
+
+ # prep the first geom for performance improvement
+ prep(parsed_geoms[0])
+ if operation == SupportedShapelyOperations.MEASUREMENT_AREA.value:
+ if parsed_geoms[0].type not in [
+ SupportedGeometryTypes.POLYGON.value,
+ SupportedGeometryTypes.MULTI_POLYGON.value
+ ]:
+ raise ProcessorExecuteError(
+ f"""Invalid geometry type.{operation}
+ operation only works on
+ `Polygon and MultiPolygon` geometry."""
+ )
+ result.update({"result": parsed_geoms[0].area})
+ elif operation == SupportedShapelyOperations.MEASUREMENT_BOUNDS.value:
+ result.update({"result": parsed_geoms[0].bounds})
+ elif operation == SupportedShapelyOperations.MEASUREMENT_DISTANCE.value: # noqa: E501
+ result.update({"result": parsed_geoms[0].distance(parsed_geoms[1])}) # noqa: E501
+ elif operation == SupportedShapelyOperations.PREDICATES_COVERS.value:
+ result.update({"result": parsed_geoms[0].covers(parsed_geoms[1])})
+ elif operation == SupportedShapelyOperations.PREDICATES_WITHIN.value:
+ result.update({"result": parsed_geoms[0].within(parsed_geoms[1])})
+ elif operation == SupportedShapelyOperations.SET_DIFFERENCE.value:
+ result.update(
+ {
+ "result": self.parse_result(
+ parsed_geoms[0].difference(parsed_geoms[1])
+ )
+ }
+ )
+ elif operation == SupportedShapelyOperations.SET_UNION.value:
+ result.update(
+ {
+ "result": self.parse_result(parsed_geoms[0].union(parsed_geoms[1])) # noqa: E501
+ }
+ )
+ elif operation == SupportedShapelyOperations.CONSTRUCTIVE_BUFFER.value:
+ # todo - how do we receive kwargs from the user?
+ result.update({"result": self.parse_result(parsed_geoms[0].buffer(10))}) # noqa: E501
+ else:
+ result.update({"result": self.parse_result(parsed_geoms[0].centroid)}) # noqa: E501
+ return result
+
+ def __repr__(self):
+ return f" {self.name}"
diff --git a/pygeoapi/provider/base.py b/pygeoapi/provider/base.py
index 4f8728dda..5f9456870 100644
--- a/pygeoapi/provider/base.py
+++ b/pygeoapi/provider/base.py
@@ -73,7 +73,7 @@ def __init__(self, provider_def):
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 @@ def get_fields(self):
"""
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 typess
+ :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
@@ -181,24 +199,6 @@ def delete(self, identifier):
raise NotImplementedError()
- def get_coverage_domainset(self):
- """
- Provide coverage domainset
-
- :returns: CIS JSON object of domainset metadata
- """
-
- raise NotImplementedError()
-
- def get_coverage_rangetype(self):
- """
- Provide coverage rangetype
-
- :returns: CIS JSON object of rangetype metadata
- """
-
- raise NotImplementedError()
-
def _load_and_prepare_item(self, item, identifier=None,
accept_missing_identifier=False,
raise_if_exists=True):
diff --git a/pygeoapi/provider/base_edr.py b/pygeoapi/provider/base_edr.py
index 3e7a259cb..7ac8792a0 100644
--- a/pygeoapi/provider/base_edr.py
+++ b/pygeoapi/provider/base_edr.py
@@ -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"""
@@ -48,13 +52,18 @@ def __init__(self, provider_def):
:returns: pygeoapi.provider.base_edr.BaseEDRProvider
"""
- super().__init__(provider_def)
+ BaseProvider.__init__(self, provider_def)
- self.instances = []
+# self.instances = []
@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
diff --git a/pygeoapi/provider/base_mvt.py b/pygeoapi/provider/base_mvt.py
index 23ef01c5c..0618909bb 100644
--- a/pygeoapi/provider/base_mvt.py
+++ b/pygeoapi/provider/base_mvt.py
@@ -122,7 +122,7 @@ def get_tiles(self, layer=None, tileset=None,
def get_html_metadata(self, dataset, server_url, layer, tileset,
title, description, keywords, **kwargs):
"""
- Gets tile metadata informations in html format
+ Gets tile metadata information in html format
:param dataset: dataset name
:param server_url: server base url
@@ -248,3 +248,45 @@ def get_tms_links(self):
]
}
return links
+
+ def get_tilematrixset(self, tileMatrixSetId):
+ """
+ Get tilematrixset
+
+ :param tileMatrixSetId: tilematrixsetid str
+
+ :returns: tilematrixset enum object
+ """
+
+ enums = [e.value for e in TileMatrixSetEnum]
+ enum = None
+
+ try:
+ for e in enums:
+ if tileMatrixSetId == e.tileMatrixSet:
+ enum = e
+ if not enum:
+ raise ValueError('could not find this tilematrixset')
+ return enum
+
+ except ValueError as err:
+ LOGGER.error(err)
+
+ def is_in_limits(self, tilematrixset, z, x, y):
+ """
+ Is within the limits of the tilematrixset
+
+ :param z: tilematrix
+ :param x: x
+ :param y: y
+
+ :returns: wether this tile is within the tile matrix
+ set limits (Boolean)
+ """
+
+ try:
+ if int(x) < tilematrixset.tileMatrices[int(z)]['matrixWidth'] and int(y) < tilematrixset.tileMatrices[int(z)]['matrixHeight']: # noqa
+ return True
+ return False
+ except ValueError as err:
+ LOGGER.error(err)
diff --git a/pygeoapi/provider/csv_.py b/pygeoapi/provider/csv_.py
index 88d56c56a..b51c1ebcb 100644
--- a/pygeoapi/provider/csv_.py
+++ b/pygeoapi/provider/csv_.py
@@ -2,7 +2,7 @@
#
# Authors: Tom Kralidis
#
-# Copyright (c) 2022 Tom Kralidis
+# Copyright (c) 2025 Tom Kralidis
#
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation
@@ -32,8 +32,9 @@
import itertools
import logging
-from pygeoapi.provider.base import (BaseProvider, ProviderQueryError,
- ProviderItemNotFoundError)
+from pygeoapi.provider.base import (BaseProvider, ProviderInvalidQueryError,
+ ProviderItemNotFoundError,
+ ProviderQueryError)
from pygeoapi.util import get_typed_value, crs_transform
LOGGER = logging.getLogger(__name__)
@@ -54,7 +55,7 @@ def __init__(self, provider_def):
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 +63,31 @@ def get_fields(self):
:returns: dict of fields
"""
-
- 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_)
-
- 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'
-
- fields[key] = {'type': type_}
-
- return 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)
+
+ 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'
+
+ self._fields[key] = {'type': type_}
+
+ return self._fields
def _load(self, offset=0, limit=10, resulttype='results',
identifier=None, bbox=[], datetime_=None, properties=[],
@@ -121,6 +121,12 @@ def _load(self, offset=0, limit=10, resulttype='results',
LOGGER.debug('Serializing DictReader')
data_ = csv.DictReader(ff)
if properties:
+ for prop in properties:
+ if prop[0] not in data_.fieldnames:
+ msg = 'Invalid fieldname'
+ LOGGER.error(msg)
+ raise ProviderInvalidQueryError(msg)
+
data_ = filter(
lambda p: all(
[p[prop[0]] == prop[1] for prop in properties]), data_)
@@ -130,16 +136,17 @@ def _load(self, offset=0, limit=10, resulttype='results',
feature_collection['numberMatched'] = len(list(data_))
return feature_collection
LOGGER.debug('Slicing CSV rows')
- for row in itertools.islice(data_, offset, offset+limit):
+ for row in itertools.islice(data_, 0, None):
try:
coordinates = [
float(row.pop(self.geometry_x)),
float(row.pop(self.geometry_y)),
]
except ValueError:
- msg = f'Skipping row with invalid geometry: {row.get(self.id_field)}' # noqa
- LOGGER.error(msg)
- continue
+ msg = f'Row with invalid geometry: {row.get(self.id_field)}, setting coordinates to None' # noqa
+ LOGGER.warning(msg)
+ coordinates = None
+
feature = {'type': 'Feature'}
feature['id'] = row.pop(self.id_field)
if not skip_geometry:
@@ -178,6 +185,9 @@ def _load(self, offset=0, limit=10, resulttype='results',
elif identifier is not None and found:
return result
+ features_returned = feature_collection['features'][offset:offset+limit]
+ feature_collection['features'] = features_returned
+
feature_collection['numberReturned'] = len(
feature_collection['features'])
diff --git a/pygeoapi/provider/csw_facade.py b/pygeoapi/provider/csw_facade.py
index cfb5bb826..69cd0ddee 100644
--- a/pygeoapi/provider/csw_facade.py
+++ b/pygeoapi/provider/csw_facade.py
@@ -69,7 +69,8 @@ def __init__(self, provider_def):
'language': ('dc:language', 'language')
}
- self.fields = self.get_fields()
+ self._fields = {}
+ self.get_fields()
def get_fields(self):
"""
@@ -78,17 +79,17 @@ def get_fields(self):
: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',
diff --git a/pygeoapi/provider/elasticsearch_.py b/pygeoapi/provider/elasticsearch_.py
index 2b146e1b3..4c0ae20c1 100644
--- a/pygeoapi/provider/elasticsearch_.py
+++ b/pygeoapi/provider/elasticsearch_.py
@@ -1,9 +1,10 @@
# =================================================================
#
# Authors: Tom Kralidis
+# Francesco Bartoli
#
-# Copyright (c) 2023 Tom Kralidis
-# Copyright (c) 2021 Francesco Bartoli
+# 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
@@ -35,13 +36,14 @@
import uuid
from elasticsearch import Elasticsearch, exceptions, helpers
-from elasticsearch_dsl import Search, Q
+from pygeofilter.backends.elasticsearch import to_filter
+
+from elasticsearch_dsl import Search
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
+from pygeoapi.util import crs_transform
LOGGER = logging.getLogger(__name__)
@@ -86,7 +88,7 @@ def __init__(self, provider_def):
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)
@@ -97,38 +99,56 @@ def get_fields(self):
:returns: dict of fields
"""
+ if not self._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:
+ 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 {}
- fields_ = {}
- ii = self.es.indices.get(index=self.index_name, allow_no_indices=False)
+ self._fields = self.get_nested_fields(p, self._fields)
+ return self._fields
- LOGGER.debug(f'Response: {ii}')
- try:
- if '*' not in self.index_name:
- p = ii[self.index_name]['mappings']['properties']['properties']
+ def get_nested_fields(self, properties, fields, prev_field=None):
+ """
+ Get Elasticsearch fields (names, types) for all nested properties
+
+ :param properties: `dict` of Elasticsearch mappings properties
+ :param fields: `dict` of fields in the current iteration
+ :param prev_field: name of the parent field
+
+ :returns: dict of fields
+ """
+ for k, v in properties['properties'].items():
+
+ cur_field = k if prev_field is None else f'{prev_field}.{k}'
+
+ if isinstance(v, dict) and 'properties' in v:
+ fields = self.get_nested_fields(v, fields, cur_field)
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'}
+ fields[cur_field] = {'type': 'string'}
elif v['type'] == 'date':
- fields_[k] = {'type': 'string', 'format': 'date'}
+ fields[cur_field] = {'type': 'string', 'format': 'date'}
elif v['type'] in ('float', 'long'):
- fields_[k] = {'type': 'number', 'format': v['type']}
+ fields[cur_field] = {'type': 'number', 'format': v['type']}
else:
- fields_[k] = {'type': v['type']}
-
- return fields_
+ fields[cur_field] = {'type': v['type']}
+ return fields
@crs_transform
def query(self, offset=0, limit=10, resulttype='results',
@@ -294,7 +314,7 @@ def query(self, offset=0, limit=10, resulttype='results',
try:
LOGGER.debug('querying Elasticsearch')
if filterq:
- LOGGER.debug(f'adding cql object: {filterq.model_dump_json()}')
+ LOGGER.debug(f'adding cql object: {filterq}')
query = update_query(input_query=query, cql=filterq)
LOGGER.debug(json.dumps(query, indent=4))
@@ -486,8 +506,8 @@ def esdoc2geojson(self, doc):
try:
feature_thinned['properties'][p] = feature_['properties'][p] # noqa
except KeyError as err:
- LOGGER.error(err)
- raise ProviderQueryError()
+ msg = f'Property missing {err}; continuing'
+ LOGGER.warning(msg)
if feature_thinned:
return feature_thinned
@@ -566,183 +586,8 @@ def __repr__(self):
return f' {self.data}'
-class ESQueryBuilder:
- 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):
+def update_query(input_query: Dict, cql):
s = Search.from_dict(input_query)
- query = ESQueryBuilder()
- output_query = _build_query(query, cql)
- s = s.query(output_query)
-
+ s = s.query(to_filter(cql))
LOGGER.debug(f'Enhanced query: {json.dumps(s.to_dict())}')
return s.to_dict()
diff --git a/pygeoapi/provider/erddap.py b/pygeoapi/provider/erddap.py
index a81876e1f..2fc71c064 100644
--- a/pygeoapi/provider/erddap.py
+++ b/pygeoapi/provider/erddap.py
@@ -51,6 +51,7 @@
from pygeoapi.provider.base import (
BaseProvider, ProviderNotFoundError, ProviderQueryError)
+from pygeoapi.util import crs_transform
LOGGER = logging.getLogger(__name__)
@@ -61,25 +62,27 @@ def __init__(self, provider_def):
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',
bbox=[], datetime_=None, properties=[], sortby=[],
select_properties=[], skip_geometry=False, q=None,
@@ -164,6 +167,7 @@ def query(self, offset=0, limit=10, resulttype='results',
'numberReturned': returned
}
+ @crs_transform
def get(self, identifier, **kwargs):
query_params = []
diff --git a/pygeoapi/provider/esri.py b/pygeoapi/provider/esri.py
index 0d22a8805..47d74e2b9 100644
--- a/pygeoapi/provider/esri.py
+++ b/pygeoapi/provider/esri.py
@@ -62,8 +62,9 @@ def __init__(self, provider_def):
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 @@ def get_fields(self):
: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 @@ def get_fields(self):
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 @@ def login(self):
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({
diff --git a/pygeoapi/provider/geojson.py b/pygeoapi/provider/geojson.py
index 180cf0746..257166a7c 100644
--- a/pygeoapi/provider/geojson.py
+++ b/pygeoapi/provider/geojson.py
@@ -68,7 +68,7 @@ def __init__(self, provider_def):
"""initializer"""
super().__init__(provider_def)
- self.fields = self.get_fields()
+ self.get_fields()
def get_fields(self):
"""
@@ -77,23 +77,24 @@ def get_fields(self):
: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'
-
- fields[key] = {'type': type_}
- else:
- LOGGER.warning(f'File {self.data} does not exist.')
- return fields
+ 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'
+
+ 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
diff --git a/pygeoapi/provider/hateoas.py b/pygeoapi/provider/hateoas.py
index 307119d46..dfb98123f 100644
--- a/pygeoapi/provider/hateoas.py
+++ b/pygeoapi/provider/hateoas.py
@@ -120,6 +120,9 @@ def get_data_path(self, baseurl, urlpath, entrypath):
try:
jsondata = _get_json_data(f'{data_path}/collection.json')
resource_type = 'Collection'
+ for key in ['license', 'extent', 'id']:
+ if key in jsondata:
+ content[key] = jsondata[key]
except Exception:
try:
filename = os.path.basename(data_path)
@@ -148,7 +151,7 @@ def get_data_path(self, baseurl, urlpath, entrypath):
child_links.append({
'rel': 'child',
'href': newpath,
- 'type': 'text/html',
+ 'type': 'application/json',
'created': "-",
'entry:type': 'Catalog'
})
@@ -156,7 +159,7 @@ def get_data_path(self, baseurl, urlpath, entrypath):
child_links.append({
'rel': 'child',
'href': newpath,
- 'type': 'text/html',
+ 'type': 'application/json',
'created': "-",
'entry:type': 'Collection'
})
diff --git a/pygeoapi/provider/mapscript_.py b/pygeoapi/provider/mapscript_.py
index a459b1e3b..b1d237225 100644
--- a/pygeoapi/provider/mapscript_.py
+++ b/pygeoapi/provider/mapscript_.py
@@ -35,6 +35,7 @@
from pygeoapi.provider.base import (BaseProvider, ProviderConnectionError,
ProviderQueryError)
+from pygeoapi.util import str2bool
LOGGER = logging.getLogger(__name__)
@@ -74,13 +75,17 @@ def __init__(self, provider_def):
file_extension = self.data.split('.')[-1]
- if file_extension in ['shp', 'tif']:
- LOGGER.debug('Setting built-in MapServer driver')
- self._layer.data = self.data
+ if str2bool(self.options.get('tileindex', False)):
+ LOGGER.debug('Setting tileindex')
+ self._layer.tileindex = self.data
else:
- LOGGER.debug('Setting OGR driver')
- self._layer.setConnectionType(mapscript.MS_OGR, 'OGR')
- self._layer.connection = self.data
+ if file_extension in ['shp', 'tif']:
+ LOGGER.debug('Setting built-in MapServer driver')
+ self._layer.data = self.data
+ else:
+ LOGGER.debug('Setting OGR driver')
+ self._layer.setConnectionType(mapscript.MS_OGR, 'OGR')
+ self._layer.connection = self.data
self._layer.type = getattr(mapscript, self.options['type'])
@@ -111,6 +116,9 @@ def __init__(self, provider_def):
cls.updateFromString(cls_def)
self._layer.insertClass(cls)
+ if self.options['type'] == 'MS_LAYER_RASTER':
+ self._layer.addProcessing('SCALE=AUTO')
+
except MapServerError as err:
LOGGER.warning(err)
raise ProviderConnectionError('Cannot connect to map service')
diff --git a/pygeoapi/provider/mongo.py b/pygeoapi/provider/mongo.py
index f97a724f1..ca258018c 100644
--- a/pygeoapi/provider/mongo.py
+++ b/pygeoapi/provider/mongo.py
@@ -31,7 +31,6 @@
from datetime import datetime
import logging
-from bson import Code
from pymongo import MongoClient
from pymongo import GEOSPHERE
from pymongo import ASCENDING, DESCENDING
@@ -67,7 +66,7 @@ def __init__(self, provider_def):
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):
"""
@@ -76,21 +75,24 @@ def get_fields(self):
:returns: dict of fields
"""
- map = Code(
- "function() { for (var key in this.properties) "
- "{ emit(key, null); } }")
- reduce = Code("function(key, stuff) { return null; }")
- result = self.featuredb[self.collection].map_reduce(
- map, reduce, "myresults")
-
- # prepare a dictionary with fields
- # set the field type to 'string'.
- # by operating without a schema, mongo can query any data type.
- fields = {}
- for i in result.distinct('_id'):
- fields[i] = {'type': 'string'}
-
- return (fields)
+ 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))
+
+ # 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'}
+
+ return self._fields
def _get_feature_list(self, filterObj, sortList=[], skip=0, maxitems=1,
skip_geometry=False):
diff --git a/pygeoapi/provider/mvt_elastic.py b/pygeoapi/provider/mvt_elastic.py
index df739c18e..027009197 100644
--- a/pygeoapi/provider/mvt_elastic.py
+++ b/pygeoapi/provider/mvt_elastic.py
@@ -35,8 +35,9 @@
from pygeoapi.provider.base import (ProviderConnectionError,
ProviderGenericError,
ProviderInvalidQueryError)
+from pygeoapi.provider.tile import ProviderTileNotFoundError
from pygeoapi.models.provider.base import (
- TileSetMetadata, LinkType)
+ TileSetMetadata, TileMatrixSetEnum, LinkType)
from pygeoapi.util import is_url, url_join
LOGGER = logging.getLogger(__name__)
@@ -119,6 +120,13 @@ def get_layer(self):
LOGGER.debug('Removing leading "/"')
return layer[1:]
+ def get_tiling_schemes(self):
+
+ "Only WebMercatorQuad tiling scheme is supported in elastic"
+ return [
+ TileMatrixSetEnum.WEBMERCATORQUAD.value
+ ]
+
def get_tiles_service(self, baseurl=None, servicepath=None,
dirpath=None, tile_type=None):
"""
@@ -166,8 +174,15 @@ def get_tiles(self, layer=None, tileset=None,
try:
with requests.Session() as session:
+ data = {'fields': ['*']}
session.get(base_url)
- resp = session.get(f'{base_url}/{layer}/{z}/{y}/{x}{url_query}') # noqa
+ resp = session.get(f'{base_url}/{layer}/{z}/{y}/{x}{url_query}', json=data) # noqa
+
+ if resp.status_code == 404:
+ if (self.is_in_limits(TileMatrixSetEnum.WEBMERCATORQUAD.value, z, x, y)): # noqa
+ return None
+ raise ProviderTileNotFoundError
+
resp.raise_for_status()
return resp.content
except requests.exceptions.RequestException as e:
@@ -224,7 +239,7 @@ def get_default_metadata(self, dataset, server_url, layer, tileset,
tiling_scheme = LinkType(href=tiling_scheme_url,
rel="http://www.opengis.net/def/rel/ogc/1.0/tiling-scheme", # noqa
- type=tiling_scheme_url_type,
+ type_=tiling_scheme_url_type,
title=tiling_scheme_url_title)
if tiling_scheme is None:
@@ -240,7 +255,7 @@ def get_default_metadata(self, dataset, server_url, layer, tileset,
service_url_link_type = "application/vnd.mapbox-vector-tile"
service_url_link_title = f'{tileset} vector tiles for {layer}'
service_url_link = LinkType(href=service_url, rel="item",
- type=service_url_link_type,
+ type_=service_url_link_type,
title=service_url_link_title)
links.append(tiling_scheme)
@@ -249,11 +264,3 @@ def get_default_metadata(self, dataset, server_url, layer, tileset,
content.links = links
return content.dict(exclude_none=True)
-
- def get_vendor_metadata(self, dataset, server_url, layer, tileset,
- title, description, keywords, **kwargs):
- """
- Gets tile metadata in tilejson format
- """
- LOGGER.debug("Get tilejson metadata")
- return ""
diff --git a/pygeoapi/provider/mvt_proxy.py b/pygeoapi/provider/mvt_proxy.py
new file mode 100644
index 000000000..3d20d3ced
--- /dev/null
+++ b/pygeoapi/provider/mvt_proxy.py
@@ -0,0 +1,262 @@
+# =================================================================
+#
+# Authors: Antonio Cerciello
+#
+# Copyright (c) 2024 Antonio Cerciello
+#
+# 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 logging
+import requests
+from urllib.parse import urlparse
+
+from pygeoapi.provider.base_mvt import BaseMVTProvider
+from pygeoapi.provider.base import (ProviderConnectionError,
+ ProviderGenericError,
+ ProviderInvalidQueryError)
+from pygeoapi.provider.tile import ProviderTileNotFoundError
+from pygeoapi.models.provider.base import (
+ TileSetMetadata, LinkType)
+from pygeoapi.util import is_url, url_join
+
+LOGGER = logging.getLogger(__name__)
+
+
+class MVTProxyProvider(BaseMVTProvider):
+ """
+ MVT Proxy Provider
+ Provider for serving tiles rendered with an external
+ tiles provider
+ """
+
+ def __init__(self, BaseMVTProvider):
+ """
+ Initialize object
+
+ :param provider_def: provider definition
+
+ :returns: pygeoapi.provider.mvt_proxy.pygeoapi/provider/mvt_proxy.py # noqa
+ """
+
+ super().__init__(BaseMVTProvider)
+
+ if not is_url(self.data):
+ msg = 'Wrong input format for MVT'
+ LOGGER.error(msg)
+ raise ProviderConnectionError(msg)
+
+ url = urlparse(self.data)
+ baseurl = f'{url.scheme}://{url.netloc}'
+ param_type = '?f=mvt'
+ layer = f'/{self.get_layer()}'
+
+ LOGGER.debug('Extracting layer name from URL')
+ LOGGER.debug(f'Layer: {layer}')
+
+ tilepath = f'{layer}/tiles'
+ servicepath = f'{tilepath}/{{tileMatrixSetId}}/{{tileMatrix}}/{{tileRow}}/{{tileCol}}{param_type}' # noqa
+
+ self._service_url = url_join(baseurl, servicepath)
+
+ self._service_metadata_url = url_join(
+ self.service_url.split('{tileMatrix}/{tileRow}/{tileCol}')[0],
+ 'metadata')
+
+ def __repr__(self):
+ return f' {self.data}'
+
+ @property
+ def service_url(self):
+ return self._service_url
+
+ @property
+ def service_metadata_url(self):
+ return self._service_metadata_url
+
+ def get_layer(self):
+ """
+ Extracts layer name from url
+
+ :returns: layer name
+ """
+
+ if not is_url(self.data):
+ msg = 'Wrong input format for MVT'
+ LOGGER.error(msg)
+ raise ProviderConnectionError(msg)
+
+ url = urlparse(self.data)
+
+ if ('/{z}/{x}/{y}' not in url.path):
+ msg = 'Wrong input format for MVT'
+ LOGGER.error(msg)
+ raise ProviderConnectionError(msg)
+
+ layer = url.path.split('/{z}/{x}/{y}')[0]
+
+ LOGGER.debug(layer)
+ LOGGER.debug('Removing leading "/"')
+ return layer[1:]
+
+ def get_tiles_service(self, baseurl=None, servicepath=None,
+ dirpath=None, tile_type=None):
+ """
+ Gets mvt service description
+
+ :param baseurl: base URL of endpoint
+ :param servicepath: base path of URL
+ :param dirpath: directory basepath (equivalent of URL)
+ :param tile_type: tile format type
+
+ :returns: `dict` of item tile service
+ """
+
+ super().get_tiles_service(baseurl, servicepath,
+ dirpath, tile_type)
+
+ self._service_url = servicepath
+ return self.get_tms_links()
+
+ def get_tiles(self, layer=None, tileset=None,
+ z=None, y=None, x=None, format_=None):
+ """
+ Gets tile
+
+ :param layer: mvt tile layer
+ :param tileset: mvt tileset
+ :param z: z index
+ :param y: y index
+ :param x: x index
+ :param format_: tile format
+
+ :returns: an encoded mvt tile
+ """
+ if format_ == 'mvt':
+ format_ = self.format_type
+
+ if is_url(self.data):
+ url = urlparse(self.data)
+ base_url = f'{url.scheme}://{url.netloc}'
+
+ if url.query:
+ url_query = f'?{url.query}'
+ else:
+ url_query = ''
+
+ resp = None
+
+ try:
+ with requests.Session() as session:
+ session.get(base_url)
+ if '.' in url.path:
+ resp = session.get(f'{base_url}/{layer}/{z}/{y}/{x}.{format_}{url_query}') # noqa
+ else:
+ resp = session.get(f'{base_url}/{layer}/{z}/{y}/{x}{url_query}') # noqa
+
+ if resp.status_code == 404:
+ if (self.is_in_limits(self.get_tilematrixset(tileset), z, x, y)): # noqa
+ return None
+ raise ProviderTileNotFoundError
+
+ resp.raise_for_status()
+ return resp.content
+ except requests.exceptions.RequestException as e:
+ LOGGER.debug(e)
+ if resp and resp.status_code < 500:
+ raise ProviderInvalidQueryError # Client is sending an invalid request # noqa
+ raise ProviderGenericError # Server error
+ else:
+ msg = 'Wrong input format for MVT'
+ LOGGER.error(msg)
+ raise ProviderConnectionError(msg)
+
+ def get_html_metadata(self, dataset, server_url, layer, tileset,
+ title, description, keywords, **kwargs):
+
+ service_url = url_join(
+ server_url,
+ f'collections/{dataset}/tiles/{tileset}/{{tileMatrix}}/{{tileRow}}/{{tileCol}}?f=mvt') # noqa
+ metadata_url = url_join(
+ server_url,
+ f'collections/{dataset}/tiles/{tileset}/metadata')
+
+ metadata = dict()
+ metadata['id'] = dataset
+ metadata['title'] = title
+ metadata['tileset'] = tileset
+ metadata['collections_path'] = service_url
+ metadata['json_url'] = f'{metadata_url}?f=json'
+
+ return metadata
+
+ def get_default_metadata(self, dataset, server_url, layer, tileset,
+ title, description, keywords, **kwargs):
+
+ service_url = url_join(
+ server_url,
+ f'collections/{dataset}/tiles/{tileset}/{{tileMatrix}}/{{tileRow}}/{{tileCol}}?f=mvt') # noqa
+
+ content = {}
+ tiling_schemes = self.get_tiling_schemes()
+ # Default values
+ tileMatrixSetURI = tiling_schemes[0].tileMatrixSetURI
+ crs = tiling_schemes[0].crs
+ # Checking the selected matrix in configured tiling_schemes
+ for schema in tiling_schemes:
+ if (schema.tileMatrixSet == tileset):
+ crs = schema.crs
+ tileMatrixSetURI = schema.tileMatrixSetURI
+
+ tiling_scheme_url = url_join(
+ server_url, f'/TileMatrixSets/{schema.tileMatrixSet}')
+ tiling_scheme_url_type = "application/json"
+ tiling_scheme_url_title = f'{schema.tileMatrixSet} tile matrix set definition' # noqa
+
+ tiling_scheme = LinkType(href=tiling_scheme_url,
+ rel="http://www.opengis.net/def/rel/ogc/1.0/tiling-scheme", # noqa
+ type_=tiling_scheme_url_type,
+ title=tiling_scheme_url_title)
+
+ if tiling_scheme is None:
+ msg = f'Could not identify a valid tiling schema' # noqa
+ LOGGER.error(msg)
+ raise ProviderConnectionError(msg)
+
+ content = TileSetMetadata(title=title, description=description,
+ keywords=keywords, crs=crs,
+ tileMatrixSetURI=tileMatrixSetURI)
+
+ links = []
+ service_url_link_type = "application/vnd.mapbox-vector-tile"
+ service_url_link_title = f'{tileset} vector tiles for {layer}'
+ service_url_link = LinkType(href=service_url, rel="item",
+ type_=service_url_link_type,
+ title=service_url_link_title)
+
+ links.append(tiling_scheme)
+ links.append(service_url_link)
+
+ content.links = links
+
+ return content.dict(exclude_none=True)
diff --git a/pygeoapi/provider/mvt_tippecanoe.py b/pygeoapi/provider/mvt_tippecanoe.py
index 207524153..2cf855903 100644
--- a/pygeoapi/provider/mvt_tippecanoe.py
+++ b/pygeoapi/provider/mvt_tippecanoe.py
@@ -40,7 +40,7 @@
ProviderGenericError)
from pygeoapi.provider.base_mvt import BaseMVTProvider
from pygeoapi.models.provider.base import (
- TileSetMetadata, LinkType)
+ TileSetMetadata, TileMatrixSetEnum, LinkType)
from pygeoapi.models.provider.mvt import MVTTilesJson
from pygeoapi.util import is_url, url_join
@@ -72,14 +72,13 @@ def __init__(self, BaseMVTProvider):
if is_url(self.data):
url = urlparse(self.data)
baseurl = f'{url.scheme}://{url.netloc}'
- extension = Path(url.path).suffix # e.g. ".pbf"
layer = f'/{self.get_layer()}'
LOGGER.debug('Extracting layer name from URL')
LOGGER.debug(f'Layer: {layer}')
tilepath = f'{layer}/tiles'
- servicepath = f'{tilepath}/{{tileMatrixSetId}}/{{tileMatrix}}/{{tileRow}}/{{tileCol}}{extension}' # noqa
+ servicepath = f'{tilepath}/{{tileMatrixSetId}}/{{tileMatrix}}/{{tileRow}}/{{tileCol}}?f=mvt' # noqa
self._service_url = url_join(baseurl, servicepath)
@@ -137,6 +136,13 @@ def get_layer(self):
else:
return Path(self.data).name
+ def get_tiling_schemes(self):
+
+ "Only WebMercatorQuad tiling scheme is supported in elastic"
+ return [
+ TileMatrixSetEnum.WEBMERCATORQUAD.value
+ ]
+
def get_tiles_service(self, baseurl=None, servicepath=None,
dirpath=None, tile_type=None):
"""
@@ -180,6 +186,12 @@ def get_tiles_from_url(self, layer=None, tileset=None,
with requests.Session() as session:
session.get(base_url)
resp = session.get(f'{base_url}/{layer}/{z}/{y}/{x}{extension}') # noqa
+
+ if resp.status_code == 404:
+ if (self.is_in_limits(TileMatrixSetEnum.WEBMERCATORQUAD.value, z, x, y)): # noqa
+ return None
+ raise ProviderTileNotFoundError
+
resp.raise_for_status()
return resp.content
except requests.exceptions.RequestException as e:
@@ -208,6 +220,8 @@ def get_tiles_from_disk(self, layer=None, tileset=None,
with open(service_url_path, mode='rb') as tile:
return tile.read()
except FileNotFoundError as err:
+ if (self.is_in_limits(TileMatrixSetEnum.WEBMERCATORQUAD.value, z, x, y)): # noqa
+ return None
raise ProviderTileNotFoundError(err)
def get_tiles(self, layer=None, tileset=None,
@@ -289,7 +303,7 @@ def get_html_metadata(self, dataset, server_url, layer, tileset,
content.tiles = service_url
content.vector_layers = json.loads(
metadata_json_content["json"])["vector_layers"]
- metadata['metadata'] = content.model_dump()
+ metadata['metadata'] = content.dict()
# Some providers may not implement tilejson metadata
metadata['tilejson_url'] = f'{metadata_url}?f=tilejson'
except ProviderConnectionError:
@@ -326,7 +340,7 @@ def get_default_metadata(self, dataset, server_url, layer, tileset,
tiling_scheme = LinkType(href=tiling_scheme_url,
rel="http://www.opengis.net/def/rel/ogc/1.0/tiling-scheme", # noqa
- type=tiling_scheme_url_type,
+ type_=tiling_scheme_url_type,
title=tiling_scheme_url_title)
if tiling_scheme is None:
@@ -343,7 +357,7 @@ def get_default_metadata(self, dataset, server_url, layer, tileset,
service_url_link_type = "application/vnd.mapbox-vector-tile"
service_url_link_title = f'{tileset} vector tiles for {layer}'
service_url_link = LinkType(href=service_url, rel="item",
- type=service_url_link_type,
+ type_=service_url_link_type,
title=service_url_link_title)
links.append(tiling_scheme)
@@ -351,7 +365,7 @@ def get_default_metadata(self, dataset, server_url, layer, tileset,
content.links = links
- return content.model_dump(exclude_none=True)
+ return content.dict(exclude_none=True)
def get_vendor_metadata(self, dataset, server_url, layer, tileset,
title, description, keywords, **kwargs):
@@ -370,7 +384,7 @@ def get_vendor_metadata(self, dataset, server_url, layer, tileset,
content.tiles = service_url
content.vector_layers = json.loads(
metadata_json_content["json"])["vector_layers"]
- return content.model_dump()
+ return content.dict()
except ProviderConnectionError:
msg = f'No tiles metadata json available: {self.service_metadata_url}' # noqa
LOGGER.error(msg)
diff --git a/pygeoapi/provider/ogr.py b/pygeoapi/provider/ogr.py
index 1584e6dcc..3132c2cb8 100644
--- a/pygeoapi/provider/ogr.py
+++ b/pygeoapi/provider/ogr.py
@@ -188,7 +188,7 @@ def __init__(self, provider_def):
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 @@ def get_fields(self):
: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=[],
diff --git a/pygeoapi/provider/opensearch_.py b/pygeoapi/provider/opensearch_.py
new file mode 100644
index 000000000..3e84de0e8
--- /dev/null
+++ b/pygeoapi/provider/opensearch_.py
@@ -0,0 +1,569 @@
+# =================================================================
+#
+# Authors: Tom Kralidis
+# Francesco Bartoli
+#
+# 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
+
+from pygeofilter.backends.opensearch import to_filter
+
+from pygeoapi.provider.base import (BaseProvider, ProviderConnectionError,
+ ProviderQueryError,
+ ProviderItemNotFoundError)
+from pygeoapi.util import 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}')
+ 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' {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' {self.data}'
+
+
+def update_query(input_query: Dict, cql):
+ s = Search.from_dict(input_query)
+ s = s.query(to_filter(cql))
+
+ LOGGER.debug(f'Enhanced query: {json.dumps(s.to_dict())}')
+ return s.to_dict()
diff --git a/pygeoapi/provider/oracle.py b/pygeoapi/provider/oracle.py
index b899e154a..c8c973774 100644
--- a/pygeoapi/provider/oracle.py
+++ b/pygeoapi/provider/oracle.py
@@ -1,9 +1,10 @@
# =================================================================
#
# Authors: Andreas Kosubek
+# Authors: Moritz Langer
#
# Copyright (c) 2023 Andreas Kosubek
-#
+# Copyright (c) 2024 Moritz Langer
# 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
@@ -30,9 +31,12 @@
import importlib
import json
import logging
+import os
+import threading
+from typing import Optional
+
import oracledb
import pyproj
-from typing import Optional
from pygeoapi.api import DEFAULT_STORAGE_CRS
@@ -54,6 +58,41 @@ class DatabaseConnection:
"""Database connection class to be used as 'with' statement.
The class returns a connection object.
"""
+ pool = None # Class-level connection pool
+ lock = threading.Lock()
+
+ @classmethod
+ def create_pool(cls, conn_dict, oracle_pool_min, oracle_pool_max):
+ """Initialize the connection pool for the class
+ Lock is implemented before function call at __init__"""
+ dsn = cls._make_dsn(conn_dict)
+
+ 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
def __init__(self, conn_dic, table, properties=[], context="query"):
"""
@@ -88,106 +127,137 @@ def __init__(self, conn_dic, table, properties=[], context="query"):
)
self.properties = [item.lower() for item in properties]
self.fields = {} # Dict of columns. Key is col name, value is type
- self.conn = None
-
- def __enter__(self):
- try:
- if self.conn_dict.get("init_oracle_client", False):
- oracledb.init_oracle_client()
-
- # Connect with tnsnames.ora entry and Login with Oracle Wallet
- if self.conn_dict.get("external_auth") == "wallet":
- LOGGER.debug(
- "Oracle connect with tnsnames.ora entry \
- and login with Oracle Wallet"
- )
-
- if "tns_name" not in self.conn_dict:
- raise ProviderConnectionError(
- "tns_name must be set for external authentication!"
+ oracle_pool_min = int(os.environ.get('ORACLE_POOL_MIN', 0))
+ oracle_pool_max = int(os.environ.get('ORACLE_POOL_MAX', 0))
+ # Initialize the connection pool if it hasn't been initialized
+ if oracle_pool_min and oracle_pool_max:
+ LOGGER.debug("Found environment variables for session pooling:")
+ LOGGER.debug(f"ORACLE_POOL_MIN: {oracle_pool_min}")
+ LOGGER.debug(f"ORACLE_POOL_MAX: {oracle_pool_max}")
+ with DatabaseConnection.lock:
+ if DatabaseConnection.pool is None:
+ LOGGER.debug(f"self.conn_dict contains {self.conn_dict}")
+ DatabaseConnection.pool = DatabaseConnection.create_pool(
+ self.conn_dict, oracle_pool_min, oracle_pool_max
+ )
+ LOGGER.debug(
+ "Initialized connection pool with "
+ f"{DatabaseConnection.pool.max} connections"
)
- dsn = self.conn_dict["tns_name"]
+ @staticmethod
+ def _make_dsn(conn_dict):
+ if conn_dict.get("init_oracle_client", False):
+ oracledb.init_oracle_client()
- # Connect with SERVICE_NAME
- if "service_name" in self.conn_dict:
- LOGGER.debug(
- f"Oracle connect with service_name: \
- {self.conn_dict['service_name']}"
+ # Connect with tnsnames.ora entry and Login with Oracle Wallet
+ if conn_dict.get("external_auth") == "wallet":
+ LOGGER.debug(
+ "Oracle connect with tnsnames.ora entry \
+ and login with Oracle Wallet"
+ )
+ if "tns_name" not in conn_dict:
+ raise ProviderConnectionError(
+ "tns_name must be set for external authentication!"
)
- if "host" not in self.conn_dict:
- raise ProviderConnectionError(
- "Host must be set for connection with service_name!"
- )
+ dsn = conn_dict["tns_name"]
- dsn = oracledb.makedsn(
- self.conn_dict["host"],
- self.conn_dict.get("port", 1521),
- service_name=self.conn_dict["service_name"],
- )
+ # Connect with SERVICE_NAME
+ if "service_name" in conn_dict:
+ LOGGER.debug(
+ f"Oracle connect with service_name: \
+ {conn_dict['service_name']}"
+ )
- # Connect with SID
- elif "sid" in self.conn_dict:
- LOGGER.debug(
- f"Oracle connect with sid: {self.conn_dict['sid']}"
+ if "host" not in conn_dict:
+ raise ProviderConnectionError(
+ "Host must be set for connection with service_name!"
)
- if "host" not in self.conn_dict:
- raise ProviderConnectionError(
- "Host must be set for connection with sid!"
- )
-
- dsn = oracledb.makedsn(
- self.conn_dict["host"],
- self.conn_dict.get("port", 1521),
- sid=self.conn_dict["sid"],
- )
+ dsn = oracledb.makedsn(
+ conn_dict["host"],
+ conn_dict.get("port", 1521),
+ service_name=conn_dict["service_name"],
+ )
- # Connect with tnsnames.ora entry
- elif "tns_name" in self.conn_dict:
- LOGGER.debug(
- f"Oracle connect with tns_name: \
- {self.conn_dict['tns_name']}"
- )
- dsn = self.conn_dict["tns_name"]
+ # Connect with SID
+ elif "sid" in conn_dict:
+ LOGGER.debug(
+ f"Oracle connect with sid: {conn_dict['sid']}"
+ )
- else:
+ if "host" not in conn_dict:
raise ProviderConnectionError(
- "One of service_name, sid or tns_name must be specified!"
+ "Host must be set for connection with sid!"
)
- LOGGER.debug(f"Oracle DSN string: {dsn}")
+ dsn = oracledb.makedsn(
+ conn_dict["host"],
+ conn_dict.get("port", 1521),
+ sid=conn_dict["sid"],
+ )
- # Connect with tnsnames.ora entry and Login with Oracle Wallet
- if self.conn_dict.get("external_auth") == "wallet":
- self.conn = oracledb.connect(externalauth=True, dsn=dsn)
+ # Connect with tnsnames.ora entry
+ elif "tns_name" in conn_dict:
+ LOGGER.debug(
+ f"Oracle connect with tns_name: \
+ {conn_dict['tns_name']}"
+ )
+ dsn = conn_dict["tns_name"]
- # Connect with tnsnames.ora entry,
- # TNS_ADMIN is set via configuration
- if "tns_admin" in self.conn_dict:
- self.conn = oracledb.connect(
- user=self.conn_dict["user"],
- password=self.conn_dict["password"],
- dsn=dsn,
- config_dir=self.conn_dict["tns_admin"],
- )
+ else:
+ raise ProviderConnectionError(
+ "One of service_name, sid or tns_name must be specified!"
+ )
+
+ LOGGER.debug(f"Oracle DSN string: {dsn}")
- # Connect with user / password via dsn string
- # When dsn is a TNS name, the environment variable TNS_ADMIN must
- # be set (Path to tnsnames.ora file)
+ return dsn
+
+ def __enter__(self):
+ """Acquires a connection from the pool."""
+ try:
+ if DatabaseConnection.pool:
+ self.conn = DatabaseConnection.pool.acquire()
+ LOGGER.debug("Connection acquired from pool .")
+ LOGGER.debug(f"Connection from pool is {self.conn}.")
else:
- self.conn = oracledb.connect(
- user=self.conn_dict["user"],
- password=self.conn_dict["password"],
- dsn=dsn,
- )
+ dsn = self._make_dsn(self.conn_dict)
+ LOGGER.debug(f"Created dsn for single connection with params: {dsn}") # noqa
+ # Connect with tnsnames.ora entry and Login with Oracle Wallet # noqa
+ if self.conn_dict.get("external_auth") == "wallet":
+ self.conn = oracledb.connect(externalauth=True, dsn=dsn)
+
+ # Connect with tnsnames.ora entry,
+ # TNS_ADMIN is set via configuration
+ if "tns_admin" in self.conn_dict:
+ self.conn = oracledb.connect(
+ user=self.conn_dict["user"],
+ password=self.conn_dict["password"],
+ dsn=dsn,
+ config_dir=self.conn_dict["tns_admin"],
+ )
+
+ # Connect with user / password via dsn string
+ # When dsn is a TNS name, the environment variable TNS_ADMIN must # noqa
+ # be set (Path to tnsnames.ora file)
+ else:
+ self.conn = oracledb.connect(
+ user=self.conn_dict["user"],
+ password=self.conn_dict["password"],
+ dsn=dsn,
+ )
except oracledb.DatabaseError as e:
- LOGGER.error(
- f"Couldn't connect to Oracle using:{str(self.conn_dict)}"
- )
- LOGGER.error(e)
+ if DatabaseConnection.pool:
+ LOGGER.error("Couldn't acquire a connection from the pool.")
+ LOGGER.error(e)
+ else:
+ LOGGER.error(
+ f"Couldn't connect to Oracle using:{str(self.conn_dict)}"
+ )
+ LOGGER.error(e)
raise ProviderConnectionError(e)
# Check if table name has schema/owner inside
@@ -204,36 +274,47 @@ def __enter__(self):
LOGGER.debug("Table: " + table)
if self.context == "query":
- column_list = self._get_table_columns(schema, table)
-
- # When self.properties is set, then the result would be filtered
- if self.properties:
- column_list = [
- col
- for col in column_list
- if col[0].lower()
- in [item.lower() for item in self.properties]
- ]
-
- # Concatenate column names with ', '
- self.columns = ", ".join([item[0].lower() for item in column_list])
+ columns = dict(self._get_table_columns(schema, table))
# Populate dictionary for columns with column type
- for k, v in dict(column_list).items():
+ # NOTE: we want all columns available here because they are
+ # used for filtering in the where clause, not only
+ # the ones that are returned to the client.
+ for k, v in columns.items():
self.fields[k.lower()] = {"type": v}
+ filtered_columns = set(self.fields)
+ if self.properties:
+ filtered_columns &= {k.lower() for k in self.properties}
+
+ # fields which are part of the output
+ self.filtered_fields = {
+ k: v for k, v in self.fields.items() if k in filtered_columns
+ }
+
return self
def __exit__(self, exc_type, exc_val, exc_tb):
- # some logic to commit/rollback
- self.conn.close()
+ """
+ Releases the connection back to the pool.
+ """
+ try:
+ if DatabaseConnection.pool:
+ DatabaseConnection.pool.release(self.conn)
+ LOGGER.debug("Connection released back to pool.")
+ else:
+ self.conn.close()
+ LOGGER.debug("Single Connection closed")
+ except oracledb.DatabaseError as e:
+ LOGGER.error("Error closing the connection.")
+ LOGGER.error(e)
def _get_table_columns(self, schema, table):
"""
Returns an array with all column names and data types
from Oracle table ALL_TAB_COLUMNS.
Lookup for public and private synonyms.
- Throws ProviderGenericError when table not exist or accesable.
+ Throws ProviderGenericError when table not exist or accessible.
"""
sql = """
@@ -327,6 +408,7 @@ def __init__(self, provider_def):
self.geom = provider_def["geom_field"]
self.properties = [item.lower() for item in self.properties]
self.mandatory_properties = provider_def.get("mandatory_properties")
+ self.extra_properties = provider_def.get("extra_properties", [])
# SQL manipulator properties
self.sql_manipulator = provider_def.get("sql_manipulator")
@@ -367,12 +449,12 @@ def get_fields(self):
"""
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,
@@ -496,6 +578,12 @@ def _get_orderby(self, sortby):
return f"ORDER BY {','.join(ret)}"
+ def _get_extra_columns_expression(self):
+ """Returns part of SELECT clause for extra properties"""
+ return "".join(
+ f", {e_prop}" for e_prop in self.extra_properties
+ )
+
def _output_type_handler(
self, cursor, name, default_type, size, precision, scale
):
@@ -525,6 +613,38 @@ def _get_srid_from_crs(self, crs):
return srid
+ def _process_query_with_sql_manipulator_sup(
+ self, db, sql_query, bind_variables, extra_params, **query_args
+ ):
+ """
+ Apply the SQL manipulation plugin to process the SQL query.
+
+ :param db: Database connection instance
+ :param sql_query: The SQL query to process
+ :param bind_variables: Query bind variables
+ :param extra_params: Additional parameters for manipulation
+ :param query_args: Other dynamic arguments required for processing
+ :return: Processed SQL query and bind variables
+ """
+ if self.sql_manipulator:
+ LOGGER.debug(f"sql_manipulator: {self.sql_manipulator}")
+ manipulation_class = _class_factory(self.sql_manipulator)
+
+ # Pass all arguments to the process_query method
+ sql_query, bind_variables = manipulation_class.process_query(
+ db=db,
+ sql_query=sql_query,
+ bind_variables=bind_variables,
+ sql_manipulator_options=self.sql_manipulator_options,
+ **query_args,
+ extra_params=extra_params,
+ )
+
+ for placeholder in ["#HINTS#", "#JOIN#", "#WHERE#"]:
+ sql_query = sql_query.replace(placeholder, "")
+
+ return sql_query, bind_variables
+
def query(
self,
offset=0,
@@ -559,6 +679,19 @@ def query(
: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)
@@ -574,8 +707,6 @@ def query(
f"Missing mandatory filter property: {mand_col}"
)
- # GA customisation - unindented until Line 755 (pagination)
- # if resulttype == "hits":
with DatabaseConnection(
self.conn_dic,
self.table,
@@ -596,9 +727,36 @@ def query(
# because of getFields ...
sql_query = f"SELECT COUNT(1) AS hits \
FROM {self.table} \
- {where_dict['clause']}"
+ {where_dict['clause']} #WHERE#"
+
+ # Assign where_dict["properties"] to bind_variables
+ bind_variables = {**where_dict["properties"]}
+
+ # Default values for the process_query function (sql_manipulator)
+ query_args = {
+ "offset": offset,
+ "limit": limit,
+ "resulttype": resulttype,
+ "bbox": bbox,
+ "datetime_": datetime_,
+ "properties": properties,
+ "sortby": sortby,
+ "skip_geometry": skip_geometry,
+ "select_properties": select_properties,
+ "crs_transform_spec": crs_transform_spec,
+ "q": q,
+ "language": language,
+ "filterq": filterq,
+ }
+
+ # Apply the SQL manipulation plugin
+ extra_params["geom"] = self.geom
+ sql_query, bind_variables = self._process_query_with_sql_manipulator_sup( # noqa: E501
+ db, sql_query, bind_variables, extra_params, **query_args
+ )
+
try:
- cursor.execute(sql_query, where_dict["properties"])
+ cursor.execute(sql_query, bind_variables)
except oracledb.Error as err:
LOGGER.error(
f"Error executing sql_query: {sql_query}: {err}"
@@ -608,9 +766,6 @@ def query(
hits = cursor.fetchone()[0]
LOGGER.debug(f"hits: {str(hits)}")
- # GA customisation - desable the return below (pagination)
- # return self._response_feature_hits(hits)
-
with DatabaseConnection(
self.conn_dic, self.table, properties=self.properties
) as db:
@@ -621,10 +776,10 @@ def query(
# Create column list.
# Uses columns field that was generated in the Connection class
# or the configured columns from the Yaml file.
- props = (
- db.columns
- if select_properties == []
- else ", ".join([p for p in select_properties])
+ props = ", ".join(
+ select_properties
+ if select_properties
+ else db.filtered_fields
)
where_dict = self._get_where_clauses(
@@ -659,7 +814,7 @@ def query(
LOGGER.debug(f"target_srid: {target_srid}")
# Build geometry column call
- # When a different output CRS is definded, the geometry
+ # When a different output CRS is defined, the geometry
# geometry column would be transformed.
if skip_geometry:
geom = ""
@@ -682,14 +837,16 @@ def query(
# SQL manipulation class
paging_bind = {}
if limit > 0:
- sql_query = f"SELECT #HINTS# {props} {geom} \
+ sql_query = f"SELECT #HINTS# {props} \
+ {self._get_extra_columns_expression()} {geom} \
FROM {self.table} t1 #JOIN# \
{where_dict['clause']} #WHERE# \
{orderby} \
OFFSET :offset ROWS FETCH NEXT :limit ROWS ONLY"
paging_bind = {"offset": offset, "limit": limit}
else:
- sql_query = f"SELECT #HINTS# {props} {geom} \
+ sql_query = f"SELECT #HINTS# {props} \
+ {self._get_extra_columns_expression()} {geom} \
FROM {self.table} t1 #JOIN# \
{where_dict['clause']} #WHERE# \
{orderby}"
@@ -697,35 +854,10 @@ def query(
# Create dictionary for sql bind variables
bind_variables = {**where_dict["properties"], **paging_bind}
- # SQL manipulation plugin
- if self.sql_manipulator:
- LOGGER.debug("sql_manipulator: " + self.sql_manipulator)
- manipulation_class = _class_factory(self.sql_manipulator)
- sql_query, bind_variables = manipulation_class.process_query(
- db,
- sql_query,
- bind_variables,
- self.sql_manipulator_options,
- offset,
- limit,
- resulttype,
- bbox,
- datetime_,
- properties,
- sortby,
- skip_geometry,
- select_properties,
- crs_transform_spec,
- q,
- language,
- filterq,
- )
-
- # Clean up placeholders that aren't used by the
- # manipulation class.
- sql_query = sql_query.replace("#HINTS#", "")
- sql_query = sql_query.replace("#JOIN#", "")
- sql_query = sql_query.replace("#WHERE#", "")
+ # Apply the SQL manipulation plugin
+ sql_query, bind_variables = self._process_query_with_sql_manipulator_sup( # noqa: E501
+ db, sql_query, bind_variables, extra_params, **query_args
+ )
LOGGER.debug(f"SQL Query: {sql_query}")
LOGGER.debug(f"Bind variables: {bind_variables}")
@@ -850,7 +982,7 @@ def get(self, identifier, crs_transform_spec=None, **kwargs):
LOGGER.debug(f"target_srid: {target_srid}")
# Build geometry column call
- # When a different output CRS is definded, the geometry
+ # When a different output CRS is defined, the geometry
# geometry column would be transformed.
if source_srid != target_srid:
crs_dict = {"target_srid": target_srid}
@@ -862,7 +994,10 @@ def get(self, identifier, crs_transform_spec=None, **kwargs):
else:
geom_sql = f", t1.{self.geom}.get_geojson() AS geometry "
- sql_query = f"SELECT {db.columns} {geom_sql} \
+ columns = ", ".join(db.filtered_fields)
+ sql_query = f"SELECT {columns} \
+ {self._get_extra_columns_expression()} \
+ {geom_sql} \
FROM {self.table} t1 \
WHERE {self.id_field} = :in_id"
@@ -975,15 +1110,13 @@ def create(self, request_data):
columns = [
col
for col in columns
- if col.lower() in [field.lower() for field in self.fields]
+ if col.lower() in db.filtered_fields
]
- # Flter function to get only properties who are
+ # Filter function to get only properties who are
# in the column list
def filter_binds(pair):
- return pair[0].lower() in [
- field.lower() for field in self.fields
- ]
+ return pair[0].lower() in db.filtered_fields
# Filter bind variables
bind_variables = dict(
@@ -1074,15 +1207,13 @@ def update(self, identifier, request_data):
columns = [
col
for col in columns
- if col.lower() in [field.lower() for field in self.fields]
+ if col.lower() in db.filtered_fields
]
- # Flter function to get only properties who are
+ # Filter function to get only properties who are
# in the column list
def filter_binds(pair):
- return pair[0].lower() in [
- field.lower() for field in self.fields
- ]
+ return pair[0].lower() in db.filtered_fields
# Filter bind variables
bind_variables = dict(
diff --git a/pygeoapi/provider/parquet.py b/pygeoapi/provider/parquet.py
new file mode 100644
index 000000000..1d6314c27
--- /dev/null
+++ b/pygeoapi/provider/parquet.py
@@ -0,0 +1,458 @@
+# =================================================================
+#
+# Authors: Leo Ghignone
+#
+# 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(' 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' {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
diff --git a/pygeoapi/provider/postgresql.py b/pygeoapi/provider/postgresql.py
index 47d9b7e59..6a52881ad 100644
--- a/pygeoapi/provider/postgresql.py
+++ b/pygeoapi/provider/postgresql.py
@@ -6,11 +6,13 @@
# John A Stevenson
# Colin Blackburn
# Francesco Bartoli
+# Bernhard Mallinger
#
# Copyright (c) 2018 Jorge Samuel Mendes de Jesus
-# Copyright (c) 2023 Tom Kralidis
+# Copyright (c) 2025 Tom Kralidis
# Copyright (c) 2022 John A Stevenson and Colin Blackburn
# Copyright (c) 2023 Francesco Bartoli
+# Copyright (c) 2024 Bernhard Mallinger
#
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation
@@ -48,29 +50,33 @@
# gunzip < tests/data/hotosm_bdi_waterways.sql.gz |
# psql -U postgres -h 127.0.0.1 -p 5432 test
+from copy import deepcopy
+from datetime import datetime
+from decimal import Decimal
+import functools
import logging
-from copy import deepcopy
from geoalchemy2 import Geometry # noqa - this isn't used explicitly but is needed to process Geometry columns
from geoalchemy2.functions import ST_MakeEnvelope
-from geoalchemy2.shape import to_shape
+from geoalchemy2.shape import to_shape, from_shape
from pygeofilter.backends.sqlalchemy.evaluate import to_filter
import pyproj
import shapely
-from sqlalchemy import create_engine, MetaData, PrimaryKeyConstraint, asc, desc
+from sqlalchemy import create_engine, MetaData, PrimaryKeyConstraint, asc, \
+ desc, delete
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_
from pygeoapi.provider.base import BaseProvider, \
- ProviderConnectionError, ProviderQueryError, ProviderItemNotFoundError
+ ProviderConnectionError, ProviderInvalidDataError, ProviderQueryError, \
+ ProviderItemNotFoundError
from pygeoapi.util import get_transform_from_crs
-_ENGINE_STORE = {}
-_TABLE_MODEL_STORE = {}
LOGGER = logging.getLogger(__name__)
@@ -102,14 +108,36 @@ def __init__(self, provider_def):
LOGGER.debug(f'ID field: {self.id_field}')
LOGGER.debug(f'Geometry field: {self.geom}')
+ # conforming to the docs:
+ # https://docs.pygeoapi.io/en/latest/data-publishing/ogcapi-features.html#connection-examples # noqa
+ self.storage_crs = provider_def.get(
+ 'storage_crs',
+ 'https://www.opengis.net/def/crs/OGC/0/CRS84'
+ )
+ LOGGER.debug(f'Configured Storage CRS: {self.storage_crs}')
+
# Read table information from database
options = None
if provider_def.get('options'):
options = provider_def['options']
self._store_db_parameters(provider_def['data'], options)
- self._engine, self.table_model = self._get_engine_and_table_model()
+ self._engine = get_engine(
+ self.db_host,
+ self.db_port,
+ self.db_name,
+ self.db_user,
+ self._db_password,
+ **(self.db_options or {})
+ )
+ self.table_model = get_table_model(
+ self.table,
+ self.id_field,
+ self.db_search_path,
+ self._engine
+ )
+
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=[],
@@ -140,6 +168,7 @@ def query(self, offset=0, limit=10, resulttype='results',
property_filters = self._get_property_filters(properties)
cql_filters = self._get_cql_filters(filterq)
bbox_filter = self._get_bbox_filter(bbox)
+ time_filter = self._get_datetime_filter(datetime_)
order_by_clauses = self._get_order_by_clauses(sortby, self.table_model)
selected_properties = self._select_properties_clause(select_properties,
skip_geometry)
@@ -151,15 +180,10 @@ def query(self, offset=0, limit=10, resulttype='results',
.filter(property_filters)
.filter(cql_filters)
.filter(bbox_filter)
- .order_by(*order_by_clauses)
- .options(selected_properties)
- .offset(offset))
+ .filter(time_filter)
+ .options(selected_properties))
matched = results.count()
- if limit < matched:
- returned = limit
- else:
- returned = matched
LOGGER.debug(f'Found {matched} result(s)')
@@ -168,14 +192,16 @@ def query(self, offset=0, limit=10, resulttype='results',
'type': 'FeatureCollection',
'features': [],
'numberMatched': matched,
- 'numberReturned': returned
+ 'numberReturned': 0
}
if resulttype == "hits" or not results:
- response['numberReturned'] = 0
return response
+
crs_transform_out = self._get_crs_transform(crs_transform_spec)
- for item in results.limit(limit):
+
+ for item in results.order_by(*order_by_clauses).offset(offset).limit(limit): # noqa
+ response['numberReturned'] += 1
response['features'].append(
self._sqlalchemy_to_feature(item, crs_transform_out)
)
@@ -188,39 +214,64 @@ def get_fields(self):
:returns: dict of fields
"""
+
LOGGER.debug('Get available fields/properties')
# 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
column_type_map = {
- str: 'string',
+ bool: 'boolean',
+ datetime: 'string',
+ Decimal: 'number',
+ dict: 'object',
float: 'number',
int: 'integer',
- bool: 'boolean',
+ str: 'string'
+ }
+ default_type = 'string'
+
+ # https://json-schema.org/understanding-json-schema/reference/string#built-in-formats # noqa
+ column_format_map = {
+ 'date': 'date',
+ 'interval': 'duration',
+ 'time': 'time',
+ 'timestamp': 'date-time'
}
- default_value = 'string'
def _column_type_to_json_schema_type(column_type):
try:
python_type = column_type.python_type
except NotImplementedError:
LOGGER.warning(f'Unsupported column type {column_type}')
- return default_value
+ return default_type
else:
try:
return column_type_map[python_type]
except KeyError:
LOGGER.warning(f'Unsupported column type {column_type}')
- return default_value
+ return default_type
- return {
- str(column.name): {
- 'type': _column_type_to_json_schema_type(column.type)
- }
- for column in self.table_model.__table__.columns
- if column.name != self.geom # Exclude geometry column
- }
+ def _column_format_to_json_schema_format(column_type):
+ try:
+ ct = str(column_type).lower()
+ return column_format_map[ct]
+ except KeyError:
+ LOGGER.debug('No string format detected')
+ return None
+
+ if not self._fields:
+ for column in self.table_model.__table__.columns:
+ LOGGER.debug(f'Testing {column.name}')
+ if column.name == self.geom:
+ continue
+
+ self._fields[str(column.name)] = {
+ 'type': _column_type_to_json_schema_type(column.type),
+ 'format': _column_format_to_json_schema_format(column.type)
+ }
+
+ return self._fields
def get(self, identifier, crs_transform_spec=None, **kwargs):
"""
@@ -269,120 +320,76 @@ def get(self, identifier, crs_transform_spec=None, **kwargs):
return feature
- def _store_db_parameters(self, parameters, options):
- self.db_user = parameters.get('user')
- self.db_host = parameters.get('host')
- self.db_port = parameters.get('port', 5432)
- self.db_name = parameters.get('dbname')
- self.db_search_path = parameters.get('search_path', ['public'])
- self._db_password = parameters.get('password')
- self.db_options = options
-
- def _get_engine_and_table_model(self):
+ def create(self, item):
"""
- Create a SQL Alchemy engine for the database and reflect the table
- model. Use existing versions from stores if available to allow reuse
- of Engine connection pool and save expensive table reflection.
+ Create a new item
+
+ :param item: `dict` of new item
+
+ :returns: identifier of created item
"""
- # One long-lived engine is used per database URL:
- # https://docs.sqlalchemy.org/en/14/core/connections.html#basic-usage
- engine_store_key = (self.db_user, self.db_host, self.db_port,
- self.db_name)
- try:
- engine = _ENGINE_STORE[engine_store_key]
- except KeyError:
- conn_str = URL.create(
- 'postgresql+psycopg2',
- username=self.db_user,
- password=self._db_password,
- host=self.db_host,
- port=self.db_port,
- database=self.db_name
- )
- conn_args = {
- 'client_encoding': 'utf8',
- 'application_name': 'pygeoapi'
- }
- if self.db_options:
- conn_args.update(self.db_options)
- engine = create_engine(
- conn_str,
- connect_args=conn_args,
- pool_pre_ping=True)
- _ENGINE_STORE[engine_store_key] = engine
-
- # Reuse table model if one exists
- table_model_store_key = (self.db_host, self.db_port, self.db_name,
- self.table)
- try:
- table_model = _TABLE_MODEL_STORE[table_model_store_key]
- except KeyError:
- table_model = self._reflect_table_model(engine)
- _TABLE_MODEL_STORE[table_model_store_key] = table_model
- return engine, table_model
+ identifier, json_data = self._load_and_prepare_item(
+ item, accept_missing_identifier=True)
+
+ new_instance = self._feature_to_sqlalchemy(json_data, identifier)
+ with Session(self._engine) as session:
+ session.add(new_instance)
+ session.commit()
+ result_id = getattr(new_instance, self.id_field)
+
+ # NOTE: need to use id from instance in case it's generated
+ return result_id
- def _reflect_table_model(self, engine):
+ def update(self, identifier, item):
"""
- Reflect database metadata to create a SQL Alchemy model corresponding
- to target table. This requires a database query and is expensive to
- perform.
+ Updates an existing item
+
+ :param identifier: feature id
+ :param item: `dict` of partial or full item
+
+ :returns: `bool` of update result
"""
- metadata = MetaData()
- # Look for table in the first schema in the search path
- try:
- schema = self.db_search_path[0]
- metadata.reflect(
- bind=engine, schema=schema, only=[self.table], views=True)
- except OperationalError:
- msg = (f"Could not connect to {repr(engine.url)} "
- "(password hidden).")
- raise ProviderConnectionError(msg)
- except InvalidRequestError:
- msg = (f"Table '{self.table}' not found in schema '{schema}' "
- f"on {repr(engine.url)}.")
- raise ProviderQueryError(msg)
-
- # Create SQLAlchemy model from reflected table
- # It is necessary to add the primary key constraint because SQLAlchemy
- # requires it to reflect the table, but a view in a PostgreSQL database
- # does not have a primary key defined.
- sqlalchemy_table_def = metadata.tables[f'{schema}.{self.table}']
- try:
- sqlalchemy_table_def.append_constraint(
- PrimaryKeyConstraint(self.id_field)
- )
- except KeyError:
- msg = (f"No such id_field column ({self.id_field}) on "
- f"{schema}.{self.table}.")
- raise ProviderQueryError(msg)
-
- Base = automap_base(metadata=metadata)
- Base.prepare(
- name_for_scalar_relationship=self._name_for_scalar_relationship,
- )
- TableModel = getattr(Base.classes, self.table)
+ identifier, json_data = self._load_and_prepare_item(
+ item, raise_if_exists=False)
+
+ new_instance = self._feature_to_sqlalchemy(json_data, identifier)
+ with Session(self._engine) as session:
+ session.merge(new_instance)
+ session.commit()
- return TableModel
+ return True
- @staticmethod
- def _name_for_scalar_relationship(
- base, local_cls, referred_cls, constraint,
- ):
- """Function used when automapping classes and relationships from
- database schema and fixes potential naming conflicts.
+ def delete(self, identifier):
"""
- name = referred_cls.__name__.lower()
- local_table = local_cls.__table__
- if name in local_table.columns:
- newname = name + '_'
- LOGGER.debug(
- f'Already detected column name {name!r} in table '
- f'{local_table!r}. Using {newname!r} for relationship name.'
+ Deletes an existing item
+
+ :param identifier: item id
+
+ :returns: `bool` of deletion result
+ """
+ with Session(self._engine) as session:
+ id_column = getattr(self.table_model, self.id_field)
+ result = session.execute(
+ delete(self.table_model)
+ .where(id_column == identifier)
)
- return newname
- return name
+ session.commit()
+
+ return result.rowcount > 0
+
+ def _store_db_parameters(self, parameters, options):
+ self.db_user = parameters.get('user')
+ self.db_host = parameters.get('host')
+ self.db_port = parameters.get('port', 5432)
+ self.db_name = parameters.get('dbname')
+ # db_search_path gets converted to a tuple here in order to ensure it
+ # is hashable - which allows us to use functools.cache() when
+ # reflecting the table definition from the DB
+ self.db_search_path = tuple(parameters.get('search_path', ['public']))
+ self._db_password = parameters.get('password')
+ self.db_options = options
def _sqlalchemy_to_feature(self, item, crs_transform_out=None):
feature = {
@@ -408,6 +415,26 @@ def _sqlalchemy_to_feature(self, item, crs_transform_out=None):
return feature
+ def _feature_to_sqlalchemy(self, json_data, identifier=None):
+ attributes = {**json_data['properties']}
+ # 'identifier' key maybe be present in geojson properties, but might
+ # not be a valid db field
+ attributes.pop('identifier', None)
+ attributes[self.geom] = from_shape(
+ shapely.geometry.shape(json_data['geometry']),
+ # NOTE: for some reason, postgis in the github action requires
+ # explicit crs information. i think it's valid to assume 4326:
+ # https://portal.ogc.org/files/108198#feature-crs
+ srid=pyproj.CRS.from_user_input(self.storage_crs).to_epsg()
+ )
+ attributes[self.id_field] = identifier
+
+ try:
+ return self.table_model(**attributes)
+ except Exception as e:
+ LOGGER.exception('Failed to create db model')
+ raise ProviderInvalidDataError(str(e))
+
def _get_order_by_clauses(self, sort_by, table_model):
# Build sort_by clauses if provided
clauses = []
@@ -459,6 +486,29 @@ def _get_bbox_filter(self, bbox):
return bbox_filter
+ def _get_datetime_filter(self, datetime_):
+ if datetime_ in (None, '../..'):
+ return True
+ else:
+ if self.time_field is None:
+ LOGGER.error('time_field not enabled for collection')
+ raise ProviderQueryError()
+
+ time_column = getattr(self.table_model, self.time_field)
+
+ if '/' in datetime_: # envelope
+ LOGGER.debug('detected time range')
+ time_begin, time_end = datetime_.split('/')
+ if time_begin == '..':
+ datetime_filter = time_column <= time_end
+ elif time_end == '..':
+ datetime_filter = time_column >= time_begin
+ else:
+ datetime_filter = time_column.between(time_begin, time_end)
+ else:
+ datetime_filter = time_column == datetime_
+ return datetime_filter
+
def _select_properties_clause(self, select_properties, skip_geometry):
# List the column names that we want
if select_properties:
@@ -495,3 +545,91 @@ def _get_crs_transform(self, crs_transform_spec=None):
else:
crs_transform = None
return crs_transform
+
+
+@functools.cache
+def get_engine(
+ host: str,
+ port: str,
+ database: str,
+ user: str,
+ password: str,
+ **connection_options
+):
+ """Create SQL Alchemy engine."""
+ conn_str = URL.create(
+ 'postgresql+psycopg2',
+ username=user,
+ password=password,
+ host=host,
+ port=int(port),
+ database=database
+ )
+ conn_args = {
+ 'client_encoding': 'utf8',
+ 'application_name': 'pygeoapi',
+ **connection_options,
+ }
+ engine = create_engine(
+ conn_str,
+ connect_args=conn_args,
+ pool_pre_ping=True)
+ return engine
+
+
+@functools.cache
+def get_table_model(
+ table_name: str,
+ id_field: str,
+ db_search_path: tuple[str],
+ engine,
+):
+ """Reflect table."""
+ metadata = MetaData()
+
+ # Look for table in the first schema in the search path
+ schema = db_search_path[0]
+ try:
+ metadata.reflect(
+ bind=engine, schema=schema, only=[table_name], views=True)
+ except OperationalError:
+ raise ProviderConnectionError(
+ f"Could not connect to {repr(engine.url)} (password hidden).")
+ except InvalidRequestError:
+ raise ProviderQueryError(
+ f"Table '{table_name}' not found in schema '{schema}' "
+ f"on {repr(engine.url)}."
+ )
+
+ # Create SQLAlchemy model from reflected table
+ # It is necessary to add the primary key constraint because SQLAlchemy
+ # requires it to reflect the table, but a view in a PostgreSQL database
+ # does not have a primary key defined.
+ sqlalchemy_table_def = metadata.tables[f'{schema}.{table_name}']
+ try:
+ sqlalchemy_table_def.append_constraint(PrimaryKeyConstraint(id_field))
+ except (ConstraintColumnNotFoundError, KeyError):
+ raise ProviderQueryError(
+ f"No such id_field column ({id_field}) on {schema}.{table_name}.")
+
+ _Base = automap_base(metadata=metadata)
+ _Base.prepare(
+ name_for_scalar_relationship=_name_for_scalar_relationship,
+ )
+ return getattr(_Base.classes, table_name)
+
+
+def _name_for_scalar_relationship(base, local_cls, referred_cls, constraint):
+ """Function used when automapping classes and relationships from
+ database schema and fixes potential naming conflicts.
+ """
+ name = referred_cls.__name__.lower()
+ local_table = local_cls.__table__
+ if name in local_table.columns:
+ newname = name + '_'
+ LOGGER.debug(
+ f'Already detected column name {name!r} in table '
+ f'{local_table!r}. Using {newname!r} for relationship name.'
+ )
+ return newname
+ return name
diff --git a/pygeoapi/provider/rasterio_.py b/pygeoapi/provider/rasterio_.py
index 7414fcc3f..3b0fbc2c7 100644
--- a/pygeoapi/provider/rasterio_.py
+++ b/pygeoapi/provider/rasterio_.py
@@ -2,7 +2,7 @@
#
# Authors: Tom Kralidis
#
-# Copyright (c) 2022 Tom Kralidis
+# Copyright (c) 2024 Tom Kralidis
#
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation
@@ -59,107 +59,39 @@ def __init__(self, provider_def):
self.axes = self._coverage_properties['axes']
self.crs = self._coverage_properties['bbox_crs']
self.num_bands = self._coverage_properties['num_bands']
- self.fields = [str(num) for num in range(1, self.num_bands+1)]
+ self.get_fields()
self.native_format = provider_def['format']['name']
except Exception as err:
LOGGER.warning(err)
raise ProviderConnectionError(err)
- def get_coverage_domainset(self, *args, **kwargs):
- """
- Provide coverage domainset
- :returns: CIS JSON object of domainset metadata
- """
-
- domainset = {
- 'type': 'DomainSet',
- 'generalGrid': {
- 'type': 'GeneralGridCoverage',
- 'srsName': self._coverage_properties['bbox_crs'],
- 'axisLabels': [
- self._coverage_properties['x_axis_label'],
- self._coverage_properties['y_axis_label']
- ],
- 'axis': [{
- 'type': 'RegularAxis',
- 'axisLabel': self._coverage_properties['x_axis_label'],
- 'lowerBound': self._coverage_properties['bbox'][0],
- 'upperBound': self._coverage_properties['bbox'][2],
- 'uomLabel': self._coverage_properties['bbox_units'],
- 'resolution': self._coverage_properties['resx']
- }, {
- 'type': 'RegularAxis',
- 'axisLabel': self._coverage_properties['y_axis_label'],
- 'lowerBound': self._coverage_properties['bbox'][1],
- 'upperBound': self._coverage_properties['bbox'][3],
- 'uomLabel': self._coverage_properties['bbox_units'],
- 'resolution': self._coverage_properties['resy']
- }],
- 'gridLimits': {
- 'type': 'GridLimits',
- 'srsName': 'http://www.opengis.net/def/crs/OGC/0/Index2D',
- 'axisLabels': ['i', 'j'],
- 'axis': [{
- 'type': 'IndexAxis',
- 'axisLabel': 'i',
- 'lowerBound': 0,
- 'upperBound': self._coverage_properties['width']
- }, {
- 'type': 'IndexAxis',
- 'axisLabel': 'j',
- 'lowerBound': 0,
- 'upperBound': self._coverage_properties['height']
- }]
- }
- },
- '_meta': {
- 'tags': self._coverage_properties['tags']
- }
- }
-
- return domainset
+ def get_fields(self):
+ 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)
- def get_coverage_rangetype(self, *args, **kwargs):
- """
- Provide coverage rangetype
- :returns: CIS JSON object of rangetype metadata
- """
-
- rangetype = {
- 'type': 'DataRecord',
- 'field': []
- }
-
- for i, dtype, nodataval in zip(self._data.indexes, self._data.dtypes,
- self._data.nodatavals):
- LOGGER.debug(f'Determing rangetype for band {i}')
-
- name, units = None, None
- if self._data.units[i-1] is None:
parameter = _get_parameter_metadata(
self._data.profile['driver'], self._data.tags(i))
+
name = parameter['description']
- units = parameter['unit_label']
-
- rangetype['field'].append({
- 'id': i,
- 'type': 'Quantity',
- 'name': name,
- 'encodingInfo': {
- 'dataType': f'http://www.opengis.net/def/dataType/OGC/0/{dtype}' # noqa
- },
- 'nodata': nodataval,
- 'uom': {
- 'id': f'http://www.opengis.net/def/uom/UCUM/{units}',
- 'type': 'UnitReference',
- 'code': units
- },
- '_meta': {
- 'tags': self._data.tags(i)
+ units = parameter.get('unit_label')
+
+ dtype2 = dtype
+ if dtype.startswith('float'):
+ dtype2 = 'number'
+ elif dtype.startswith('int'):
+ dtype2 = 'integer'
+
+ self._fields[i2] = {
+ 'title': name,
+ 'type': dtype2,
+ '_meta': self._data.tags(i)
}
- })
+ if units is not None:
+ self._fields[i2]['x-ogc-unit'] = units
- return rangetype
+ return self._fields
def query(self, properties=[], subsets={}, bbox=None, bbox_crs=4326,
datetime_=None, format_='json', **kwargs):
@@ -310,16 +242,15 @@ def query(self, properties=[], subsets={}, bbox=None, bbox_crs=4326,
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)
-
- else: # return data in native format
+ 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
+ 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()
diff --git a/pygeoapi/provider/sensorthings.py b/pygeoapi/provider/sensorthings.py
index 6b497b44b..4d99705bb 100644
--- a/pygeoapi/provider/sensorthings.py
+++ b/pygeoapi/provider/sensorthings.py
@@ -3,7 +3,7 @@
# Authors: Benjamin Webb
# Authors: Tom Kralidis
#
-# Copyright (c) 2023 Benjamin Webb
+# Copyright (c) 2025 Benjamin Webb
# Copyright (c) 2022 Tom Kralidis
#
# Permission is hereby granted, free of charge, to any person
@@ -30,14 +30,18 @@
# =================================================================
from json.decoder import JSONDecodeError
-import os
import logging
from requests import Session
+from requests.exceptions import ConnectionError
+from urllib.parse import urlparse
+from pygeoapi.config import get_config
from pygeoapi.provider.base import (
- BaseProvider, ProviderQueryError, ProviderConnectionError)
+ BaseProvider, ProviderQueryError, ProviderConnectionError,
+ ProviderInvalidDataError)
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,
+ get_typed_value)
LOGGER = logging.getLogger(__name__)
@@ -51,10 +55,10 @@
_EXPAND = {
'Things': 'Locations,Datastreams',
'Observations': 'Datastream,FeatureOfInterest',
+ 'ObservedProperties': 'Datastreams/Thing/Locations',
'Datastreams': """
Sensor
,ObservedProperty
- ,Thing
,Thing/Locations
,Observations(
$select=@iot.id;
@@ -71,6 +75,7 @@
class SensorThingsProvider(BaseProvider):
"""SensorThings API (STA) Provider"""
+ expand = EXPAND
def __init__(self, provider_def):
"""
@@ -82,63 +87,11 @@ def __init__(self, provider_def):
: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}')
- 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
- }
+ self._generate_mappings(provider_def)
+ LOGGER.debug(f'STA endpoint: {self.data}, Entity: {self.entity}')
# Start session
self.http = Session()
@@ -150,22 +103,26 @@ def get_fields(self):
:returns: dict of fields
"""
- if not self.fields:
- r = self._get_response(self._url, {'$top': 1})
+ if not self._fields:
try:
+ r = self._get_response(self._url, {'$top': 1})
results = r['value'][0]
except IndexError:
LOGGER.warning('could not get fields; returning empty set')
return {}
+ except (ConnectionError, ProviderConnectionError):
+ msg = f'Unable to contact SensorThings endpoint at {self._url}'
+ LOGGER.error(msg)
+ raise ProviderConnectionError(msg)
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',
@@ -205,6 +162,65 @@ def get(self, identifier, **kwargs):
response = self._get_response(f'{self._url}({identifier})')
return self._make_feature(response)
+ def create(self, item):
+ """
+ Create a new item
+
+ :param item: `dict` of new item
+
+ :returns: identifier of created item
+ """
+ response = self.http.post(self._url, json=item)
+
+ if response.status_code == 201:
+ location = response.headers.get("Location")
+ iotid = location[location.find("(")+1:location.find(")")]
+
+ LOGGER.debug(f'Feature created with @iot.id: {iotid}')
+ return get_typed_value(iotid)
+ else:
+ msg = f"Failed to create item: {response.text}"
+ raise ProviderInvalidDataError(msg)
+
+ 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
+ """
+ id = f"'{identifier}'" \
+ if isinstance(identifier, str) else str(identifier)
+ LOGGER.debug(f'Updating @iot.id: {id}')
+ response = self.http.put(f"{self._url}({id})", json=item)
+
+ if response.status_code == 200:
+ return True
+ else:
+ msg = f'Failed to update item: {response.text}'
+ raise ProviderConnectionError(msg)
+
+ def delete(self, identifier):
+ """
+ Deletes an existing item
+
+ :param identifier: item id
+
+ :returns: `bool` of deletion result
+ """
+ id = f"'{identifier}'" \
+ if isinstance(identifier, str) else str(identifier)
+ LOGGER.debug(f'Deleting @iot.id: {id}')
+ response = self.http.delete(f"{self._url}({id})")
+
+ if response.status_code == 200:
+ return True
+ else:
+ msg = f"Failed to delete item: {response.text}"
+ raise ProviderConnectionError(msg)
+
def _load(self, offset=0, limit=10, resulttype='results',
bbox=[], datetime_=None, properties=[], sortby=[],
select_properties=[], skip_geometry=False, q=None):
@@ -258,8 +274,13 @@ def _load(self, offset=0, limit=10, resulttype='results',
v = response.get('value')
while len(v) < limit:
try:
+ # Ensure we only use provided network location
+ next_ = urlparse(response['@iot.nextLink'])._replace(
+ scheme=self.parsed_url.scheme,
+ netloc=self.parsed_url.netloc
+ ).geturl()
+
LOGGER.debug('Fetching next set of values')
- next_ = response['@iot.nextLink']
response = self._get_response(next_)
v.extend(response['value'])
except (ProviderConnectionError, KeyError):
@@ -272,17 +293,19 @@ def _load(self, offset=0, limit=10, resulttype='results',
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 +313,35 @@ def _make_feature(self, entity, select_properties=[], skip_geometry=False):
# 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 +357,15 @@ def _get_response(self, url, params={}):
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 +377,8 @@ def _make_filter(self, properties, bbox=[], datetime_=None):
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 +397,20 @@ def _make_filter(self, properties, bbox=[], datetime_=None):
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 +431,90 @@ def _make_orderby(self, sortby):
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']
+ try:
+ if self.uri_field is not None:
+ uri = feature['properties'][self.uri_field]
+ except KeyError:
+ msg = f'Unable to find uri field: {self.uri_field}'
+ LOGGER.error(msg)
+ raise ProviderInvalidDataError(msg)
# 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
+ if self.uri_field is not None:
+ feature[self.uri_field] = uri
- return entity
+ return feature
@staticmethod
def _expand_location(entity):
@@ -517,5 +566,74 @@ def _get_entity(uri):
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}')
+
+ self.parsed_url = urlparse(self.data)
+
+ # 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'
+
+ # Custom expand
+ if provider_def.get('expand'):
+ EXPAND[self.entity] = provider_def['expand']
+
+ # 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' {self.data}, {self.entity}'
diff --git a/pygeoapi/provider/sensorthings_edr.py b/pygeoapi/provider/sensorthings_edr.py
new file mode 100644
index 000000000..de943765e
--- /dev/null
+++ b/pygeoapi/provider/sensorthings_edr.py
@@ -0,0 +1,442 @@
+# =================================================================
+#
+# Authors: Ben Webb
+#
+# Copyright (c) 2025 Ben Webb
+#
+# 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 logging
+
+from pygeoapi.provider.base import ProviderNoDataError
+from pygeoapi.provider.base_edr import BaseEDRProvider
+from pygeoapi.provider.sensorthings import SensorThingsProvider
+
+LOGGER = logging.getLogger(__name__)
+
+GEOGRAPHIC_CRS = {
+ 'coordinates': ['x', 'y'],
+ 'system': {
+ 'type': 'GeographicCRS',
+ 'id': 'http://www.opengis.net/def/crs/OGC/1.3/CRS84'
+ }
+}
+
+TEMPORAL_RS = {
+ 'coordinates': ['t'],
+ 'system': {'type': 'TemporalRS', 'calendar': 'Gregorian'}
+}
+
+
+class SensorThingsEDRProvider(BaseEDRProvider, SensorThingsProvider):
+ def __init__(self, provider_def):
+ """
+ Initialize the SensorThingsEDRProvider instance.
+
+ :param provider_def: provider definition
+ """
+
+ provider_def['entity'] = 'ObservedProperties'
+ BaseEDRProvider.__init__(self, provider_def)
+ SensorThingsProvider.__init__(self, provider_def)
+ self.expand['ObservedProperties'] = (
+ 'Datastreams/Thing/Locations,Datastreams/Observations'
+ )
+ self.time_field = 'Datastreams/Observations/resultTime'
+ self._fields = {}
+ self.get_fields()
+
+ def get_fields(self):
+ """
+ Retrieve and store ObservedProperties field definitions.
+
+ :returns: A dictionary mapping field IDs to their properties.
+ """
+
+ if not self._fields:
+ r = self._get_response(
+ self._url, entity='ObservedProperties', expand='Datastreams'
+ )
+ try:
+ _ = r['value'][0]
+ except IndexError:
+ LOGGER.warning('could not get fields; returning empty set')
+ return {}
+
+ for feature in r['value']:
+ id = str(feature['@iot.id'])
+ key = feature['name']
+ try:
+ uom = feature['Datastreams'][0]['unitOfMeasurement']
+ except IndexError:
+ continue
+
+ self._fields[id] = {
+ 'type': 'number',
+ 'title': key,
+ 'x-ogc-unit': uom['symbol']
+ }
+
+ return self._fields
+
+ @BaseEDRProvider.register()
+ def items(self, **kwargs):
+ """
+ Retrieve a collection of items.
+
+ :param kwargs: Additional parameters for the request.
+ :returns: A GeoJSON representation of the items.
+ """
+
+ # This method is empty due to the way pygeoapi handles items requests
+ # We implement this method inside of the feature provider
+ pass
+
+ @BaseEDRProvider.register()
+ def locations(
+ self,
+ select_properties: list = [],
+ bbox: list = [],
+ datetime_: str = None,
+ location_id: str = None,
+ **kwargs
+ ):
+ """
+ Extract and return location data from ObservedProperties.
+
+ :param select_properties: List of properties to include.
+ :param bbox: Bounding box geometry for spatial queries.
+ :param datetime_: Temporal filter for observations.
+ :param location_id: Identifier of the location to filter by.
+
+ :returns: A GeoJSON FeatureCollection of locations.
+ """
+
+ fc = {'type': 'FeatureCollection', 'features': []}
+
+ params = {}
+ expand = [
+ 'Datastreams($select=description,name,unitOfMeasurement)',
+ 'Datastreams/Thing($select=@iot.id)',
+ 'Datastreams/Thing/Locations($select=location)'
+ ]
+
+ if select_properties:
+ properties = [
+ ['@iot.id', f"'{p}'" if isinstance(p, str) else p]
+ for p in select_properties
+ ]
+ ret = [f'{name} eq {value}' for (name, value) in properties]
+ params['$filter'] = ' or '.join(ret)
+
+ filter_ = f'$filter={self._make_dtf(datetime_)};' if datetime_ else ''
+ if location_id:
+ expand[0] = (
+ f'Datastreams($filter=Thing/@iot.id eq {location_id};'
+ '$select=description,name,unitOfMeasurement)'
+ )
+ expand.append(
+ f'Datastreams/Observations({filter_}$orderby=phenomenonTime;'
+ '$select=result,phenomenonTime,resultTime)'
+ )
+ else:
+ expand.append(
+ f'Datastreams/Observations({filter_}$select=result;$top=1)')
+
+ if bbox:
+ geom_filter = self._make_bbox(bbox, 'Datastreams')
+ expand[0] = f'Datastreams($filter={geom_filter})'
+
+ expand = ','.join(expand)
+ response = self._get_response(
+ url=self._url, params=params,
+ entity='ObservedProperties', expand=expand
+ )
+
+ if location_id:
+ return self._make_coverage_collection(response)
+
+ for property in response['value']:
+ for datastream in property['Datastreams']:
+ if len(datastream['Observations']) == 0:
+ continue
+
+ fc['features'].append(
+ self._make_feature(datastream['Thing'], entity='Things')
+ )
+
+ return fc
+
+ @BaseEDRProvider.register()
+ def cube(
+ self,
+ select_properties: list = [],
+ bbox: list = [],
+ datetime_: str = None,
+ **kwargs
+ ):
+ """
+ Extract and return coverage data from ObservedProperties.
+
+ :param select_properties: List of properties to include.
+ :param bbox: Bounding box geometry for spatial queries.
+ :param datetime_: Temporal filter for observations.
+
+ :returns: A CovJSON CoverageCollection.
+ """
+
+ params = {}
+
+ geom_filter = self._make_bbox(bbox, 'Datastreams')
+ expand = [
+ (
+ f'Datastreams($filter={geom_filter};'
+ '$select=description,name,unitOfMeasurement)'
+ ),
+ 'Datastreams/Thing($select=@iot.id)',
+ 'Datastreams/Thing/Locations($select=location)'
+ ]
+
+ if select_properties:
+ properties = [
+ ['@iot.id', f"'{p}'" if isinstance(p, str) else p]
+ for p in select_properties
+ ]
+ ret = [f'{name} eq {value}' for (name, value) in properties]
+ params['$filter'] = ' or '.join(ret)
+
+ filter_ = f'$filter={self._make_dtf(datetime_)};' if datetime_ else ''
+ expand.append(
+ f'Datastreams/Observations({filter_}$orderby=phenomenonTime;'
+ '$select=result,phenomenonTime,resultTime)'
+ )
+
+ expand = ','.join(expand)
+ response = self._get_response(
+ url=self._url, params=params,
+ entity='ObservedProperties', expand=expand
+ )
+
+ return self._make_coverage_collection(response)
+
+ @BaseEDRProvider.register()
+ def area(
+ self,
+ wkt: str,
+ select_properties: list = [],
+ datetime_: str = None,
+ **kwargs
+ ):
+ """
+ Extract and return coverage data from a specified area.
+
+ :param wkt: Well-Known Text (WKT) representation of the
+ geometry for the area.
+ :param select_properties: List of properties to include
+ in the response.
+ :param datetime_: Temporal filter for observations.
+
+ :returns: A CovJSON CoverageCollection.
+ """
+
+ params = {}
+
+ expand = [
+ (
+ 'Datastreams($filter=st_within('
+ f"Thing/Locations/location,geography'{wkt}');"
+ '$select=description,name,unitOfMeasurement)'
+ ),
+ 'Datastreams/Thing($select=@iot.id)',
+ 'Datastreams/Thing/Locations($select=location)'
+ ]
+
+ if select_properties:
+ properties = [
+ ['@iot.id', f"'{p}'" if isinstance(p, str) else p]
+ for p in select_properties
+ ]
+ ret = [f'{name} eq {value}' for (name, value) in properties]
+ params['$filter'] = ' or '.join(ret)
+
+ filter_ = f'$filter={self._make_dtf(datetime_)};' if datetime_ else ''
+ expand.append(
+ f'Datastreams/Observations({filter_}$orderby=phenomenonTime;'
+ '$select=result,phenomenonTime,resultTime)'
+ )
+
+ expand = ','.join(expand)
+ response = self._get_response(
+ url=self._url, params=params,
+ entity='ObservedProperties', expand=expand
+ )
+
+ return self._make_coverage_collection(response)
+
+ def _generate_coverage(self, datastream: dict, id: str) -> dict:
+ """
+ Generate a coverage object for a datastream.
+
+ :param datastream: The datastream data to generate coverage for.
+ :param id: The ID to use for the coverage.
+
+ :returns: A dict containing the coverage object.
+ """
+
+ times, values = self._expand_observations(datastream)
+ thing = datastream['Thing']
+ coords = thing['Locations'][0]['location']['coordinates']
+ length = len(values)
+
+ return {
+ 'type': 'Coverage',
+ 'id': str(thing['@iot.id']),
+ 'domain': {
+ 'type': 'Domain',
+ 'domainType': 'PointSeries',
+ 'axes': {
+ 'x': {'values': [coords[0]]},
+ 'y': {'values': [coords[1]]},
+ 't': {'values': times}
+ },
+ 'referencing': [GEOGRAPHIC_CRS, TEMPORAL_RS]
+ },
+ 'ranges': {
+ id: {
+ 'type': 'NdArray',
+ 'dataType': 'float',
+ 'axisNames': ['t'],
+ 'shape': [length],
+ 'values': values
+ }
+ }
+ }, length
+
+ @staticmethod
+ def _generate_paramters(datastream: dir, label: str) -> dict:
+ """
+ Generate parameters for a given datastream.
+
+ :param datastream: The datastream data to generate parameters for.
+ :param label: The label for the parameter.
+
+ :returns: A dictionary containing the parameter definition.
+ """
+
+ return {
+ 'type': 'Parameter',
+ 'description': {'en': datastream['description']},
+ 'observedProperty': {'id': label, 'label': {'en': label}},
+ 'unit': {
+ 'label': {'en': datastream['unitOfMeasurement']['name']},
+ 'symbol': datastream['unitOfMeasurement']['symbol']
+ }
+ }
+
+ @staticmethod
+ def _make_dtf(datetime_: str) -> str:
+ """
+ Create a Date-Time filter for querying.
+
+ :param datetime_: Temporal filter.
+
+ :returns: A string datetime filter for use in queries.
+ """
+
+ dtf_r = []
+ if '/' in datetime_:
+ time_start, time_end = datetime_.split('/')
+ if time_start != '..':
+ dtf_r.append(f'phenomenonTime ge {time_start}')
+
+ if time_end != '..':
+ dtf_r.append(f'phenomenonTime le {time_end}')
+
+ else:
+ dtf_r.append(f'phenomenonTime eq {datetime_}')
+
+ return ' and '.join(dtf_r)
+
+ def _make_coverage_collection(self, response):
+ """
+ Build a CoverageCollection from the SensorThings API response.
+
+ :param response: source response from SensorThings.
+
+ :returns: The updated CoverageCollection object.
+ """
+
+ cc = {
+ 'type': 'CoverageCollection',
+ 'domainType': 'PointSeries',
+ 'parameters': {},
+ 'coverages': []
+ }
+
+ for feature in response['value']:
+ if len(feature['Datastreams']) == 0:
+ continue
+
+ id = feature['name'].replace(' ', '+')
+
+ for datastream in feature['Datastreams']:
+ coverage, length = self._generate_coverage(datastream, id)
+ if length > 0:
+ cc['coverages'].append(coverage)
+ cc['parameters'][id] = self._generate_paramters(
+ datastream, feature['name']
+ )
+
+ if cc['parameters'] == {} or cc['coverages'] == []:
+ msg = 'No data found'
+ LOGGER.warning(msg)
+ raise ProviderNoDataError(msg)
+
+ return cc
+
+ @staticmethod
+ def _expand_observations(datastream: dict):
+ """
+ Expand and extract observation times and values from a datastream.
+
+ :param datastream: The datastream containing observations.
+
+ :returns: A tuple containing lists of times and values.
+ """
+
+ times = []
+ values = []
+ # TODO: Expand observations when 'Observations@iot.nextLink'
+ # or '@iot.nextLink' is present
+ for obs in datastream['Observations']:
+ resultTime = obs['resultTime'] or obs['phenomenonTime']
+ if obs['result'] is not None and resultTime:
+ try:
+ result = float(obs['result'])
+ except ValueError:
+ result = obs['result']
+ times.append(resultTime)
+ values.append(result)
+
+ return times, values
diff --git a/pygeoapi/provider/socrata.py b/pygeoapi/provider/socrata.py
index 0f402a735..9d2292549 100644
--- a/pygeoapi/provider/socrata.py
+++ b/pygeoapi/provider/socrata.py
@@ -75,7 +75,7 @@ def get_fields(self):
: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 @@ def get_fields(self):
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',
diff --git a/pygeoapi/provider/sqlite.py b/pygeoapi/provider/sqlite.py
index d7ae81d16..9ba796bb9 100644
--- a/pygeoapi/provider/sqlite.py
+++ b/pygeoapi/provider/sqlite.py
@@ -88,7 +88,7 @@ def get_fields(self):
: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,16 +100,16 @@ def get_fields(self):
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=[]):
"""
Generarates WHERE conditions to be implemented in query.
Private method mainly associated with query method.
- Method returns part of the SQL query, plus tupple to be used
+ Method returns part of the SQL query, plus tuple to be used
in the sqlite query method
:param properties: list of tuples (name, value)
diff --git a/pygeoapi/provider/tinydb_.py b/pygeoapi/provider/tinydb_.py
index 3bac4c059..502b6a385 100644
--- a/pygeoapi/provider/tinydb_.py
+++ b/pygeoapi/provider/tinydb_.py
@@ -2,7 +2,7 @@
#
# Authors: Tom Kralidis
#
-# Copyright (c) 2023 Tom Kralidis
+# Copyright (c) 2025 Tom Kralidis
#
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation
@@ -32,17 +32,20 @@
import os
import uuid
+from dateutil.parser import parse as parse_date
from shapely.geometry import shape
from tinydb import TinyDB, Query, where
from pygeoapi.provider.base import (BaseProvider, ProviderConnectionError,
+ ProviderInvalidQueryError,
ProviderItemNotFoundError)
+from pygeoapi.util import crs_transform, get_typed_value
LOGGER = logging.getLogger(__name__)
-class TinyDBCatalogueProvider(BaseProvider):
- """TinyDB Catalogue Provider"""
+class TinyDBProvider(BaseProvider):
+ """TinyDB Provider"""
def __init__(self, provider_def):
"""
@@ -50,15 +53,13 @@ def __init__(self, provider_def):
:param provider_def: provider definition
- :returns: pygeoapi.provider.tinydb_.TinyDBCatalogueProvider
+ :returns: pygeoapi.provider.tinydb_.TinyDBProvider
"""
- self.excludes = [
- '_metadata-anytext',
- ]
-
super().__init__(provider_def)
+ self._excludes = []
+
LOGGER.debug(f'Connecting to TinyDB db at {self.data}')
if not os.path.exists(self.data):
@@ -74,7 +75,7 @@ def __init__(self, provider_def):
else:
self.db = TinyDB(self.data)
- self.fields = self.get_fields()
+ self.get_fields()
def get_fields(self):
"""
@@ -83,22 +84,39 @@ def get_fields(self):
:returns: dict of fields
"""
- fields = {}
-
- try:
- r = self.db.all()[0]
- except IndexError as err:
- LOGGER.debug(err)
- return fields
-
- for p in r['properties'].keys():
- if p not in self.excludes:
- fields[p] = {'type': 'string'}
-
- fields['q'] = {'type': 'string'}
-
- return fields
-
+ if not self._fields:
+ try:
+ r = self.db.all()[0]
+ except IndexError as err:
+ LOGGER.debug(err)
+ return {}
+
+ 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'
+
+ 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',
bbox=[], datetime_=None, properties=[], sortby=[],
select_properties=[], skip_geometry=False, q=None, **kwargs):
@@ -164,11 +182,13 @@ def query(self, offset=0, limit=10, resulttype='results',
if properties:
LOGGER.debug('processing properties')
for prop in properties:
- QUERY.append(f"(Q.properties['{prop[0]}']=='{prop[1]}')")
+ if isinstance(prop[1], str):
+ value = f"'{prop[1]}'"
+ else:
+ value = prop[1]
+ QUERY.append(f"(Q.properties['{prop[0]}']=={value})")
- if q is not None:
- for t in q.split():
- QUERY.append(f"(Q.properties['_metadata-anytext'].search('{t}', flags=re.IGNORECASE))") # noqa
+ QUERY = self._add_search_query(QUERY, q)
QUERY_STRING = '&'.join(QUERY)
LOGGER.debug(f'QUERY_STRING: {QUERY_STRING}')
@@ -178,7 +198,12 @@ def query(self, offset=0, limit=10, resulttype='results',
LOGGER.debug('querying database')
if len(QUERY) > 0:
LOGGER.debug(f'running eval on {SEARCH_STRING}')
- results = eval(SEARCH_STRING)
+ try:
+ results = eval(SEARCH_STRING)
+ except SyntaxError as err:
+ msg = 'Invalid query'
+ LOGGER.error(f'{msg}: {err}')
+ raise ProviderInvalidQueryError(msg)
else:
results = self.db.all()
@@ -188,7 +213,7 @@ def query(self, offset=0, limit=10, resulttype='results',
return feature_collection
for r in results:
- for e in self.excludes:
+ for e in self._excludes:
try:
del r['properties'][e]
except KeyError:
@@ -219,6 +244,7 @@ def query(self, offset=0, limit=10, resulttype='results',
return feature_collection
+ @crs_transform
def get(self, identifier, **kwargs):
"""
Get TinyDB document by id
@@ -235,7 +261,7 @@ def get(self, identifier, **kwargs):
if record is None:
raise ProviderItemNotFoundError('record does not exist')
- for e in self.excludes:
+ for e in self._excludes:
try:
del record['properties'][e]
except KeyError:
@@ -259,14 +285,7 @@ def create(self, item):
identifier = str(uuid.uuid4())
json_data["id"] = identifier
- try:
- json_data['properties']['_metadata-anytext'] = ''.join([
- json_data['properties']['title'],
- json_data['properties']['description']
- ])
- except KeyError:
- LOGGER.debug('Missing title and description')
- json_data['properties']['_metadata_anytext'] = ''
+ json_data = self._add_extra_fields(json_data)
LOGGER.debug(f'Inserting data with identifier {identifier}')
result = self.db.insert(json_data)
@@ -306,17 +325,71 @@ def delete(self, identifier):
return True
- def _bbox(input_bbox, record_bbox):
+ def _add_extra_fields(self, json_data: dict) -> dict:
"""
- Test whether one bbox intersects another
+ Helper function to add extra fields to an item payload
- :param input_bbox: `list` of minx,miny,maxx,maxy
- :param record_bbox: `list` of minx,miny,maxx,maxy
+ :param json_data: `dict` of JSON data
- :returns: `bool` of result
+ :returns: `dict` of updated JSON data
"""
- return True
+ return json_data
+
+ def _add_search_query(self, query: list, search_term: str = None) -> str:
+ """
+ Helper function to add extra query predicates
+
+ :param query: `list` of query predicates
+ :param search_term: `str` of search term
+
+ :returns: `list` of updated query predicates
+ """
+
+ return query
+
+ def __repr__(self):
+ return f'