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:
Benjamin Webb
2024-01-03 08:45:07 -07:00
committed by GitHub
parent 649a02ee48
commit 8e122d1a61
27 changed files with 1719 additions and 51 deletions
+42
View File
@@ -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
+2
View File
@@ -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 . \
+30
View File
@@ -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
+1
View File
@@ -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
+1
View File
@@ -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
+623
View File
@@ -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
View File
@@ -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
+2 -2
View File
@@ -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)
+22
View File
@@ -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(
+65 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+65 -6
View File
@@ -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=[
+5
View File
@@ -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>
+59
View File
@@ -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
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
filelock
jsonpatch
gunicorn
+5
View File
@@ -0,0 +1,5 @@
{
"logging": {
"level": "DEBUG"
}
}
+72
View File
@@ -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 &copy; <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"
}
}
}
}
+5
View File
@@ -0,0 +1,5 @@
{
"title": {
"en": "Data assets, updated by HTTP PATCH"
}
}
+48
View File
@@ -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"
]
}
]
}
}
+46
View File
@@ -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"
]
}
]
}
+101
View File
@@ -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 &copy; <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
+160
View File
@@ -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()
+1 -1
View File
@@ -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')