Add Admin API (#1137)
* Add Admin API
- Create `admin.py` to serve as Admin API Core
- Create `flask_admin.py` to create flask blueprint for admin API
- Consolidate configuration getter
- Add Pathlib serializing
- Add docker example
* Add integration tests
- Amend admin example to allow writing to configuration. If FS is read only admin API does not work. Returns a 500 and logs `OSError: [Errno 30] Read-only file system: '/pygeoapi/local.config.yml' `
* Preserve env variables in configuration
* Use common accessor functions
- Use common configuration accessor methods for Django and Starlette
* GET returns raw config file
Return configuration with environment variables preserved on GET requests
* Safeguard env variables for root cfg view
"bind": {
"host": "localhost",
"port": "6000"
}
->
"bind": {
"host": "${HOST}",
"port": "${PORT}"
}
* Simplify admin HTML imports
- Use jinja recursion to expand the configuration
- Remove vue from templates
* Create admin API documentation
* Use render_item_value in admin template
* Add Admin API
- Create `admin.py` to serve as Admin API Core
- Create `flask_admin.py` to create flask blueprint for admin API
- Consolidate configuration getter
- Add Pathlib serializing
- Add docker example
* Update GitHub Actions deployment
* Update admin entrypoint
Update admin entrypoint to align with upstream pygeoapi implementation
* Make requested changes
Co-Authored-By: Tom Kralidis <tomkralidis@gmail.com>
* Amend test url
Co-Authored-By: Tom Kralidis <tomkralidis@gmail.com>
* Fix Admin CI tests
* Add PUT and PATCH for root configuration
- Add put and patch for root configuration
- Add CI tests for PUT and PATCH of root
* Update OpenAPI document wording
* Update entrypoint.sh
Replace tabs with spaces
* Remove unused step
Error from rebasing. Admin API tests are moved to their own job.
* Use jsonpatch
- Use debian supported packaging
- Use custom merge function
* Move test data location
* Create Starlette and Django app
- Fold flask_admin.py into flask_app.py
Co-Authored-By: Tom Kralidis <tomkralidis@gmail.com>
* Make requirements-admin.txt
Move admin dependencies to requirements-admin.txt
* Delete guiblock.html
* Update test count for STAC
Update expected test count for addt'l admin test data
* Relegate config warning to config.py
* Move admin tests out of example
* Delete admin docker example
* Update admin-api.rst
* Update pygeoapi-config-0.x.yml
* Update configuration.rst
* Update config.py
* Update admin.py
* Update admin.py
---------
Co-authored-by: Tom Kralidis <tomkralidis@gmail.com>
This commit is contained in:
@@ -94,6 +94,7 @@ jobs:
|
|||||||
- name: Install requirements 📦
|
- name: Install requirements 📦
|
||||||
run: |
|
run: |
|
||||||
pip3 install -r requirements.txt
|
pip3 install -r requirements.txt
|
||||||
|
pip3 install -r requirements-admin.txt
|
||||||
pip3 install -r requirements-starlette.txt
|
pip3 install -r requirements-starlette.txt
|
||||||
pip3 install -r requirements-dev.txt
|
pip3 install -r requirements-dev.txt
|
||||||
pip3 install -r requirements-provider.txt
|
pip3 install -r requirements-provider.txt
|
||||||
@@ -151,3 +152,44 @@ jobs:
|
|||||||
if: ${{ failure() }}
|
if: ${{ failure() }}
|
||||||
run: |
|
run: |
|
||||||
pip3 list -v
|
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() }}
|
||||||
|
run: |
|
||||||
|
pip3 list -v
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ ARG ADD_DEB_PACKAGES="\
|
|||||||
python3-elasticsearch \
|
python3-elasticsearch \
|
||||||
python3-fiona \
|
python3-fiona \
|
||||||
python3-gdal \
|
python3-gdal \
|
||||||
|
python3-jsonpatch \
|
||||||
python3-netcdf4 \
|
python3-netcdf4 \
|
||||||
python3-pandas \
|
python3-pandas \
|
||||||
python3-psycopg2 \
|
python3-psycopg2 \
|
||||||
@@ -121,6 +122,7 @@ RUN \
|
|||||||
|
|
||||||
# Install remaining pygeoapi deps
|
# Install remaining pygeoapi deps
|
||||||
&& pip3 install -r requirements-docker.txt \
|
&& pip3 install -r requirements-docker.txt \
|
||||||
|
&& pip3 install -r requirements-admin.txt \
|
||||||
|
|
||||||
# Install pygeoapi
|
# Install pygeoapi
|
||||||
&& pip3 install -e . \
|
&& pip3 install -e . \
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
.. _admin-api:
|
||||||
|
|
||||||
|
Admin API
|
||||||
|
=========
|
||||||
|
|
||||||
|
pygeoapi provides the ability to manage configuration through an API.
|
||||||
|
|
||||||
|
When enabled, :ref:`transactions` can be made on pygeoapi's configured resources. This allows for API based modification of the pygeoapi configuration.
|
||||||
|
|
||||||
|
The API is enabled with the following server configuration:
|
||||||
|
|
||||||
|
.. code-block:: yaml
|
||||||
|
|
||||||
|
server:
|
||||||
|
admin: true # boolean on whether to enable Admin API.
|
||||||
|
|
||||||
|
For pygeoapi to hot reload the configuration as changes are made, the pygeoapi configuration file must be included as
|
||||||
|
demonstrated for a gunicorn deployment of pygeoapi via flask:
|
||||||
|
|
||||||
|
.. code-block:: bash
|
||||||
|
|
||||||
|
gunicorn \
|
||||||
|
--workers ${WSGI_WORKERS} \
|
||||||
|
--worker-class=${WSGI_WORKER_CLASS} \
|
||||||
|
--timeout ${WSGI_WORKER_TIMEOUT} \
|
||||||
|
--name=${CONTAINER_NAME} \
|
||||||
|
--bind ${CONTAINER_HOST}:${CONTAINER_PORT} \
|
||||||
|
--reload \
|
||||||
|
--reload-extra-file ${PYGEOAPI_CONFIG} \
|
||||||
|
pygeoapi.flask_app:APP
|
||||||
@@ -50,6 +50,7 @@ For more information related to API design rules (the ``api_rules`` property in
|
|||||||
cors: true # boolean on whether server should support CORS
|
cors: true # boolean on whether server should support CORS
|
||||||
pretty_print: true # whether JSON responses should be pretty-printed
|
pretty_print: true # whether JSON responses should be pretty-printed
|
||||||
limit: 10 # server limit on number of items to return
|
limit: 10 # server limit on number of items to return
|
||||||
|
admin: false # whether to enable the Admin API
|
||||||
|
|
||||||
templates: # optional configuration to specify a different set of templates for HTML pages. Recommend using absolute paths. Omit this to use the default provided templates
|
templates: # optional configuration to specify a different set of templates for HTML pages. Recommend using absolute paths. Omit this to use the default provided templates
|
||||||
path: /path/to/jinja2/templates/folder # path to templates folder containing the Jinja2 template HTML files
|
path: /path/to/jinja2/templates/folder # path to templates folder containing the Jinja2 template HTML files
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ reference documentation on all aspects of the project.
|
|||||||
openapi
|
openapi
|
||||||
data-publishing/index
|
data-publishing/index
|
||||||
transactions
|
transactions
|
||||||
|
admin-api
|
||||||
plugins
|
plugins
|
||||||
html-templating
|
html-templating
|
||||||
crs
|
crs
|
||||||
|
|||||||
@@ -0,0 +1,623 @@
|
|||||||
|
# =================================================================
|
||||||
|
#
|
||||||
|
# Authors: Tom Kralidis <tomkralidis@gmail.com>
|
||||||
|
# Benjamin Webb <benjamin.miller.webb@gmail.com>
|
||||||
|
#
|
||||||
|
# Copyright (c) 2023 Tom Kralidis
|
||||||
|
# Copyright (c) 2023 Benjamin 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.
|
||||||
|
#
|
||||||
|
# =================================================================
|
||||||
|
|
||||||
|
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 pygeoapi.api import API, APIRequest, F_HTML, pre_process
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class Admin(API):
|
||||||
|
"""Admin object"""
|
||||||
|
|
||||||
|
PYGEOAPI_CONFIG = os.environ.get('PYGEOAPI_CONFIG')
|
||||||
|
PYGEOAPI_OPENAPI = os.environ.get('PYGEOAPI_OPENAPI')
|
||||||
|
|
||||||
|
def __init__(self, config, openapi):
|
||||||
|
"""
|
||||||
|
constructor
|
||||||
|
|
||||||
|
:param config: configuration dict
|
||||||
|
:param openapi: openapi dict
|
||||||
|
|
||||||
|
:returns: `pygeoapi.Admin` instance
|
||||||
|
"""
|
||||||
|
|
||||||
|
super().__init__(config, openapi)
|
||||||
|
|
||||||
|
def merge(self, obj1, obj2):
|
||||||
|
"""
|
||||||
|
Merge two dictionaries
|
||||||
|
|
||||||
|
:param obj1: `dict` of first object
|
||||||
|
:param obj2: `dict` of second object
|
||||||
|
|
||||||
|
:returns: `dict` of merged objects
|
||||||
|
"""
|
||||||
|
|
||||||
|
if isinstance(obj1, dict) and isinstance(obj2, dict):
|
||||||
|
merged = obj1.copy()
|
||||||
|
for key, value in obj2.items():
|
||||||
|
if key in merged:
|
||||||
|
merged[key] = self.merge(merged[key], value)
|
||||||
|
else:
|
||||||
|
merged[key] = value
|
||||||
|
return merged
|
||||||
|
elif isinstance(obj1, list) and isinstance(obj2, list):
|
||||||
|
return [self.merge(i1, i2) for i1, i2 in zip(obj1, obj2)]
|
||||||
|
else:
|
||||||
|
return obj2
|
||||||
|
|
||||||
|
def validate(self, config):
|
||||||
|
"""
|
||||||
|
Validate pygeoapi configuration and OpenAPI to file
|
||||||
|
|
||||||
|
:param config: configuration dict
|
||||||
|
"""
|
||||||
|
|
||||||
|
# validate pygeoapi configuration
|
||||||
|
LOGGER.debug('Validating configuration')
|
||||||
|
validate_config(config)
|
||||||
|
# validate OpenAPI document
|
||||||
|
# LOGGER.debug('Validating openapi document')
|
||||||
|
# oas = get_oas(config)
|
||||||
|
# validate_openapi_document(oas)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def write(self, config):
|
||||||
|
"""
|
||||||
|
Write pygeoapi configuration and OpenAPI to file
|
||||||
|
|
||||||
|
:param config: configuration dict
|
||||||
|
"""
|
||||||
|
|
||||||
|
self.write_config(config)
|
||||||
|
self.write_oas(config)
|
||||||
|
|
||||||
|
def write_config(self, config):
|
||||||
|
"""
|
||||||
|
Write pygeoapi configuration file
|
||||||
|
|
||||||
|
:param config: configuration dict
|
||||||
|
"""
|
||||||
|
|
||||||
|
# validate pygeoapi configuration
|
||||||
|
config = deepcopy(config)
|
||||||
|
validate_config(config)
|
||||||
|
|
||||||
|
# Preserve env variables
|
||||||
|
LOGGER.debug('Reading env variables in configuration')
|
||||||
|
raw_conf = get_config(raw=True)
|
||||||
|
conf = 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')
|
||||||
|
yaml_dump(config, self.PYGEOAPI_CONFIG)
|
||||||
|
LOGGER.debug('Finished writing pygeoapi configuration')
|
||||||
|
|
||||||
|
def write_oas(self, config):
|
||||||
|
"""
|
||||||
|
Write pygeoapi OpenAPI document
|
||||||
|
|
||||||
|
:param config: configuration dict
|
||||||
|
"""
|
||||||
|
|
||||||
|
# validate OpenAPI document
|
||||||
|
config = deepcopy(config)
|
||||||
|
oas = get_oas(config)
|
||||||
|
# validate_openapi_document(oas)
|
||||||
|
|
||||||
|
# write OpenAPI document
|
||||||
|
LOGGER.debug('Writing OpenAPI document')
|
||||||
|
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
|
||||||
|
|
||||||
|
:param request: request object
|
||||||
|
|
||||||
|
: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
|
||||||
|
|
||||||
|
: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()
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
: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['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
|
||||||
|
|
||||||
|
:param request: request object
|
||||||
|
|
||||||
|
: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()
|
||||||
|
|
||||||
|
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
|
||||||
+25
-3
@@ -33,17 +33,38 @@ import click
|
|||||||
import json
|
import json
|
||||||
from jsonschema import validate as jsonschema_validate
|
from jsonschema import validate as jsonschema_validate
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
import os
|
||||||
|
import yaml
|
||||||
|
|
||||||
from pygeoapi.util import to_json, yaml_load
|
from pygeoapi.util import to_json, yaml_load, THISDIR
|
||||||
|
|
||||||
LOGGER = logging.getLogger(__name__)
|
LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
THISDIR = Path(__file__).parent.resolve()
|
|
||||||
|
def get_config(raw: bool = False) -> dict:
|
||||||
|
"""
|
||||||
|
Get pygeoapi configurations
|
||||||
|
|
||||||
|
:param raw: `bool` over interpolation during config loading
|
||||||
|
|
||||||
|
:returns: `dict` of pygeoapi configuration
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not os.environ.get('PYGEOAPI_CONFIG'):
|
||||||
|
raise RuntimeError('PYGEOAPI_CONFIG environment variable not set')
|
||||||
|
|
||||||
|
with open(os.environ.get('PYGEOAPI_CONFIG'), encoding='utf8') as fh:
|
||||||
|
if raw:
|
||||||
|
CONFIG = yaml.safe_load(fh)
|
||||||
|
else:
|
||||||
|
CONFIG = yaml_load(fh)
|
||||||
|
|
||||||
|
return CONFIG
|
||||||
|
|
||||||
|
|
||||||
def load_schema() -> dict:
|
def load_schema() -> dict:
|
||||||
""" Reads the JSON schema YAML file. """
|
""" Reads the JSON schema YAML file. """
|
||||||
|
|
||||||
schema_file = THISDIR / 'schemas' / 'config' / 'pygeoapi-config-0.x.yml'
|
schema_file = THISDIR / 'schemas' / 'config' / 'pygeoapi-config-0.x.yml'
|
||||||
|
|
||||||
with schema_file.open() as fh2:
|
with schema_file.open() as fh2:
|
||||||
@@ -58,6 +79,7 @@ def validate_config(instance_dict: dict) -> bool:
|
|||||||
|
|
||||||
:returns: `bool` of validation
|
:returns: `bool` of validation
|
||||||
"""
|
"""
|
||||||
|
|
||||||
jsonschema_validate(json.loads(to_json(instance_dict)), load_schema())
|
jsonschema_validate(json.loads(to_json(instance_dict)), load_schema())
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ https://docs.djangoproject.com/en/2.2/ref/settings/
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
# pygeoapi specific
|
# pygeoapi specific
|
||||||
from pygeoapi.django_app import config
|
from pygeoapi.config import get_config
|
||||||
from pygeoapi.util import get_api_rules
|
from pygeoapi.util import get_api_rules
|
||||||
|
|
||||||
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
|
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
|
||||||
@@ -166,7 +166,7 @@ STATIC_ROOT = os.path.join(BASE_DIR, 'assets')
|
|||||||
STATIC_URL = '/static/'
|
STATIC_URL = '/static/'
|
||||||
|
|
||||||
# pygeoapi specific
|
# pygeoapi specific
|
||||||
PYGEOAPI_CONFIG = config()
|
PYGEOAPI_CONFIG = get_config()
|
||||||
|
|
||||||
API_RULES = get_api_rules(PYGEOAPI_CONFIG)
|
API_RULES = get_api_rules(PYGEOAPI_CONFIG)
|
||||||
|
|
||||||
|
|||||||
@@ -241,6 +241,28 @@ if url_route_prefix:
|
|||||||
path(url_route_prefix, include(urlpatterns))
|
path(url_route_prefix, include(urlpatterns))
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if settings.PYGEOAPI_CONFIG['server'].get('admin', False):
|
||||||
|
admin_urlpatterns = [
|
||||||
|
path(
|
||||||
|
apply_slash_rule('admin/config'),
|
||||||
|
views.admin_config,
|
||||||
|
name='admin-config'
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
apply_slash_rule('admin/config/resources'),
|
||||||
|
views.admin_config_resources,
|
||||||
|
name='admin-config-resources'
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
apply_slash_rule('admin/config/resources/<str:resource_id>'),
|
||||||
|
views.admin_config_resource,
|
||||||
|
name='admin-config-resource'
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
urlpatterns.extend(admin_urlpatterns)
|
||||||
|
|
||||||
|
|
||||||
# Add static URL and optionally add prefix (note: do NOT use django style here)
|
# Add static URL and optionally add prefix (note: do NOT use django style here)
|
||||||
url_static_prefix = settings.API_RULES.get_url_prefix()
|
url_static_prefix = settings.API_RULES.get_url_prefix()
|
||||||
urlpatterns += static(
|
urlpatterns += static(
|
||||||
|
|||||||
@@ -34,9 +34,12 @@
|
|||||||
# =================================================================
|
# =================================================================
|
||||||
|
|
||||||
"""Integration module for Django"""
|
"""Integration module for Django"""
|
||||||
|
|
||||||
from typing import Tuple, Dict, Mapping, Optional
|
from typing import Tuple, Dict, Mapping, Optional
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.http import HttpRequest, HttpResponse
|
from django.http import HttpRequest, HttpResponse
|
||||||
|
|
||||||
|
from pygeoapi.admin import Admin
|
||||||
from pygeoapi.api import API
|
from pygeoapi.api import API
|
||||||
|
|
||||||
|
|
||||||
@@ -485,12 +488,73 @@ def stac_catalog_search(request: HttpRequest) -> HttpResponse:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def admin_config(request: HttpRequest) -> HttpResponse:
|
||||||
|
"""
|
||||||
|
Admin landing page endpoint
|
||||||
|
|
||||||
|
:returns: HTTP response
|
||||||
|
"""
|
||||||
|
|
||||||
|
if request.method == 'GET':
|
||||||
|
return _feed_response(request, 'get_admin_config')
|
||||||
|
|
||||||
|
elif request.method == 'PUT':
|
||||||
|
return _feed_response(request, 'put_admin_config')
|
||||||
|
|
||||||
|
elif request.method == 'PATCH':
|
||||||
|
return _feed_response(request, 'patch_admin_config')
|
||||||
|
|
||||||
|
|
||||||
|
def admin_config_resources(request: HttpRequest) -> HttpResponse:
|
||||||
|
"""
|
||||||
|
Resource landing page endpoint
|
||||||
|
|
||||||
|
:returns: HTTP response
|
||||||
|
"""
|
||||||
|
|
||||||
|
if request.method == 'GET':
|
||||||
|
return _feed_response(request, 'get_admin_config_resources')
|
||||||
|
|
||||||
|
elif request.method == 'POST':
|
||||||
|
return _feed_response(request, 'put_admin_config_resources')
|
||||||
|
|
||||||
|
|
||||||
|
def admin_config_resource(request: HttpRequest,
|
||||||
|
resource_id: str) -> HttpResponse:
|
||||||
|
"""
|
||||||
|
Resource landing page endpoint
|
||||||
|
|
||||||
|
:returns: HTTP response
|
||||||
|
"""
|
||||||
|
|
||||||
|
if request.method == 'GET':
|
||||||
|
return _feed_response(request, 'put_admin_config_resource',
|
||||||
|
resource_id)
|
||||||
|
|
||||||
|
elif request.method == 'DELETE':
|
||||||
|
return _feed_response(request, 'delete_admin_config_resource',
|
||||||
|
resource_id)
|
||||||
|
|
||||||
|
elif request.method == 'PUT':
|
||||||
|
return _feed_response(request, 'put_admin_config_resource',
|
||||||
|
resource_id)
|
||||||
|
|
||||||
|
elif request.method == 'PATCH':
|
||||||
|
return _feed_response(request, 'patch_admin_config_resource',
|
||||||
|
resource_id)
|
||||||
|
|
||||||
|
|
||||||
def _feed_response(request: HttpRequest, api_definition: str,
|
def _feed_response(request: HttpRequest, api_definition: str,
|
||||||
*args, **kwargs) -> Tuple[Dict, int, str]:
|
*args, **kwargs) -> Tuple[Dict, int, str]:
|
||||||
"""Use pygeoapi api to process the input request"""
|
"""Use pygeoapi api to process the input request"""
|
||||||
|
|
||||||
api_ = API(settings.PYGEOAPI_CONFIG)
|
if 'admin' not in api_definition:
|
||||||
|
api_ = API(settings.PYGEOAPI_CONFIG)
|
||||||
|
else:
|
||||||
|
api_ = Admin(settings.PYGEOAPI_CONFIG)
|
||||||
|
|
||||||
api = getattr(api_, api_definition)
|
api = getattr(api_, api_definition)
|
||||||
|
|
||||||
return api(request, *args, **kwargs)
|
return api(request, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+2
-12
@@ -37,17 +37,7 @@ import os
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
from pygeoapi.config import get_config
|
||||||
def config():
|
|
||||||
from pygeoapi.util import yaml_load
|
|
||||||
|
|
||||||
if not os.environ.get('PYGEOAPI_CONFIG'):
|
|
||||||
raise RuntimeError('PYGEOAPI_CONFIG environment variable not set')
|
|
||||||
|
|
||||||
with open(os.environ.get('PYGEOAPI_CONFIG'), encoding='utf8') as fh:
|
|
||||||
CONFIG = yaml_load(fh)
|
|
||||||
|
|
||||||
return CONFIG
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
@@ -62,7 +52,7 @@ def main():
|
|||||||
'forget to activate a virtual environment?'
|
'forget to activate a virtual environment?'
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
CONFIG = config()
|
CONFIG = get_config()
|
||||||
|
|
||||||
bind = f"{CONFIG['server']['bind']['host']}:{CONFIG['server']['bind']['port']}" # noqa
|
bind = f"{CONFIG['server']['bind']['host']}:{CONFIG['server']['bind']['port']}" # noqa
|
||||||
|
|
||||||
|
|||||||
+66
-11
@@ -3,7 +3,7 @@
|
|||||||
# Authors: Tom Kralidis <tomkralidis@gmail.com>
|
# Authors: Tom Kralidis <tomkralidis@gmail.com>
|
||||||
# Norman Barker <norman.barker@gmail.com>
|
# Norman Barker <norman.barker@gmail.com>
|
||||||
#
|
#
|
||||||
# Copyright (c) 2022 Tom Kralidis
|
# Copyright (c) 2023 Tom Kralidis
|
||||||
#
|
#
|
||||||
# Permission is hereby granted, free of charge, to any person
|
# Permission is hereby granted, free of charge, to any person
|
||||||
# obtaining a copy of this software and associated documentation
|
# obtaining a copy of this software and associated documentation
|
||||||
@@ -36,20 +36,14 @@ import click
|
|||||||
|
|
||||||
from flask import Flask, Blueprint, make_response, request, send_from_directory
|
from flask import Flask, Blueprint, make_response, request, send_from_directory
|
||||||
|
|
||||||
|
from pygeoapi.admin import Admin
|
||||||
from pygeoapi.api import API
|
from pygeoapi.api import API
|
||||||
from pygeoapi.openapi import load_openapi_document
|
from pygeoapi.openapi import load_openapi_document
|
||||||
from pygeoapi.util import get_mimetype, yaml_load, get_api_rules
|
from pygeoapi.config import get_config
|
||||||
|
from pygeoapi.util import get_mimetype, get_api_rules
|
||||||
|
|
||||||
|
|
||||||
if 'PYGEOAPI_CONFIG' not in os.environ:
|
CONFIG = get_config()
|
||||||
raise RuntimeError('PYGEOAPI_CONFIG environment variable not set')
|
|
||||||
|
|
||||||
with open(os.environ.get('PYGEOAPI_CONFIG'), encoding='utf8') as fh:
|
|
||||||
CONFIG = yaml_load(fh)
|
|
||||||
|
|
||||||
if 'PYGEOAPI_OPENAPI' not in os.environ:
|
|
||||||
raise RuntimeError('PYGEOAPI_OPENAPI environment variable not set')
|
|
||||||
|
|
||||||
OPENAPI = load_openapi_document()
|
OPENAPI = load_openapi_document()
|
||||||
|
|
||||||
API_RULES = get_api_rules(CONFIG)
|
API_RULES = get_api_rules(CONFIG)
|
||||||
@@ -67,6 +61,7 @@ BLUEPRINT = Blueprint(
|
|||||||
static_folder=STATIC_FOLDER,
|
static_folder=STATIC_FOLDER,
|
||||||
url_prefix=API_RULES.get_url_prefix('flask')
|
url_prefix=API_RULES.get_url_prefix('flask')
|
||||||
)
|
)
|
||||||
|
ADMIN_BLUEPRINT = Blueprint('admin', __name__, static_folder=STATIC_FOLDER)
|
||||||
|
|
||||||
# CORS: optionally enable from config.
|
# CORS: optionally enable from config.
|
||||||
if CONFIG['server'].get('cors', False):
|
if CONFIG['server'].get('cors', False):
|
||||||
@@ -464,8 +459,68 @@ def stac_catalog_path(path):
|
|||||||
return get_response(api_.get_stac_path(request, path))
|
return get_response(api_.get_stac_path(request, path))
|
||||||
|
|
||||||
|
|
||||||
|
@ADMIN_BLUEPRINT.route('/admin/config', methods=['GET', 'PUT', 'PATCH'])
|
||||||
|
def admin_config():
|
||||||
|
"""
|
||||||
|
Admin endpoint
|
||||||
|
|
||||||
|
:returns: HTTP response
|
||||||
|
"""
|
||||||
|
|
||||||
|
if request.method == 'GET':
|
||||||
|
return get_response(admin_.get_config(request))
|
||||||
|
|
||||||
|
elif request.method == 'PUT':
|
||||||
|
return get_response(admin_.put_config(request))
|
||||||
|
|
||||||
|
elif request.method == 'PATCH':
|
||||||
|
return get_response(admin_.patch_config(request))
|
||||||
|
|
||||||
|
|
||||||
|
@ADMIN_BLUEPRINT.route('/admin/config/resources', methods=['GET', 'POST'])
|
||||||
|
def admin_config_resources():
|
||||||
|
"""
|
||||||
|
Resources endpoint
|
||||||
|
|
||||||
|
:returns: HTTP response
|
||||||
|
"""
|
||||||
|
|
||||||
|
if request.method == 'GET':
|
||||||
|
return get_response(admin_.get_resources(request))
|
||||||
|
|
||||||
|
elif request.method == 'POST':
|
||||||
|
return get_response(admin_.post_resource(request))
|
||||||
|
|
||||||
|
|
||||||
|
@ADMIN_BLUEPRINT.route(
|
||||||
|
'/admin/config/resources/<resource_id>',
|
||||||
|
methods=['GET', 'PUT', 'PATCH', 'DELETE'])
|
||||||
|
def admin_config_resource(resource_id):
|
||||||
|
"""
|
||||||
|
Resource endpoint
|
||||||
|
|
||||||
|
:returns: HTTP response
|
||||||
|
"""
|
||||||
|
|
||||||
|
if request.method == 'GET':
|
||||||
|
return get_response(admin_.get_resource(request, resource_id))
|
||||||
|
|
||||||
|
elif request.method == 'DELETE':
|
||||||
|
return get_response(admin_.delete_resource(request, resource_id))
|
||||||
|
|
||||||
|
elif request.method == 'PUT':
|
||||||
|
return get_response(admin_.put_resource(request, resource_id))
|
||||||
|
|
||||||
|
elif request.method == 'PATCH':
|
||||||
|
return get_response(admin_.patch_resource(request, resource_id))
|
||||||
|
|
||||||
|
|
||||||
APP.register_blueprint(BLUEPRINT)
|
APP.register_blueprint(BLUEPRINT)
|
||||||
|
|
||||||
|
if CONFIG['server'].get('admin'):
|
||||||
|
admin_ = Admin(CONFIG, OPENAPI)
|
||||||
|
APP.register_blueprint(ADMIN_BLUEPRINT)
|
||||||
|
|
||||||
|
|
||||||
@click.command()
|
@click.command()
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
|
|||||||
+223
-7
@@ -59,11 +59,10 @@ OPENAPI_YAML = {
|
|||||||
'oapif-2': 'https://schemas.opengis.net/ogcapi/features/part2/1.0/openapi/ogcapi-features-2.yaml', # noqa
|
'oapif-2': 'https://schemas.opengis.net/ogcapi/features/part2/1.0/openapi/ogcapi-features-2.yaml', # noqa
|
||||||
'oapip': 'https://schemas.opengis.net/ogcapi/processes/part1/1.0/openapi',
|
'oapip': 'https://schemas.opengis.net/ogcapi/processes/part1/1.0/openapi',
|
||||||
'oacov': 'https://raw.githubusercontent.com/tomkralidis/ogcapi-coverages-1/fix-cis/yaml-unresolved', # noqa
|
'oacov': 'https://raw.githubusercontent.com/tomkralidis/ogcapi-coverages-1/fix-cis/yaml-unresolved', # noqa
|
||||||
'oapit': 'https://raw.githubusercontent.com/opengeospatial/ogcapi-tiles/master/openapi/swaggerhub/tiles.yaml', # noqa
|
|
||||||
'oapimt': 'https://raw.githubusercontent.com/opengeospatial/ogcapi-tiles/master/openapi/swaggerhub/map-tiles.yaml', # noqa
|
|
||||||
'oapir': 'https://raw.githubusercontent.com/opengeospatial/ogcapi-records/master/core/openapi', # noqa
|
'oapir': 'https://raw.githubusercontent.com/opengeospatial/ogcapi-records/master/core/openapi', # noqa
|
||||||
'oaedr': 'https://schemas.opengis.net/ogcapi/edr/1.0/openapi', # noqa
|
'oaedr': 'https://schemas.opengis.net/ogcapi/edr/1.0/openapi', # noqa
|
||||||
'oat': 'https://schemas.opengis.net/ogcapi/tiles/part1/1.0/openapi/ogcapi-tiles-1.yaml' # noqa
|
'oapit': 'https://schemas.opengis.net/ogcapi/tiles/part1/1.0/openapi/ogcapi-tiles-1.yaml', # noqa
|
||||||
|
'pygeoapi': 'https://raw.githubusercontent.com/geopython/pygeoapi/master/pygeoapi/schemas/config/pygeoapi-config-0.x.yml' # noqa
|
||||||
}
|
}
|
||||||
|
|
||||||
THISDIR = os.path.dirname(os.path.realpath(__file__))
|
THISDIR = os.path.dirname(os.path.realpath(__file__))
|
||||||
@@ -432,6 +431,15 @@ def get_oas_30(cfg):
|
|||||||
'additionalProperties': True
|
'additionalProperties': True
|
||||||
},
|
},
|
||||||
'style': 'form'
|
'style': 'form'
|
||||||
|
},
|
||||||
|
'resourceId': {
|
||||||
|
'name': 'resourceId',
|
||||||
|
'in': 'path',
|
||||||
|
'description': 'Configuration resource identifier',
|
||||||
|
'required': True,
|
||||||
|
'schema': {
|
||||||
|
'type': 'string'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
'schemas': {
|
'schemas': {
|
||||||
@@ -950,10 +958,10 @@ def get_oas_30(cfg):
|
|||||||
'tags': [name],
|
'tags': [name],
|
||||||
'operationId': f'get{name.capitalize()}Tiles',
|
'operationId': f'get{name.capitalize()}Tiles',
|
||||||
'parameters': [
|
'parameters': [
|
||||||
{'$ref': f"{OPENAPI_YAML['oat']}#/components/parameters/tileMatrixSetId"}, # noqa
|
{'$ref': f"{OPENAPI_YAML['oapit']}#/components/parameters/tileMatrixSetId"}, # noqa
|
||||||
{'$ref': f"{OPENAPI_YAML['oat']}#/components/parameters/tileMatrix"}, # noqa
|
{'$ref': f"{OPENAPI_YAML['oapit']}#/components/parameters/tileMatrix"}, # noqa
|
||||||
{'$ref': f"{OPENAPI_YAML['oat']}#/components/parameters/tileRow"}, # noqa
|
{'$ref': f"{OPENAPI_YAML['oapit']}#/components/parameters/tileRow"}, # noqa
|
||||||
{'$ref': f"{OPENAPI_YAML['oat']}#/components/parameters/tileCol"}, # noqa
|
{'$ref': f"{OPENAPI_YAML['oapit']}#/components/parameters/tileCol"}, # noqa
|
||||||
{
|
{
|
||||||
'name': 'f',
|
'name': 'f',
|
||||||
'in': 'query',
|
'in': 'query',
|
||||||
@@ -1307,9 +1315,217 @@ def get_oas_30(cfg):
|
|||||||
|
|
||||||
oas['paths'] = paths
|
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())
|
||||||
|
|
||||||
return oas
|
return oas
|
||||||
|
|
||||||
|
|
||||||
|
def get_config_schema():
|
||||||
|
schema_file = os.path.join(THISDIR, 'schemas', 'config',
|
||||||
|
'pygeoapi-config-0.x.yml')
|
||||||
|
|
||||||
|
with open(schema_file) as fh2:
|
||||||
|
return yaml_load(fh2)
|
||||||
|
|
||||||
|
|
||||||
|
def get_admin():
|
||||||
|
|
||||||
|
schema_dict = get_config_schema()
|
||||||
|
|
||||||
|
paths = {}
|
||||||
|
|
||||||
|
paths['/admin/config'] = {
|
||||||
|
'get': {
|
||||||
|
'summary': 'Get admin configuration',
|
||||||
|
'description': 'Get admin configuration',
|
||||||
|
'tags': ['admin'],
|
||||||
|
'operationId': 'getAdminConfig',
|
||||||
|
'parameters': [
|
||||||
|
{'$ref': '#/components/parameters/f'},
|
||||||
|
{'$ref': '#/components/parameters/lang'}
|
||||||
|
],
|
||||||
|
'responses': {
|
||||||
|
'200': {
|
||||||
|
'content': {
|
||||||
|
'application/json': {
|
||||||
|
'schema': schema_dict
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'put': {
|
||||||
|
'summary': 'Update admin configuration full',
|
||||||
|
'description': 'Update admin configuration full',
|
||||||
|
'tags': ['admin'],
|
||||||
|
'operationId': 'putAdminConfig',
|
||||||
|
'requestBody': {
|
||||||
|
'description': 'Updates admin configuration',
|
||||||
|
'content': {
|
||||||
|
'application/json': {
|
||||||
|
'schema': schema_dict
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'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
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'patch': {
|
||||||
|
'summary': 'Partially update admin configuration',
|
||||||
|
'description': 'Partially update admin configuration',
|
||||||
|
'tags': ['admin'],
|
||||||
|
'operationId': 'patchAdminConfig',
|
||||||
|
'requestBody': {
|
||||||
|
'description': 'Updates admin configuration',
|
||||||
|
'content': {
|
||||||
|
'application/json': {
|
||||||
|
'schema': schema_dict
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
paths['/admin/config/resources'] = {
|
||||||
|
'get': {
|
||||||
|
'summary': 'Get admin configuration resources',
|
||||||
|
'description': 'Get admin configuration resources',
|
||||||
|
'tags': ['admin'],
|
||||||
|
'operationId': 'getAdminConfigResources',
|
||||||
|
'parameters': [
|
||||||
|
{'$ref': '#/components/parameters/f'},
|
||||||
|
{'$ref': '#/components/parameters/lang'}
|
||||||
|
],
|
||||||
|
'responses': {
|
||||||
|
'200': {
|
||||||
|
'content': {
|
||||||
|
'application/json': {
|
||||||
|
'schema': schema_dict['properties']['resources']['patternProperties']['^.*$'] # noqa
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'post': {
|
||||||
|
'summary': 'Create admin configuration resource',
|
||||||
|
'description': 'Create admin configuration resource',
|
||||||
|
'tags': ['admin'],
|
||||||
|
'operationId': 'postAdminConfigResource',
|
||||||
|
'requestBody': {
|
||||||
|
'description': 'Adds resource to configuration',
|
||||||
|
'content': {
|
||||||
|
'application/json': {
|
||||||
|
'schema': schema_dict['properties']['resources']['patternProperties']['^.*$'] # noqa
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'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
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
paths['/admin/config/resources/{resourceId}'] = {
|
||||||
|
'get': {
|
||||||
|
'summary': 'Get admin configuration resource',
|
||||||
|
'description': 'Get admin configuration resource',
|
||||||
|
'tags': ['admin'],
|
||||||
|
'operationId': 'getAdminConfigResource',
|
||||||
|
'parameters': [
|
||||||
|
{'$ref': '#/components/parameters/resourceId'},
|
||||||
|
{'$ref': '#/components/parameters/f'},
|
||||||
|
{'$ref': '#/components/parameters/lang'}
|
||||||
|
],
|
||||||
|
'responses': {
|
||||||
|
'200': {
|
||||||
|
'content': {
|
||||||
|
'application/json': {
|
||||||
|
'schema': schema_dict['properties']['resources']['patternProperties']['^.*$'] # noqa
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'put': {
|
||||||
|
'summary': 'Update admin configuration resource',
|
||||||
|
'description': 'Update admin configuration resource',
|
||||||
|
'tags': ['admin'],
|
||||||
|
'operationId': 'putAdminConfigResource',
|
||||||
|
'parameters': [
|
||||||
|
{'$ref': '#/components/parameters/resourceId'},
|
||||||
|
],
|
||||||
|
'requestBody': {
|
||||||
|
'description': 'Updates admin configuration resource',
|
||||||
|
'content': {
|
||||||
|
'application/json': {
|
||||||
|
'schema': schema_dict['properties']['resources']['patternProperties']['^.*$'] # 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
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'patch': {
|
||||||
|
'summary': 'Partially update admin configuration resource',
|
||||||
|
'description': 'Partially update admin configuration resource',
|
||||||
|
'tags': ['admin'],
|
||||||
|
'operationId': 'patchAdminConfigResource',
|
||||||
|
'parameters': [
|
||||||
|
{'$ref': '#/components/parameters/resourceId'},
|
||||||
|
],
|
||||||
|
'requestBody': {
|
||||||
|
'description': 'Updates admin configuration resource',
|
||||||
|
'content': {
|
||||||
|
'application/json': {
|
||||||
|
'schema': schema_dict['properties']['resources']['patternProperties']['^.*$'] # 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
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'delete': {
|
||||||
|
'summary': 'Delete admin configuration resource',
|
||||||
|
'description': 'Delete admin configuration resource',
|
||||||
|
'tags': ['admin'],
|
||||||
|
'operationId': 'deleteAdminConfigResource',
|
||||||
|
'parameters': [
|
||||||
|
{'$ref': '#/components/parameters/resourceId'},
|
||||||
|
],
|
||||||
|
'responses': {
|
||||||
|
'204': {'$ref': '#/components/responses/204'},
|
||||||
|
'404': {'$ref': f"{OPENAPI_YAML['oapip']}/responses/NotFound.yaml"}, # noqa
|
||||||
|
'default': {'$ref': '#/components/responses/default'} # noqa
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return paths
|
||||||
|
|
||||||
|
|
||||||
def get_oas(cfg, version='3.0'):
|
def get_oas(cfg, version='3.0'):
|
||||||
"""
|
"""
|
||||||
Stub to generate OpenAPI Document
|
Stub to generate OpenAPI Document
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ properties:
|
|||||||
url:
|
url:
|
||||||
type: string
|
type: string
|
||||||
description: URL of server (as used by client)
|
description: URL of server (as used by client)
|
||||||
|
admin:
|
||||||
|
type: boolean
|
||||||
|
description: whether to enable the Admin API (default is false)
|
||||||
|
default: false
|
||||||
mimetype:
|
mimetype:
|
||||||
type: string
|
type: string
|
||||||
description: default MIME type
|
description: default MIME type
|
||||||
@@ -407,6 +411,7 @@ properties:
|
|||||||
editable:
|
editable:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: whether the resource is editable
|
description: whether the resource is editable
|
||||||
|
default: false
|
||||||
table:
|
table:
|
||||||
type: string
|
type: string
|
||||||
description: table name for RDBMS-based providers
|
description: table name for RDBMS-based providers
|
||||||
|
|||||||
@@ -50,14 +50,12 @@ from starlette.responses import (
|
|||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
||||||
from pygeoapi.api import API
|
from pygeoapi.api import API
|
||||||
|
from pygeoapi.admin import Admin
|
||||||
from pygeoapi.openapi import load_openapi_document
|
from pygeoapi.openapi import load_openapi_document
|
||||||
from pygeoapi.util import yaml_load, get_api_rules
|
from pygeoapi.config import get_config
|
||||||
|
from pygeoapi.util import get_api_rules
|
||||||
|
|
||||||
if 'PYGEOAPI_CONFIG' not in os.environ:
|
CONFIG = get_config()
|
||||||
raise RuntimeError('PYGEOAPI_CONFIG environment variable not set')
|
|
||||||
|
|
||||||
with open(os.environ.get('PYGEOAPI_CONFIG'), encoding='utf8') as fh:
|
|
||||||
CONFIG = yaml_load(fh)
|
|
||||||
|
|
||||||
if 'PYGEOAPI_OPENAPI' not in os.environ:
|
if 'PYGEOAPI_OPENAPI' not in os.environ:
|
||||||
raise RuntimeError('PYGEOAPI_OPENAPI environment variable not set')
|
raise RuntimeError('PYGEOAPI_OPENAPI environment variable not set')
|
||||||
@@ -479,6 +477,56 @@ async def stac_catalog_path(request: Request):
|
|||||||
return get_response(api_.get_stac_path(request, path))
|
return get_response(api_.get_stac_path(request, path))
|
||||||
|
|
||||||
|
|
||||||
|
async def admin_config(request: Request):
|
||||||
|
"""
|
||||||
|
Admin endpoint
|
||||||
|
|
||||||
|
:returns: Starlette HTTP Response
|
||||||
|
"""
|
||||||
|
|
||||||
|
if request.method == 'GET':
|
||||||
|
return get_response(ADMIN.get_config(request))
|
||||||
|
elif request.method == 'PUT':
|
||||||
|
return get_response(ADMIN.put_config(request))
|
||||||
|
elif request.method == 'PATCH':
|
||||||
|
return get_response(ADMIN.patch_config(request))
|
||||||
|
|
||||||
|
|
||||||
|
async def admin_config_resources(request: Request):
|
||||||
|
"""
|
||||||
|
Resources endpoint
|
||||||
|
|
||||||
|
:returns: HTTP response
|
||||||
|
"""
|
||||||
|
|
||||||
|
if request.method == 'GET':
|
||||||
|
return get_response(ADMIN.get_resources(request))
|
||||||
|
elif request.method == 'POST':
|
||||||
|
return get_response(ADMIN.put_resource(request))
|
||||||
|
|
||||||
|
|
||||||
|
async def admin_config_resource(request: Request, resource_id: str):
|
||||||
|
"""
|
||||||
|
Resource endpoint
|
||||||
|
|
||||||
|
:param resource_id: resource identifier
|
||||||
|
|
||||||
|
:returns: Starlette HTTP Response
|
||||||
|
"""
|
||||||
|
|
||||||
|
if 'resource_id' in request.path_params:
|
||||||
|
resource_id = request.path_params['resource_id']
|
||||||
|
|
||||||
|
if request.method == 'GET':
|
||||||
|
return get_response(ADMIN.get_resource(request, resource_id))
|
||||||
|
elif request.method == 'PUT':
|
||||||
|
return get_response(ADMIN.put_resource(request, resource_id))
|
||||||
|
elif request.method == 'PATCH':
|
||||||
|
return get_response(ADMIN.patch_resource(request, resource_id))
|
||||||
|
elif request.method == 'DELETE':
|
||||||
|
return get_response(ADMIN.delete_resource(request, resource_id))
|
||||||
|
|
||||||
|
|
||||||
class ApiRulesMiddleware:
|
class ApiRulesMiddleware:
|
||||||
""" Custom middleware to properly deal with trailing slashes.
|
""" Custom middleware to properly deal with trailing slashes.
|
||||||
See https://github.com/encode/starlette/issues/869.
|
See https://github.com/encode/starlette/issues/869.
|
||||||
@@ -553,6 +601,17 @@ api_routes = [
|
|||||||
Route('/stac/{path:path}', stac_catalog_path),
|
Route('/stac/{path:path}', stac_catalog_path),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
admin_routes = [
|
||||||
|
Route('/admin/config', admin_config, methods=['GET', 'PUT', 'PATCH']),
|
||||||
|
Route('/admin/config/resources', admin_config_resources, methods=['GET', 'POST']), # noqa
|
||||||
|
Route('/admin/config/resources/{resource_id:path}', admin_config_resource,
|
||||||
|
methods=['GET', 'PUT', 'PATCH', 'DELETE'])
|
||||||
|
]
|
||||||
|
|
||||||
|
if CONFIG['server'].get('admin', False):
|
||||||
|
ADMIN = Admin(CONFIG, OPENAPI)
|
||||||
|
api_routes.extend(admin_routes)
|
||||||
|
|
||||||
url_prefix = API_RULES.get_url_prefix('starlette')
|
url_prefix = API_RULES.get_url_prefix('starlette')
|
||||||
APP = Starlette(
|
APP = Starlette(
|
||||||
routes=[
|
routes=[
|
||||||
|
|||||||
@@ -46,6 +46,11 @@
|
|||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a href="mailto:{{ config['metadata']['contact']['email'] }}" class="nav-link" aria-current="page">Contact</a>
|
<a href="mailto:{{ config['metadata']['contact']['email'] }}" class="nav-link" aria-current="page">Contact</a>
|
||||||
</li>
|
</li>
|
||||||
|
{% if config['server']['admin'] %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a href="{{ config['server']['url'] }}/admin/config" class="nav-link" aria-current="page">{% trans %}Admin{% endtrans %}</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
<!--
|
<!--
|
||||||
Add additional menu items here
|
Add additional menu items here
|
||||||
<a href="https://pygeoapi.io" class="nav-link">About</a>
|
<a href="https://pygeoapi.io" class="nav-link">About</a>
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
{% extends "_base.html" %} {% block title %} Admin {% endblock %}
|
||||||
|
{% macro render_item_value(v, width) -%}
|
||||||
|
{% set val = v | string | trim %}
|
||||||
|
{% if v is string or v is number %}
|
||||||
|
{{ val | urlize() }}
|
||||||
|
{% elif v is mapping %}
|
||||||
|
{% for i,j in v.items() %}
|
||||||
|
<i>{{ i }}:</i> {{ render_item_value(j, 60) }}<br/>
|
||||||
|
{% endfor %}
|
||||||
|
{% elif v is iterable %}
|
||||||
|
{% for i in v %}
|
||||||
|
{{ render_item_value(i, 60) }}
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
{{ val | urlize() }}
|
||||||
|
{% endif %}
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
{% set tree = data %}
|
||||||
|
|
||||||
|
<section id="admin">
|
||||||
|
<div class="admin">
|
||||||
|
|
||||||
|
{% for key, node in tree.items() recursive %}
|
||||||
|
<div class="card mt-2">
|
||||||
|
|
||||||
|
{# if node has a title #}
|
||||||
|
{% if key|int(-1) == -1 %}
|
||||||
|
<div class="card-header">
|
||||||
|
<b>{{ key }}</b>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
{# if node has a renderable value #}
|
||||||
|
{% if node is not iterable or node is string %}
|
||||||
|
{{ render_item_value(node, 80) }}
|
||||||
|
|
||||||
|
{# if node has named child nodes #}
|
||||||
|
{% elif node is mapping %}
|
||||||
|
{{ loop(node.items()) }}
|
||||||
|
|
||||||
|
{# if node has unnamed child nodes #}
|
||||||
|
{% else %}
|
||||||
|
{% set outerloop = loop %}
|
||||||
|
{% for subnode in node %}
|
||||||
|
{% set tmpnode = {loop.index|string: subnode} %}
|
||||||
|
{{ outerloop(tmpnode.items()) }}
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
+40
-8
@@ -30,6 +30,7 @@
|
|||||||
"""Generic util functions used in the code"""
|
"""Generic util functions used in the code"""
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
|
from filelock import FileLock
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import mimetypes
|
import mimetypes
|
||||||
@@ -41,6 +42,7 @@ from dataclasses import dataclass
|
|||||||
from datetime import date, datetime, time
|
from datetime import date, datetime, time
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
import pathlib
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, IO, Union, List, Callable
|
from typing import Any, IO, Union, List, Callable
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
@@ -79,7 +81,8 @@ LOGGER = logging.getLogger(__name__)
|
|||||||
|
|
||||||
DATETIME_FORMAT = '%Y-%m-%dT%H:%M:%S.%fZ'
|
DATETIME_FORMAT = '%Y-%m-%dT%H:%M:%S.%fZ'
|
||||||
|
|
||||||
TEMPLATES = Path(__file__).parent.resolve() / 'templates'
|
THISDIR = Path(__file__).parent.resolve()
|
||||||
|
TEMPLATES = THISDIR / 'templates'
|
||||||
|
|
||||||
CRS_AUTHORITY = [
|
CRS_AUTHORITY = [
|
||||||
"AUTO",
|
"AUTO",
|
||||||
@@ -90,9 +93,9 @@ CRS_AUTHORITY = [
|
|||||||
# Global to compile only once
|
# Global to compile only once
|
||||||
CRS_URI_PATTERN = re.compile(
|
CRS_URI_PATTERN = re.compile(
|
||||||
(
|
(
|
||||||
rf"^http://www.opengis\.net/def/crs/"
|
rf"^http://www.opengis\.net/def/crs/"
|
||||||
rf"(?P<auth>{'|'.join(CRS_AUTHORITY)})/"
|
rf"(?P<auth>{'|'.join(CRS_AUTHORITY)})/"
|
||||||
rf"[\d|\.]+?/(?P<code>\w+?)$"
|
rf"[\d|\.]+?/(?P<code>\w+?)$"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -209,6 +212,32 @@ def get_base_url(config: dict) -> str:
|
|||||||
return url_join(config['server']['url'], rules.get_url_prefix())
|
return url_join(config['server']['url'], rules.get_url_prefix())
|
||||||
|
|
||||||
|
|
||||||
|
def yaml_dump(dict_: dict, destfile: str) -> bool:
|
||||||
|
"""
|
||||||
|
Dump dict to YAML file
|
||||||
|
|
||||||
|
:param dict_: `dict` to dump
|
||||||
|
:param destfile: destination filepath
|
||||||
|
|
||||||
|
:returns: `bool`
|
||||||
|
"""
|
||||||
|
|
||||||
|
def path_representer(dumper, data):
|
||||||
|
return dumper.represent_scalar(u'tag:yaml.org,2002:str', str(data))
|
||||||
|
|
||||||
|
yaml.add_multi_representer(pathlib.PurePath, path_representer)
|
||||||
|
|
||||||
|
lock = FileLock(f'{destfile}.lock')
|
||||||
|
|
||||||
|
with lock:
|
||||||
|
LOGGER.debug('Dumping YAML document')
|
||||||
|
with open(destfile, 'wb') as fh:
|
||||||
|
yaml.dump(dict_, fh, sort_keys=False, encoding='utf8', indent=4,
|
||||||
|
default_flow_style=False)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def str2bool(value: Union[bool, str]) -> bool:
|
def str2bool(value: Union[bool, str]) -> bool:
|
||||||
"""
|
"""
|
||||||
helper function to return Python boolean
|
helper function to return Python boolean
|
||||||
@@ -365,10 +394,12 @@ def json_serial(obj: Any) -> str:
|
|||||||
return float(obj)
|
return float(obj)
|
||||||
elif isinstance(obj, l10n.Locale):
|
elif isinstance(obj, l10n.Locale):
|
||||||
return l10n.locale2str(obj)
|
return l10n.locale2str(obj)
|
||||||
|
elif isinstance(obj, (pathlib.PurePath, Path)):
|
||||||
msg = f'{obj} type {type(obj)} not serializable'
|
return str(obj)
|
||||||
LOGGER.error(msg)
|
else:
|
||||||
raise TypeError(msg)
|
msg = f'{obj} type {type(obj)} not serializable'
|
||||||
|
LOGGER.error(msg)
|
||||||
|
raise TypeError(msg)
|
||||||
|
|
||||||
|
|
||||||
def is_url(urlstring: str) -> bool:
|
def is_url(urlstring: str) -> bool:
|
||||||
@@ -822,6 +853,7 @@ class UrlPrefetcher:
|
|||||||
""" Prefetcher to get HTTP headers for specific URLs.
|
""" Prefetcher to get HTTP headers for specific URLs.
|
||||||
Allows a maximum of 1 redirect by default.
|
Allows a maximum of 1 redirect by default.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._session = Session()
|
self._session = Session()
|
||||||
self._session.max_redirects = 1
|
self._session.max_redirects = 1
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
filelock
|
||||||
|
jsonpatch
|
||||||
|
gunicorn
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"logging": {
|
||||||
|
"level": "DEBUG"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
{
|
||||||
|
"server": {
|
||||||
|
"bind": {
|
||||||
|
"host": "0.0.0.0",
|
||||||
|
"port": 5000
|
||||||
|
},
|
||||||
|
"url": "http://localhost:5000",
|
||||||
|
"admin": true,
|
||||||
|
"mimetype": "application/json; charset=UTF-8",
|
||||||
|
"encoding": "utf-8",
|
||||||
|
"languages": ["en-US"],
|
||||||
|
"cors": true,
|
||||||
|
"pretty_print": true,
|
||||||
|
"limit": 10,
|
||||||
|
"map": {
|
||||||
|
"url": "https://maps.wikimedia.org/osm-intl/{z}/{x}/{y}.png",
|
||||||
|
"attribution": "<a href=\"https://wikimediafoundation.org/wiki/Maps_Terms_of_Use\">Wikimedia maps</a> | Map data © <a href=\"https://openstreetmap.org/copyright\">OpenStreetMap contributors</a>"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"logging": {
|
||||||
|
"level": "INFO"
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"identification": {
|
||||||
|
"title": {
|
||||||
|
"en": "pygeoapi default instance"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"en": "pygeoapi provides an API to geospatial data"
|
||||||
|
},
|
||||||
|
"keywords": {
|
||||||
|
"en": ["geospatial", "data", "api"]
|
||||||
|
},
|
||||||
|
"keywords_type": "theme",
|
||||||
|
"terms_of_service": "https://creativecommons.org/licenses/by/4.0/",
|
||||||
|
"url": "http://example.org"
|
||||||
|
},
|
||||||
|
"license": {
|
||||||
|
"name": "CC-BY 4.0 license",
|
||||||
|
"url": "https://creativecommons.org/licenses/by/4.0/"
|
||||||
|
},
|
||||||
|
"provider": {
|
||||||
|
"name": "Organization Name",
|
||||||
|
"url": "https://pygeoapi.io"
|
||||||
|
},
|
||||||
|
"contact": {
|
||||||
|
"name": "Lastname, Firstname",
|
||||||
|
"position": "Position Title",
|
||||||
|
"address": "Mailing Address",
|
||||||
|
"city": "City",
|
||||||
|
"stateorprovince": "Administrative Area",
|
||||||
|
"postalcode": "Zip or Postal Code",
|
||||||
|
"country": "Country",
|
||||||
|
"phone": "+xx-xxx-xxx-xxxx",
|
||||||
|
"fax": "+xx-xxx-xxx-xxxx",
|
||||||
|
"email": "you@example.org",
|
||||||
|
"url": "Contact URL",
|
||||||
|
"hours": "Hours of Service",
|
||||||
|
"instructions": "During hours of service. Off on weekends.",
|
||||||
|
"role": "pointOfContact"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"resources": {
|
||||||
|
"hello-world": {
|
||||||
|
"type": "process",
|
||||||
|
"processor": {
|
||||||
|
"name": "HelloWorld"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"title": {
|
||||||
|
"en": "Data assets, updated by HTTP PATCH"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"data2": {
|
||||||
|
"type": "stac-collection",
|
||||||
|
"title": {
|
||||||
|
"en": "Data assets"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"en": "Data assets"
|
||||||
|
},
|
||||||
|
"keywords": {
|
||||||
|
"en": [
|
||||||
|
"wmo",
|
||||||
|
"wis 2.0",
|
||||||
|
"bufr",
|
||||||
|
"observations"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"links": [
|
||||||
|
{
|
||||||
|
"type": "text/html",
|
||||||
|
"rel": "canonical",
|
||||||
|
"title": "information",
|
||||||
|
"href": "https://example.org"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"extents": {
|
||||||
|
"spatial": {
|
||||||
|
"bbox": [
|
||||||
|
-180,
|
||||||
|
-90,
|
||||||
|
180,
|
||||||
|
90
|
||||||
|
],
|
||||||
|
"crs": "http://www.opengis.net/def/crs/OGC/1.3/CRS84"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"providers": [
|
||||||
|
{
|
||||||
|
"type": "stac",
|
||||||
|
"name": "FileSystem",
|
||||||
|
"data": "/data/wis2box/data",
|
||||||
|
"file_types": [
|
||||||
|
".bufr4"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"type": "stac-collection",
|
||||||
|
"title": {
|
||||||
|
"en": "Data assets, updated by HTTP PUT"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"en": "Data assets"
|
||||||
|
},
|
||||||
|
"keywords": {
|
||||||
|
"en": [
|
||||||
|
"wmo",
|
||||||
|
"wis 2.0",
|
||||||
|
"bufr",
|
||||||
|
"observations"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"links": [
|
||||||
|
{
|
||||||
|
"type": "text/html",
|
||||||
|
"rel": "canonical",
|
||||||
|
"title": "information",
|
||||||
|
"href": "https://example.org"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"extents": {
|
||||||
|
"spatial": {
|
||||||
|
"bbox": [
|
||||||
|
-180,
|
||||||
|
-90,
|
||||||
|
180,
|
||||||
|
90
|
||||||
|
],
|
||||||
|
"crs": "http://www.opengis.net/def/crs/OGC/1.3/CRS84"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"providers": [
|
||||||
|
{
|
||||||
|
"type": "stac",
|
||||||
|
"name": "FileSystem",
|
||||||
|
"data": "/data/wis2box/data",
|
||||||
|
"file_types": [
|
||||||
|
".bufr4"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# =================================================================
|
||||||
|
#
|
||||||
|
# Authors: Benjamin Webb <benjamin.miller.webb@gmail.com>
|
||||||
|
#
|
||||||
|
# Copyright (c) 2023 Benjamin 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.
|
||||||
|
#
|
||||||
|
# =================================================================
|
||||||
|
|
||||||
|
server:
|
||||||
|
bind:
|
||||||
|
host: 0.0.0.0
|
||||||
|
port: 5000
|
||||||
|
url: http://localhost:5000
|
||||||
|
admin: true
|
||||||
|
mimetype: application/json; charset=UTF-8
|
||||||
|
encoding: utf-8
|
||||||
|
languages:
|
||||||
|
- en-US
|
||||||
|
cors: true
|
||||||
|
pretty_print: true
|
||||||
|
limit: 10
|
||||||
|
map:
|
||||||
|
url: https://maps.wikimedia.org/osm-intl/{z}/{x}/{y}.png
|
||||||
|
attribution: <a href="https://wikimediafoundation.org/wiki/Maps_Terms_of_Use">Wikimedia
|
||||||
|
maps</a> | Map data © <a href="https://openstreetmap.org/copyright">OpenStreetMap
|
||||||
|
contributors</a>
|
||||||
|
# manager:
|
||||||
|
# name: TinyDB
|
||||||
|
# connection: /tmp/pygeoapi-process-manager.db
|
||||||
|
# output_dir: /tmp/
|
||||||
|
# ogc_schemas_location: /opt/schemas.opengis.net
|
||||||
|
# templates:
|
||||||
|
# path: /path/to/Jinja2/templates
|
||||||
|
# static: /path/to/static/folder # css/js/img
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level: DEBUG
|
||||||
|
#logfile: /tmp/pygeoapi.log
|
||||||
|
|
||||||
|
metadata:
|
||||||
|
identification:
|
||||||
|
title:
|
||||||
|
en: pygeoapi default instance
|
||||||
|
description:
|
||||||
|
en: pygeoapi provides an API to geospatial data
|
||||||
|
keywords:
|
||||||
|
en:
|
||||||
|
- geospatial
|
||||||
|
- data
|
||||||
|
- api
|
||||||
|
keywords_type: theme
|
||||||
|
terms_of_service: https://creativecommons.org/licenses/by/4.0/
|
||||||
|
url: http://example.org
|
||||||
|
license:
|
||||||
|
name: CC-BY 4.0 license
|
||||||
|
url: https://creativecommons.org/licenses/by/4.0/
|
||||||
|
provider:
|
||||||
|
name: Organization Name
|
||||||
|
url: https://pygeoapi.io
|
||||||
|
contact:
|
||||||
|
name: Lastname, Firstname
|
||||||
|
position: Position Title
|
||||||
|
address: Mailing Address
|
||||||
|
city: City
|
||||||
|
stateorprovince: Administrative Area
|
||||||
|
postalcode: Zip or Postal Code
|
||||||
|
country: Country
|
||||||
|
phone: +xx-xxx-xxx-xxxx
|
||||||
|
fax: +xx-xxx-xxx-xxxx
|
||||||
|
email: you@example.org
|
||||||
|
url: Contact URL
|
||||||
|
hours: Hours of Service
|
||||||
|
instructions: During hours of service. Off on weekends.
|
||||||
|
role: pointOfContact
|
||||||
|
|
||||||
|
resources:
|
||||||
|
hello-world:
|
||||||
|
type: process
|
||||||
|
processor:
|
||||||
|
name: HelloWorld
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
# =================================================================
|
||||||
|
#
|
||||||
|
# Authors: Tom Kralidis <tomkralidis@gmail.com>
|
||||||
|
# Authors: Benjamin Webb <benjamin.miller.webb@gmail.com>
|
||||||
|
#
|
||||||
|
# Copyright (c) 2023 Tom Kralidis
|
||||||
|
# Copyright (c) 2023 Benjamin 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 time
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from requests import Session
|
||||||
|
|
||||||
|
THISDIR = Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
|
||||||
|
class APITest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
"""setup test fixtures, etc."""
|
||||||
|
|
||||||
|
self.admin_endpoint = 'http://localhost:5000/admin/config'
|
||||||
|
self.http = Session()
|
||||||
|
self.http.headers.update({
|
||||||
|
'Content-type': 'application/json',
|
||||||
|
'Accept': 'application/json'
|
||||||
|
})
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
"""return to pristine state"""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_admin(self):
|
||||||
|
|
||||||
|
url = f'{self.admin_endpoint}'
|
||||||
|
content = self.http.get(url).json()
|
||||||
|
|
||||||
|
keys = ['logging', 'metadata', 'resources', 'server']
|
||||||
|
self.assertEqual(sorted(content.keys()), keys)
|
||||||
|
|
||||||
|
# PUT configuration
|
||||||
|
with get_abspath('admin-put.json').open() as fh:
|
||||||
|
put = fh.read()
|
||||||
|
response = self.http.put(url, data=put)
|
||||||
|
self.assertEqual(response.status_code, 204)
|
||||||
|
|
||||||
|
# NOTE: we sleep 5 between CRUD requests so as to let gunicorn
|
||||||
|
# restart with the refreshed configuration
|
||||||
|
time.sleep(5)
|
||||||
|
|
||||||
|
content = self.http.get(url).json()
|
||||||
|
self.assertEqual(content['logging']['level'], 'INFO')
|
||||||
|
|
||||||
|
# PATCH configuration
|
||||||
|
with get_abspath('admin-patch.json').open() as fh:
|
||||||
|
patch = fh.read()
|
||||||
|
|
||||||
|
response = self.http.patch(url, data=patch)
|
||||||
|
self.assertEqual(response.status_code, 204)
|
||||||
|
|
||||||
|
time.sleep(5)
|
||||||
|
|
||||||
|
content = self.http.get(url).json()
|
||||||
|
self.assertEqual(content['logging']['level'], 'DEBUG')
|
||||||
|
|
||||||
|
def test_resources_crud(self):
|
||||||
|
|
||||||
|
url = f'{self.admin_endpoint}/resources'
|
||||||
|
content = self.http.get(url).json()
|
||||||
|
self.assertEqual(len(content.keys()), 1)
|
||||||
|
|
||||||
|
# POST a new resource
|
||||||
|
with get_abspath('resource-post.json').open() as fh:
|
||||||
|
post_data = fh.read()
|
||||||
|
|
||||||
|
response = self.http.post(url, data=post_data)
|
||||||
|
self.assertEqual(response.status_code, 201)
|
||||||
|
self.assertEqual(response.text,
|
||||||
|
'Location: /admin/config/resources/data2')
|
||||||
|
|
||||||
|
# NOTE: we sleep 5 between CRUD requests so as to let gunicorn
|
||||||
|
# restart with the refreshed configuration
|
||||||
|
time.sleep(5)
|
||||||
|
|
||||||
|
content = self.http.get(url).json()
|
||||||
|
self.assertEqual(len(content.keys()), 2)
|
||||||
|
|
||||||
|
# PUT an existing resource
|
||||||
|
url = f'{self.admin_endpoint}/resources/data2'
|
||||||
|
with get_abspath('resource-put.json').open() as fh:
|
||||||
|
post_data = fh.read()
|
||||||
|
print(url)
|
||||||
|
print(get_abspath('resource-put.json'))
|
||||||
|
response = self.http.put(url, data=post_data)
|
||||||
|
self.assertEqual(response.status_code, 204)
|
||||||
|
|
||||||
|
time.sleep(5)
|
||||||
|
|
||||||
|
content = self.http.get(url).json()
|
||||||
|
self.assertEqual(content['title']['en'],
|
||||||
|
'Data assets, updated by HTTP PUT')
|
||||||
|
|
||||||
|
# PATCH an existing resource
|
||||||
|
url = f'{self.admin_endpoint}/resources/data2'
|
||||||
|
with get_abspath('resource-patch.json').open() as fh:
|
||||||
|
post_data = fh.read()
|
||||||
|
|
||||||
|
response = self.http.patch(url, data=post_data)
|
||||||
|
self.assertEqual(response.status_code, 204)
|
||||||
|
|
||||||
|
time.sleep(5)
|
||||||
|
|
||||||
|
content = self.http.get(url).json()
|
||||||
|
self.assertEqual(content['title']['en'],
|
||||||
|
'Data assets, updated by HTTP PATCH')
|
||||||
|
|
||||||
|
# DELETE an existing new resource
|
||||||
|
response = self.http.delete(url)
|
||||||
|
self.assertEqual(response.status_code, 204)
|
||||||
|
|
||||||
|
time.sleep(5)
|
||||||
|
|
||||||
|
url = f'{self.admin_endpoint}/resources'
|
||||||
|
content = self.http.get(url).json()
|
||||||
|
self.assertEqual(len(content.keys()), 1)
|
||||||
|
|
||||||
|
|
||||||
|
def get_abspath(filepath):
|
||||||
|
"""helper function absolute file access"""
|
||||||
|
|
||||||
|
return Path(THISDIR) / 'data' / 'admin' / filepath
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -54,7 +54,7 @@ def test_query(config):
|
|||||||
|
|
||||||
r = p.get_data_path(baseurl, urlpath, dirpath)
|
r = p.get_data_path(baseurl, urlpath, dirpath)
|
||||||
|
|
||||||
assert len(r['links']) == 11
|
assert len(r['links']) == 12
|
||||||
|
|
||||||
r = p.get_data_path(baseurl, urlpath, '/poi_portugal')
|
r = p.get_data_path(baseurl, urlpath, '/poi_portugal')
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user