add support for configuration schema and validation (#553) (#719)

* add support for configuration schema and validation (#553)

* rename types

* minor doc fix

* update i18n regexes

* make openapi click options required arguments
This commit is contained in:
Tom Kralidis
2021-07-06 17:01:05 -04:00
committed by GitHub
parent d6df7d4fef
commit 89eff49ce0
18 changed files with 627 additions and 60 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
include README.md LICENSE.md requirements.txt include README.md LICENSE.md requirements.txt
recursive-include pygeoapi *.html *.json recursive-include pygeoapi *.html *.json *.yml
recursive-include pygeoapi/static * recursive-include pygeoapi/static *
+1 -1
View File
@@ -60,7 +60,7 @@ function error() {
cd ${PYGEOAPI_HOME} cd ${PYGEOAPI_HOME}
echo "Trying to generate openapi.yml" echo "Trying to generate openapi.yml"
pygeoapi openapi generate -c ${PYGEOAPI_CONFIG} > ${PYGEOAPI_OPENAPI} pygeoapi openapi generate ${PYGEOAPI_CONFIG} > ${PYGEOAPI_OPENAPI}
[[ $? -ne 0 ]] && error "openapi.yml could not be generated ERROR" [[ $? -ne 0 ]] && error "openapi.yml could not be generated ERROR"
+4 -23
View File
@@ -20,19 +20,19 @@ To generate the OpenAPI document, run the following:
.. code-block:: bash .. code-block:: bash
pygeoapi openapi generate -c /path/to/my-pygeoapi-config.yml pygeoapi openapi generate /path/to/my-pygeoapi-config.yml
This will dump the OpenAPI document as YAML to your system's ``stdout``. To save to a file on disk, run: This will dump the OpenAPI document as YAML to your system's ``stdout``. To save to a file on disk, run:
.. code-block:: bash .. code-block:: bash
pygeoapi openapi generate -c /path/to/my-pygeoapi-config.yml > /path/to/my-pygeoapi-openapi.yml pygeoapi openapi generate /path/to/my-pygeoapi-config.yml > /path/to/my-pygeoapi-openapi.yml
To generate the OpenAPI document as JSON, run: To generate the OpenAPI document as JSON, run:
.. code-block:: bash .. code-block:: bash
pygeoapi openapi generate -c /path/to/my-pygeoapi-config.yml -f json > /path/to/my-pygeoapi-openapi.json pygeoapi openapi generate /path/to/my-pygeoapi-config.yml -f json > /path/to/my-pygeoapi-openapi.json
.. note:: .. note::
Generate as YAML or JSON? If your OpenAPI YAML definition is slow to render as JSON, Generate as YAML or JSON? If your OpenAPI YAML definition is slow to render as JSON,
@@ -56,26 +56,7 @@ utility that can be run as follows:
.. code-block:: bash .. code-block:: bash
pygeoapi validate-openapi-document -o /path/to/my-pygeoapi-openapi.yml pygeoapi openapi validate /path/to/my-pygeoapi-openapi.yml
Verifying configuration files
-----------------------------
To ensure your YAML configurations are correctly formatted, you can use any YAML validator, or try
the Python one-liner per below:
.. code-block:: bash
python -c 'import yaml, sys; yaml.safe_load(sys.stdin)' < /path/to/my-pygeoapi-config.yml
python -c 'import yaml, sys; yaml.safe_load(sys.stdin)' < /path/to/my-pygeoapi-openapi.yml
To ensure your OpenAPI JSON is correctly formatted, you can use any JSON validator, or try
the Python one-liner per below:
.. code-block:: bash
cat /path/to/my-pygeoapi-openapi.json | python -m json.tool
Setting system environment variables Setting system environment variables
+11
View File
@@ -200,6 +200,17 @@ default.
:ref:`plugins` for more information on plugins :ref:`plugins` for more information on plugins
Validating the configuration
----------------------------
To ensure your configuration is valid, pygeoapi provides a validation
utility that can be run as follows:
.. code-block:: bash
pygeoapi config validate -c /path/to/my-pygeoapi-config.yml
Using environment variables Using environment variables
--------------------------- ---------------------------
@@ -3,7 +3,7 @@
Publishing metadata to OGC API - Records Publishing metadata to OGC API - Records
======================================== ========================================
`OGC API - Records `_ provides geospatial data access functionality to vector data. `OGC API - Records`_ provides geospatial data access functionality to vector data.
To add vector data to pygeoapi, you can use the dataset example in :ref:`configuration` To add vector data to pygeoapi, you can use the dataset example in :ref:`configuration`
as a baseline and modify accordingly. as a baseline and modify accordingly.
+1 -1
View File
@@ -31,7 +31,7 @@ For developers and the truly impatient
vi example-config.yml vi example-config.yml
export PYGEOAPI_CONFIG=example-config.yml export PYGEOAPI_CONFIG=example-config.yml
export PYGEOAPI_OPENAPI=example-openapi.yml export PYGEOAPI_OPENAPI=example-openapi.yml
pygeoapi openapi generate -c $PYGEOAPI_CONFIG > $PYGEOAPI_OPENAPI pygeoapi openapi generate $PYGEOAPI_CONFIG > $PYGEOAPI_OPENAPI
pygeoapi serve pygeoapi serve
curl http://localhost:5000 curl http://localhost:5000
+1 -1
View File
@@ -165,7 +165,7 @@ resources:
bbox: [-180,-90,180,90] bbox: [-180,-90,180,90]
crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84
temporal: temporal:
begin: 2011-11-11 begin: 2011-11-11T11:11:11Z
end: null # or empty (either means open ended) end: null # or empty (either means open ended)
providers: providers:
- type: feature - type: feature
+2
View File
@@ -30,6 +30,7 @@
__version__ = '0.11.dev0' __version__ = '0.11.dev0'
import click import click
from pygeoapi.config import config
from pygeoapi.openapi import openapi from pygeoapi.openapi import openapi
@@ -58,4 +59,5 @@ def serve(ctx, server):
raise click.ClickException('--flask/--starlette is required') raise click.ClickException('--flask/--starlette is required')
cli.add_command(config)
cli.add_command(openapi) cli.add_command(openapi)
+83
View File
@@ -0,0 +1,83 @@
# =================================================================
#
# Authors: Tom Kralidis <tomkralidis@gmail.com>
#
# Copyright (c) 2021 Tom Kralidis
#
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation
# files (the "Software"), to deal in the Software without
# restriction, including without limitation the rights to use,
# copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the
# Software is furnished to do so, subject to the following
# conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
# OTHER DEALINGS IN THE SOFTWARE.
#
# =================================================================
import click
import json
from jsonschema import validate as jsonschema_validate
import logging
import os
from pygeoapi.util import to_json, yaml_load
LOGGER = logging.getLogger(__name__)
THISDIR = os.path.dirname(os.path.realpath(__file__))
def validate_config(instance_dict):
"""
Validate pygeoapi configuration against pygeoapi schema
:param instance_dict: dict of configuration
:returns: `bool` of validation
"""
schema_file = os.path.join(THISDIR, 'schemas', 'config',
'pygeoapi-config-0.x.yml')
with open(schema_file) as fh2:
schema_dict = yaml_load(fh2)
jsonschema_validate(json.loads(to_json(instance_dict)), schema_dict)
return True
@click.group()
def config():
"""Configuration management"""
pass
@click.command()
@click.pass_context
@click.option('--config', '-c', 'config_file', help='configuration file')
def validate(ctx, config_file):
"""Validate configuration"""
if config_file is None:
raise click.ClickException('--config/-c required')
with open(config_file) as ff:
click.echo('Validating {}'.format(config_file))
instance = yaml_load(ff)
validate_config(instance)
click.echo('Valid configuration')
config.add_command(validate)
+13 -14
View File
@@ -1070,7 +1070,7 @@ def openapi():
@click.command() @click.command()
@click.pass_context @click.pass_context
@click.option('--config', '-c', 'config_file', help='configuration file') @click.argument('config_file', type=click.File())
@click.option('--format', '-f', 'format_', type=click.Choice(['json', 'yaml']), @click.option('--format', '-f', 'format_', type=click.Choice(['json', 'yaml']),
default='yaml', help='output format (json|yaml)') default='yaml', help='output format (json|yaml)')
def generate(ctx, config_file, format_='yaml'): def generate(ctx, config_file, format_='yaml'):
@@ -1078,29 +1078,28 @@ def generate(ctx, config_file, format_='yaml'):
if config_file is None: if config_file is None:
raise click.ClickException('--config/-c required') raise click.ClickException('--config/-c required')
with open(config_file) as ff:
s = yaml_load(ff) s = yaml_load(config_file)
pretty_print = s['server'].get('pretty_print', False) pretty_print = s['server'].get('pretty_print', False)
if format_ == 'yaml': if format_ == 'yaml':
click.echo(yaml.safe_dump(get_oas(s), default_flow_style=False)) click.echo(yaml.safe_dump(get_oas(s), default_flow_style=False))
else: else:
click.echo(to_json(get_oas(s), pretty=pretty_print)) click.echo(to_json(get_oas(s), pretty=pretty_print))
@click.command() @click.command()
@click.pass_context @click.pass_context
@click.option('--openapi', '-o', 'openapi_file', help='OpenAPI document') @click.argument('openapi_file', type=click.File())
def validate(ctx, openapi_file): def validate(ctx, openapi_file):
"""Validate OpenAPI Document""" """Validate OpenAPI Document"""
if openapi_file is None: if openapi_file is None:
raise click.ClickException('--openapi/-o required') raise click.ClickException('--openapi/-o required')
with open(openapi_file) as ff: click.echo('Validating {}'.format(openapi_file))
click.echo('Validating {}'.format(openapi_file)) instance = yaml_load(openapi_file)
instance = yaml_load(ff) validate_openapi_document(instance)
validate_openapi_document(instance) click.echo('Valid OpenAPI document')
click.echo('Valid OpenAPI document')
openapi.add_command(generate) openapi.add_command(generate)
@@ -0,0 +1,469 @@
$schema: https://json-schema.org/draft/2020-12/schema
$id: https://raw.githubusercontent.com/geopython/pygeoapi/master/pygeoapi/schemas/config/pygeoapi-config-0.x.yml
title: pygeoapi configuration schema
description: pygeoapi configuration schema
type: object
properties:
server:
type: object
description: server object
properties:
bind:
type: object
description: binding server information
properties:
host:
type: string
description: binding IP
port:
type: integer
description: binding port
required:
- host
- port
url:
type: string
description: URL of server (as used by client)
mimetype:
type: string
description: default MIME type
encoding:
type: string
description: default server encoding
language:
type: string
description: default server language
cors:
type: boolean
description: boolean on whether server should support CORS
default: false
pretty_print:
type: boolean
description: whether JSON responses should be pretty-printed
default: false
limit:
type: integer
description: server limit on number of items to return
default: 10
templates:
type: object
description: optional configuration to specify a different set of templates for HTML pages. Recommend using absolute paths. Omit this to use the default provided templates
properties:
path:
type: string
description: path to templates folder containing the jinja2 template HTML files
static:
type: string
description: path to static folder containing css, js, images and other static files referenced by the template
map:
type: object
description: leaflet map setup for HTML pages
properties:
url:
type: string
description: URI template of tile server
attribution:
type: string
description: map attribution
required:
- url
- attribution
ogc_schemas_location:
type: string
description: local copy of http://schemas.opengis.net
manager:
type: object
description: optional OGC API - Processes asynchronous job management
properties:
name:
type: string
description: plugin name (see `pygeoapi.plugin` for supported process_managers)
connection:
type: string
description: connection info to store jobs (e.g. filepath)
output_dir:
type: string
description: temporary file area for storing job results (files)
required:
- name
- connection
- output_dir
required:
- bind
- url
- mimetype
- encoding
- map
logging:
type: object
description: logging definitions
properties:
level:
type: string
description: |-
The logging level (see https://docs.python.org/3/library/logging.html#logging-levels).
If level is defined and logfile is undefined, logging messages are output to the servers stdout
enum:
- CRITICAL
- ERROR
- WARNING
- INFO
- DEBUG
- NOTSET
logfile:
type: string
description: the full file path to the logfile.
required:
- level
metadata:
type: object
description: server metadata
properties:
identification:
type: object
description: server identification
properties:
title:
$ref: '#/definitions/i18n_string'
description: the title of the service
description:
$ref: '#/definitions/i18n_string'
description: some descriptive text about the service
keywords:
$ref: '#/definitions/i18n_array'
description: list of keywords about the service
keywords_type:
type: string
description: keyword type as per the ISO 19115 MD_KeywordTypeCode codelist
enum:
- discipline
- temporal
- place
- theme
- stratum
terms_of_service:
$ref: '#/definitions/i18n_string'
description: terms of service
url:
type: string
description: informative URL about the service
required:
- title
- description
- keywords
- url
license:
type: object
description: licensing details
properties:
name:
$ref: '#/definitions/i18n_string'
description: licensing details
url:
$ref: '#/definitions/i18n_string'
description: license URL
required:
- name
provider:
type: object
description: service provider details
properties:
name:
$ref: '#/definitions/i18n_string'
description: organization name
url:
$ref: '#/definitions/i18n_string'
description: URL of provider
required:
- name
contact:
type: object
description: service contact details
properties:
name:
type: string
description: Lastname, Firstname
position:
type: string
description: position
address:
type: string
description: postal address
city:
type: string
description: city
stateorprovince:
type: string
description: administrative area
postalcode:
type: string
description: postal or ZIP code
country:
type: string
description: country
phone:
type: string
description: phone number
fax:
type: string
description: fax number
email:
type: string
description: email address
url:
type: string
description: URL of contact
hours:
type: string
description: hours of service
instructions:
type: string
description: contact instructions
role:
type: string
description: role as per the ISO 19115 CI_RoleCode codelist
required:
- name
required:
- identification
- license
- provider
- contact
resources:
type: object
description: collections or processes published by the server
patternProperties:
"^.*$":
anyOf:
- type: object
description: base resource object
properties:
type:
type: string
description: resource type
enum:
- collection
- stac-collection
title:
$ref: '#/definitions/i18n_string'
description: the title of the service
description:
$ref: '#/definitions/i18n_string'
description: some descriptive text about the service
keywords:
$ref: '#/definitions/i18n_array'
description: list of keywords about the service
context:
type: array
description: linked data configuration
items:
- type: object
patternProperties:
"^.*$":
anyOf:
- type: string
- type: object
links:
type: array
description: list of related links
minItems: 0
items:
type: object
properties:
type:
type: string
description: MIME type
rel:
type: string
description: link relations per https://www.iana.org/assignments/link-relations/link-relations.xhtml
title:
type: string
description: title
href:
type: string
description: URL
hreflang:
type: string
description: language
required:
- type
- rel
- href
extents:
type: object
description: spatial and temporal extents
properties:
spatial:
type: object
description: spatial extent and CRS
properties:
bbox:
type: array
description: bounding box of resource
items:
type: number
minItems: 4
maxItems: 6
crs:
type: string
description: coordinate reference system of bbox
default: 'http://www.opengis.net/def/crs/OGC/1.3/CRS84'
required:
- bbox
temporal:
type: object
description: temporal extent of resource
properties:
begin:
type: [string, 'null']
format: date-time
nullable: true
end:
type: [string, 'null']
format: date-time
nullable: true
required:
- spatial
providers:
type: array
description: required connection information
items:
type: object
properties:
type:
type: string
description: underlying data geospatial type
enum:
- feature
- coverage
- record
- tile
- edr
- stac
default:
type: boolean
description: |-
whether the provider is the default. If not specified, the
first provider definition is considered the default
name:
type: string
description: |-
see `pygeoapi.plugin` for supported provider names.
For custom built plugins, use the import path (e.g. `mypackage.provider.MyProvider`)
data:
anyOf:
- type: string
- type: object
description: the data filesystem path or URL, depending on plugin setup
table:
type: string
description: table name for RDBMS-based providers
id_field:
type: string
description: required for vector data, the field corresponding to the ID
geometry:
type: object
description: the field corresponding to the geometry
properties:
x_field:
type: string
description: the field corresponding to the x geometry
y_field:
type: string
description: the field corresponding to the y geometry
required:
- x_field
- y_field
time_field:
type: string
description: optional field corresponding to the temporal property of the dataset
title_field:
type: string
description: optional field of which property to display as title/label on HTML pages
format:
type: object
description: default format
properties:
name:
type: string
description: format name
mimetype:
type: string
description: format mimetype
required:
- name
- mimetype
options:
type: object
description: optional options key value pairs to pass to provider (i.e. GDAL creation)
patternProperties:
"^[a-z]{2}$":
allOf:
- type: string
properties:
type: array
description: only return the following properties, in order
items:
type: string
minItems: 1
uniqueItems: true
required:
- type
- name
- data
required:
- type
- title
- description
- keywords
- extents
- providers
- type: object
description: process object
properties:
type:
type: string
description: resource type
enum:
- process
processor:
type: object
description: process binding
properties:
name:
type: string
description: |-
see `pygeoapi.plugin` for supported provider names.
For custom built plugins, use the import path (e.g. `mypackage.provider.MyProvider`)
required:
- name
required:
- type
- processor
definitions:
i18n_string:
oneOf:
- type: string
- type: object
patternProperties:
"^[a-zA-Z]{2,3}([-_][a-zA-Z0-9]{2,3})?$":
allOf:
- type: string
i18n_array:
oneOf:
- type: array
items:
type: string
- type: object
patternProperties:
"^[a-zA-Z]{2,3}([-_][a-zA-Z0-9]{2,3})?$":
allOf:
- type: array
items:
type: string
required:
- server
- logging
- metadata
- resources
+1 -1
View File
@@ -456,7 +456,7 @@ def get_provider_default(providers):
try: try:
default = (next(d for i, d in enumerate(providers) if 'default' in d default = (next(d for i, d in enumerate(providers) if 'default' in d
and d['default'] is True)) and d['default']))
LOGGER.debug('found default provider type') LOGGER.debug('found default provider type')
except StopIteration: except StopIteration:
LOGGER.debug('no default provider type. Returning first provider') LOGGER.debug('no default provider type. Returning first provider')
+1 -1
View File
@@ -14,6 +14,6 @@ pip install gunicorn
cd tests/cite/ogcapi-features cd tests/cite/ogcapi-features
. cite.env . cite.env
python ../../load_es_data.py ./canada-hydat-daily-mean-02hc003.geojson IDENTIFIER python ../../load_es_data.py ./canada-hydat-daily-mean-02hc003.geojson IDENTIFIER
pygeoapi openapi generate -c $PYGEOAPI_CONFIG > $PYGEOAPI_OPENAPI pygeoapi openapi generate $PYGEOAPI_CONFIG > $PYGEOAPI_OPENAPI
gunicorn pygeoapi.flask_app:APP -b 0.0.0.0:5001 --access-logfile '-' gunicorn pygeoapi.flask_app:APP -b 0.0.0.0:5001 --access-logfile '-'
``` ```
+2 -1
View File
@@ -48,6 +48,7 @@ server:
manager: manager:
name: TinyDB name: TinyDB
connection: /tmp/pygeoapi-test-process-manager.db connection: /tmp/pygeoapi-test-process-manager.db
output_dir: /tmp
logging: logging:
level: ERROR level: ERROR
@@ -195,7 +196,7 @@ resources:
bbox: [-180,-90,180,90] bbox: [-180,-90,180,90]
crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84
temporal: temporal:
begin: 2011-11-11 begin: 2011-11-11T11:11:11Z
end: null # or empty (either means open ended) end: null # or empty (either means open ended)
providers: providers:
- type: feature - type: feature
+1
View File
@@ -360,6 +360,7 @@ resources:
id_field: objectid id_field: objectid
cases_italy_per_region_from_github: cases_italy_per_region_from_github:
type: collection
title: "Cases in Italy - DPC GitHub" title: "Cases in Italy - DPC GitHub"
description: "Current situation within Italy, number of cases with variation per Italy, provided by ESRI, source data from DPC." description: "Current situation within Italy, number of cases with variation per Italy, provided by ESRI, source data from DPC."
keywords: [Daily, Cases Variation, Region] keywords: [Daily, Cases Variation, Region]
+16
View File
@@ -29,13 +29,21 @@
import os import os
from jsonschema.exceptions import ValidationError
import pytest import pytest
from pygeoapi.config import validate_config
from pygeoapi.util import yaml_load from pygeoapi.util import yaml_load
from .util import get_test_file_path from .util import get_test_file_path
@pytest.fixture()
def config():
with open(get_test_file_path('pygeoapi-test-config.yml')) as fh:
return yaml_load(fh)
def test_config_envvars(): def test_config_envvars():
os.environ['PYGEOAPI_PORT'] = '5001' os.environ['PYGEOAPI_PORT'] = '5001'
os.environ['PYGEOAPI_TITLE'] = 'my title' os.environ['PYGEOAPI_TITLE'] = 'my title'
@@ -53,3 +61,11 @@ def test_config_envvars():
with pytest.raises(EnvironmentError): with pytest.raises(EnvironmentError):
with open(get_test_file_path('pygeoapi-test-config-envvars.yml')) as fh: # noqa with open(get_test_file_path('pygeoapi-test-config-envvars.yml')) as fh: # noqa
config = yaml_load(fh) config = yaml_load(fh)
def test_validate_config(config):
is_valid = validate_config(config)
assert is_valid
with pytest.raises(ValidationError):
is_valid = validate_config({'foo': 'bar'})
+6 -2
View File
@@ -29,6 +29,8 @@
import pytest import pytest
from jsonschema.exceptions import ValidationError
from pygeoapi.openapi import (get_oas, get_ogc_schemas_location, from pygeoapi.openapi import (get_oas, get_ogc_schemas_location,
validate_openapi_document) validate_openapi_document)
from pygeoapi.util import yaml_load from pygeoapi.util import yaml_load
@@ -72,10 +74,12 @@ def test_get_oas(config, openapi):
is_valid = validate_openapi_document(openapi_doc) is_valid = validate_openapi_document(openapi_doc)
assert is_valid is True assert is_valid
def test_validate_openapi_document(openapi): def test_validate_openapi_document(openapi):
is_valid = validate_openapi_document(openapi) is_valid = validate_openapi_document(openapi)
assert is_valid
assert is_valid is True with pytest.raises(ValidationError):
is_valid = validate_openapi_document({'foo': 'bar'})
+13 -13
View File
@@ -59,19 +59,19 @@ def test_yaml_load():
def test_str2bool(): def test_str2bool():
assert util.str2bool(False) is False assert not util.str2bool(False)
assert util.str2bool('0') is False assert not util.str2bool('0')
assert util.str2bool('no') is False assert not util.str2bool('no')
assert util.str2bool('yes') is True assert util.str2bool('yes')
assert util.str2bool('1') is True assert util.str2bool('1')
assert util.str2bool(True) is True assert util.str2bool(True)
assert util.str2bool('true') is True assert util.str2bool('true')
assert util.str2bool('True') is True assert util.str2bool('True')
assert util.str2bool('TRUE') is True assert util.str2bool('TRUE')
assert util.str2bool('tRuE') is True assert util.str2bool('tRuE')
assert util.str2bool('on') is True assert util.str2bool('on')
assert util.str2bool('On') is True assert util.str2bool('On')
assert util.str2bool('off') is False assert not util.str2bool('off')
def test_json_serial(): def test_json_serial():