diff --git a/async.yml b/async.yml new file mode 100644 index 000000000..0da167d48 --- /dev/null +++ b/async.yml @@ -0,0 +1,116 @@ +asyncapi: 3.0.0 +channels: + notify-air-quality: + address: messages/a/data/collections/air-quality + description: Fraunhofer IOSB Air Quality + messages: + DefaultMessage: + payload: + $ref: https://raw.githubusercontent.com/wmo-im/wis2-monitoring-events/refs/heads/main/schemas/cloudevents-v1.0.2.yaml + notify-canada-metadata: + address: messages/a/data/collections/canada-metadata + description: Open Canada sample data + messages: + DefaultMessage: + payload: + $ref: https://raw.githubusercontent.com/wmo-im/wis2-monitoring-events/refs/heads/main/schemas/cloudevents-v1.0.2.yaml + notify-icoads-sst: + address: messages/a/data/collections/icoads-sst + description: International Comprehensive Ocean-Atmosphere Data Set (ICOADS) + messages: + DefaultMessage: + payload: + $ref: https://raw.githubusercontent.com/wmo-im/wis2-monitoring-events/refs/heads/main/schemas/cloudevents-v1.0.2.yaml + notify-lakes: + address: messages/a/data/collections/lakes + description: Large Lakes + messages: + DefaultMessage: + payload: + $ref: https://raw.githubusercontent.com/wmo-im/wis2-monitoring-events/refs/heads/main/schemas/cloudevents-v1.0.2.yaml + notify-mapserver_world_map: + address: messages/a/data/collections/mapserver_world_map + description: MapServer demo WMS world map + messages: + DefaultMessage: + payload: + $ref: https://raw.githubusercontent.com/wmo-im/wis2-monitoring-events/refs/heads/main/schemas/cloudevents-v1.0.2.yaml + notify-obs: + address: messages/a/data/collections/obs + description: Observations + messages: + DefaultMessage: + payload: + $ref: https://raw.githubusercontent.com/wmo-im/wis2-monitoring-events/refs/heads/main/schemas/cloudevents-v1.0.2.yaml +defaultContentType: application/json +id: http://localhost:5002 +info: + contact: + email: you@example.org + name: Lastname, Firstname + description: pygeoapi provides an API to geospatial data + externalDocs: + url: https://example.org + license: + name: CC-BY 4.0 license + url: https://creativecommons.org/licenses/by/4.0/ + tags: + - name: geospatial + - name: data + - name: api + title: pygeoapi default instance + version: 0.24.dev0 +operations: + consume-air-quality: + action: receive + channel: + $ref: '#/channels/notify-air-quality' + consume-canada-metadata: + action: receive + channel: + $ref: '#/channels/notify-canada-metadata' + consume-icoads-sst: + action: receive + channel: + $ref: '#/channels/notify-icoads-sst' + consume-lakes: + action: receive + channel: + $ref: '#/channels/notify-lakes' + consume-mapserver_world_map: + action: receive + channel: + $ref: '#/channels/notify-mapserver_world_map' + consume-obs: + action: receive + channel: + $ref: '#/channels/notify-obs' + publish-air-quality: + action: send + channel: + $ref: '#/channels/notify-air-quality' + publish-canada-metadata: + action: send + channel: + $ref: '#/channels/notify-canada-metadata' + publish-icoads-sst: + action: send + channel: + $ref: '#/channels/notify-icoads-sst' + publish-lakes: + action: send + channel: + $ref: '#/channels/notify-lakes' + publish-mapserver_world_map: + action: send + channel: + $ref: '#/channels/notify-mapserver_world_map' + publish-obs: + action: send + channel: + $ref: '#/channels/notify-obs' +servers: + default: + description: pygeoapi provides an API to geospatial data + host: localhost:1883 + protocol: mqtt diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 0b0f47301..0a8a58828 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -84,14 +84,14 @@ cd ${PYGEOAPI_HOME} echo "Default config in ${PYGEOAPI_CONFIG}" echo "Trying to generate openapi.yml" -/venv/bin/pygeoapi openapi generate ${PYGEOAPI_CONFIG} --output-file ${PYGEOAPI_OPENAPI} ${OPENAPI_GENERATE_FAIL_ON_INVALID_COLLECTION} +/venv/bin/pygeoapi openapi generate ${OPENAPI_GENERATE_FAIL_ON_INVALID_COLLECTION} [[ $? -ne 0 ]] && error "openapi.yml could not be generated ERROR" echo "openapi.yml generated continue to pygeoapi" echo "Trying to generate asyncapi.yml" -/venv/bin/pygeoapi asyncapi generate ${PYGEOAPI_CONFIG} --output-file ${PYGEOAPI_ASYNCAPI} +/venv/bin/pygeoapi asyncapi generate [[ $? -ne 0 ]] && echo "asyncapi.yml could not be generated; skipping" diff --git a/docs/source/administration.rst b/docs/source/administration.rst index c947365b3..3f6ebcf1a 100644 --- a/docs/source/administration.rst +++ b/docs/source/administration.rst @@ -20,19 +20,19 @@ To generate the OpenAPI document, run the following: .. code-block:: bash - pygeoapi openapi generate /path/to/my-pygeoapi-config.yml + pygeoapi openapi generate /path/to/my-pygeoapi-config.yml -f yaml This will dump the OpenAPI document as YAML to your system's ``stdout``. To save to a file on disk, run: .. code-block:: bash - pygeoapi openapi generate /path/to/my-pygeoapi-config.yml --output-file /path/to/my-pygeoapi-openapi.yml + pygeoapi openapi generate /path/to/my-pygeoapi-config.yml --openapi-file /path/to/my-pygeoapi-openapi.yml To generate the OpenAPI document as JSON, run: .. code-block:: bash - pygeoapi openapi generate /path/to/my-pygeoapi-config.yml --format json --output-file /path/to/my-pygeoapi-openapi.json + pygeoapi openapi generate /path/to/my-pygeoapi-config.yml --openapi-file /path/to/my-pygeoapi-openapi.json .. note:: Generate as YAML or JSON? If your OpenAPI YAML definition is slow to render as JSON, diff --git a/docs/source/pubsub.rst b/docs/source/pubsub.rst index 739a50d93..210f901b4 100644 --- a/docs/source/pubsub.rst +++ b/docs/source/pubsub.rst @@ -45,19 +45,19 @@ To generate the AsyncAPI document, run the following: .. code-block:: bash - pygeoapi asyncapi generate /path/to/my-pygeoapi-config.yml + pygeoapi asyncapi generate /path/to/my-pygeoapi-config.yml -f json -This will dump the AsyncAPI document as YAML to your system's ``stdout``. To save to a file on disk, run: +This will dump the AsyncAPI document as YAML to your system's ``stdout`` as JSON. To save to a file on disk, run: .. code-block:: bash - pygeoapi asyncapi generate /path/to/my-pygeoapi-config.yml --output-file /path/to/my-pygeoapi-asyncapi.yml + pygeoapi asyncapi generate /path/to/my-pygeoapi-config.yml --asyncapi-file /path/to/my-pygeoapi-asyncapi.yml To generate the AsyncAPI document as JSON, run: .. code-block:: bash - pygeoapi asyncapi generate /path/to/my-pygeoapi-config.yml --format json --output-file /path/to/my-pygeoapi-asyncapi.json + pygeoapi asyncapi generate /path/to/my-pygeoapi-config.yml --asyncapi-file /path/to/my-pygeoapi-asyncapi.json .. note:: Generate as YAML or JSON? If your AsyncAPI YAML definition is slow to render as JSON, diff --git a/pygeoapi/api/admin.py b/pygeoapi/api/admin.py index a971e1f25..0b4a6068f 100644 --- a/pygeoapi/api/admin.py +++ b/pygeoapi/api/admin.py @@ -40,7 +40,7 @@ from jsonschema.exceptions import ValidationError from pygeoapi.api import API, APIRequest -from pygeoapi.config import get_config, validate_config +from pygeoapi.config import get_config, validate_config_document from pygeoapi.formats import F_HTML from pygeoapi.openapi import get_oas from pygeoapi.util import to_json, render_j2_template, yaml_dump @@ -99,7 +99,7 @@ def validate(self, config: dict): # validate pygeoapi configuration LOGGER.debug('Validating configuration') - validate_config(config) + validate_config_document(config) # validate OpenAPI document # LOGGER.debug('Validating openapi document') # oas = get_oas(config) @@ -125,7 +125,7 @@ def write_config(self, config: dict): # validate pygeoapi configuration config = deepcopy(config) - validate_config(config) + validate_config_document(config) # Preserve env variables LOGGER.debug('Reading env variables in configuration') diff --git a/pygeoapi/asyncapi.py b/pygeoapi/asyncapi.py index 055cc1ba0..573ada337 100644 --- a/pygeoapi/asyncapi.py +++ b/pygeoapi/asyncapi.py @@ -38,8 +38,9 @@ import yaml from pygeoapi import __version__, l10n +from pygeoapi.config import get_config, cli_config from pygeoapi.models.openapi import OAPIFormat -from pygeoapi.util import to_json, yaml_load, remove_url_auth +from pygeoapi.util import to_json, yaml_load, remove_url_auth, SCHEMASDIR LOGGER = logging.getLogger(__name__) @@ -150,20 +151,13 @@ def gen_asyncapi(cfg: dict) -> dict: return a -def get_asyncapi(cfg, version='3.0'): - """ - Stub to generate AsyncAPI Document +def get_asyncapi_schema() -> dict: + """Reads the asyncapi JSON schema file.""" - :param cfg: configuration object - :param version: version of AsyncAPI (default 3.0) + schema_file = SCHEMASDIR / 'asyncapi' / 'asyncapi-3.0.0.json' - :returns: AsyncAPI definition YAML dict - """ - - if version == '3.0': - return gen_asyncapi(cfg) - else: - raise RuntimeError('AsyncAPI version not supported') + with schema_file.open() as fh: + return json.load(fh) def validate_asyncapi_document(instance_dict): @@ -175,15 +169,9 @@ def validate_asyncapi_document(instance_dict): :returns: `bool` of validation """ - schema_file = os.path.join( - THISDIR, 'resources', 'schemas', 'asyncapi', 'asyncapi-3.0.0.json') - - LOGGER.debug(f'Validating against {schema_file}') - with open(schema_file) as fh2: - schema_dict = json.load(fh2) - jsonschema_validate(instance_dict, schema_dict) + jsonschema_validate(instance_dict, get_asyncapi_schema()) - return True + return True def generate_asyncapi_document(cfg: dict, output_format: OAPIFormat): @@ -199,34 +187,38 @@ def generate_asyncapi_document(cfg: dict, output_format: OAPIFormat): pretty_print = cfg['server'].get('pretty_print', False) - if output_format == 'yaml': - content = yaml.safe_dump(get_asyncapi(cfg), default_flow_style=False) + if output_format.endswith(('yaml', 'yml')): + content = yaml.safe_dump(gen_asyncapi(cfg), default_flow_style=False) else: - content = to_json(get_asyncapi(cfg), pretty=pretty_print) + content = to_json(gen_asyncapi(cfg), pretty=pretty_print) return content -def load_asyncapi_document() -> dict: +def get_asyncapi(file_path: str | None = None) -> dict: """ - Open AsyncAPI document from `PYGEOAPI_ASYNCAPI` environment variable + Read pygeoapi AsyncAPI document - :returns: `dict` of AsyncAPI document + :param file_path: `str` of path to configuration file; if `None`, + reads from `PYGEOAPI_ASYNCAPI` environment variable + + :returns: `dict` of OpenAPI document """ - pygeoapi_asyncapi = os.environ.get('PYGEOAPI_ASYNCAPI') + if file_path is None: + file_path = os.environ.get('PYGEOAPI_ASYNCAPI') - if pygeoapi_asyncapi is None: + if not file_path: LOGGER.debug('PYGEOAPI_ASYNCAPI environment not set') return {} - if not os.path.exists(pygeoapi_asyncapi): - msg = (f'AsyncAPI document {pygeoapi_asyncapi} does not exist. ' + if not os.path.exists(file_path): + msg = (f'AsyncAPI document {file_path} does not exist. ' 'Please generate before starting pygeoapi') - LOGGER.warning(msg) - return {} + LOGGER.error(msg) + raise RuntimeError(msg) - with open(pygeoapi_asyncapi, encoding='utf8') as ff: - if pygeoapi_asyncapi.endswith(('.yaml', '.yml')): + with open(file_path, encoding='utf8') as ff: + if file_path.endswith(('yaml', 'yml')): asyncapi_ = yaml_load(ff) else: # JSON string, do not transform asyncapi_ = ff.read() @@ -242,50 +234,59 @@ def asyncapi(): @click.command() @click.pass_context -@click.argument('config_file', type=click.File(encoding='utf-8')) +@cli_config +@click.option( + '--asyncapi-file', + '-af', + 'asyncapi_file', + type=click.File('w'), + envvar='PYGEOAPI_ASYNCAPI', + help='Name of asyncapi file (env: PYGEOAPI_ASYNCAPI)' +) +@click.option('--output-file', 'deprecated', type=click.File('w'), hidden=True) @click.option('--format', '-f', 'format_', type=click.Choice(['json', 'yaml']), - default='yaml', help='output format (json|yaml)') -@click.option('--output-file', '-of', type=click.File('w', encoding='utf-8'), - help='Name of output file') -def generate(ctx, config_file, output_file, format_='yaml'): + help='output format (json|yaml); only applies to stdout.') +def generate(ctx, config_file, asyncapi_file, deprecated, format_): """Generate AsyncAPI Document""" - if config_file is None: - raise click.ClickException('--config/-c required') + if deprecated is not None: + click.echo( + 'Warning: --output-file is deprecated; use --asyncapi-file', + err=True, + ) + if asyncapi_file is None: + asyncapi_file = deprecated - if isinstance(config_file, Path): - with config_file.open(mode='r') as cf: - cfg = yaml_load(cf) - else: - cfg = yaml_load(config_file) + cfg = get_config(config_file) if 'pubsub' not in cfg: click.echo('pubsub not configured; aborting') ctx.exit(1) + format_ = Path(asyncapi_file.name).suffix if asyncapi_file else format_ content = generate_asyncapi_document(cfg, format_) - if output_file is None: + if asyncapi_file is None: click.echo(content) else: - click.echo(f'Generating {output_file.name}') - output_file.write(content) + click.echo(f'Generating {asyncapi_file.name}') + asyncapi_file.write(content) click.echo('Done') @click.command() @click.pass_context -@click.argument('asyncapi_file', type=click.File()) +@click.argument('asyncapi_file', type=click.File(), envvar='PYGEOAPI_ASYNCAPI') def validate(ctx, asyncapi_file): """Validate AsyncAPI Document""" if asyncapi_file is None: - raise click.ClickException('--asyncapi/-o required') + raise click.ClickException('asyncapi file required') click.echo(f'Validating {asyncapi_file.name}') instance = yaml_load(asyncapi_file) - validate_asyncapi_document(instance) - click.echo('Valid AsyncAPI document') + if validate_asyncapi_document(instance): + click.echo('Valid AsyncAPI document') asyncapi.add_command(generate) diff --git a/pygeoapi/config.py b/pygeoapi/config.py index 88f50e74b..83c5c951a 100644 --- a/pygeoapi/config.py +++ b/pygeoapi/config.py @@ -34,6 +34,7 @@ from jsonschema import validate as jsonschema_validate import logging import os +from pathlib import Path import yaml from pygeoapi.util import SCHEMASDIR, to_json, yaml_load @@ -41,37 +42,50 @@ LOGGER = logging.getLogger(__name__) -def get_config(raw: bool = False) -> dict: +def get_config(file_path: str | None = None, raw: bool = False) -> dict: """ - Get pygeoapi configurations + Read pygeoapi configuration document + :param file_path: `str` of path to configuration file; if `None`, + reads from `PYGEOAPI_CONFIG` environment variable :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') + if file_path is None: + file_path = os.environ.get('PYGEOAPI_CONFIG') - with open(os.environ.get('PYGEOAPI_CONFIG'), encoding='utf8') as fh: + if not file_path: + msg = 'PYGEOAPI_CONFIG file not specified' + LOGGER.error(msg) + raise RuntimeError(msg) + + if not os.path.exists(file_path): + msg = (f'pygeoapi configuration {file_path} does not exist. ' + 'Please create before starting pygeoapi') + LOGGER.error(msg) + raise RuntimeError(msg) + + with open(file_path, encoding='utf8') as fh: if raw: - CONFIG = yaml.safe_load(fh) + config_ = yaml.safe_load(fh) else: - CONFIG = yaml_load(fh) + config_ = yaml_load(fh) - return CONFIG + return config_ -def load_schema() -> dict: - """ Reads the JSON schema YAML file. """ +def get_config_schema() -> dict: + """Reads the JSON schema YAML file.""" schema_file = SCHEMASDIR / 'config' / 'pygeoapi-config-0.x.yml' - with schema_file.open() as fh2: - return yaml_load(fh2) + with schema_file.open() as fh: + return yaml_load(fh) -def validate_config(instance_dict: dict) -> bool: +def validate_config_document(instance_dict: dict) -> bool: """ Validate pygeoapi configuration against pygeoapi schema @@ -80,11 +94,25 @@ def validate_config(instance_dict: dict) -> bool: :returns: `bool` of validation """ - jsonschema_validate(json.loads(to_json(instance_dict)), load_schema()) + # Load as simple JSON + config = json.loads(to_json(instance_dict)) + jsonschema_validate(config, get_config_schema()) return True +cli_config = click.option( + '--config', + '--config-file', + '-c', + 'config_file', + required=True, + type=click.Path(dir_okay=False, path_type=Path), + envvar='PYGEOAPI_CONFIG', + help='Name of configuration document (env: PYGEOAPI_CONFIG)' +) + + @click.group() def config(): """Configuration management""" @@ -93,17 +121,13 @@ def config(): @click.command() @click.pass_context -@click.option('--config', '-c', 'config_file', help='configuration file') +@cli_config 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(f'Validating {config_file}') - instance = yaml_load(ff) - validate_config(instance) + click.echo(f'Validating {config_file.name}') + instance = get_config(config_file) + if validate_config_document(instance): click.echo('Valid configuration') diff --git a/pygeoapi/django_/settings.py b/pygeoapi/django_/settings.py index 53dad2efa..99a32bb5a 100644 --- a/pygeoapi/django_/settings.py +++ b/pygeoapi/django_/settings.py @@ -48,7 +48,7 @@ import os # pygeoapi specific from pygeoapi.config import get_config -from pygeoapi.openapi import load_openapi_document +from pygeoapi.openapi import get_openapi from pygeoapi.util import get_api_rules # Build paths inside the project like this: os.path.join(BASE_DIR, ...) @@ -168,7 +168,7 @@ # pygeoapi specific PYGEOAPI_CONFIG = get_config() -OPENAPI_DOCUMENT = load_openapi_document() +OPENAPI_DOCUMENT = get_openapi() API_RULES = get_api_rules(PYGEOAPI_CONFIG) diff --git a/pygeoapi/flask_app.py b/pygeoapi/flask_app.py index 6a272e4d2..455a1e52d 100644 --- a/pygeoapi/flask_app.py +++ b/pygeoapi/flask_app.py @@ -46,15 +46,15 @@ import pygeoapi.api.processes as processes_api import pygeoapi.api.stac as stac_api import pygeoapi.api.tiles as tiles_api -from pygeoapi.asyncapi import load_asyncapi_document -from pygeoapi.openapi import load_openapi_document +from pygeoapi.asyncapi import get_asyncapi +from pygeoapi.openapi import get_openapi from pygeoapi.config import get_config from pygeoapi.util import get_mimetype, get_api_rules CONFIG = get_config() -OPENAPI = load_openapi_document() -ASYNCAPI = load_asyncapi_document() +OPENAPI = get_openapi() +ASYNCAPI = get_asyncapi() API_RULES = get_api_rules(CONFIG) diff --git a/pygeoapi/openapi.py b/pygeoapi/openapi.py index 5c2f4ffc4..cd5c659ca 100644 --- a/pygeoapi/openapi.py +++ b/pygeoapi/openapi.py @@ -32,12 +32,10 @@ # ================================================================= from copy import deepcopy -import io import json import logging import os from pathlib import Path -from typing import Union import click from jsonschema import validate as jsonschema_validate @@ -45,6 +43,7 @@ from pygeoapi import l10n from pygeoapi.api import all_apis +from pygeoapi.config import get_config, cli_config, get_config_schema from pygeoapi.models.openapi import OAPIFormat from pygeoapi.util import (filter_dict_by_key_value, to_json, yaml_load, get_api_rules, get_base_url, SCHEMASDIR) @@ -738,13 +737,6 @@ def get_visible_collections(cfg: dict) -> dict: } -def get_config_schema(): - schema_file = SCHEMASDIR / 'config' / 'pygeoapi-config-0.x.yml' - - with schema_file.open() as fh2: - return yaml_load(fh2) - - def get_admin(cfg: dict) -> dict: schema_dict = get_config_schema() @@ -1002,7 +994,16 @@ def get_oas(cfg: dict, fail_on_invalid_collection: bool = True, raise RuntimeError('OpenAPI version not supported') -def validate_openapi_document(instance_dict: dict) -> bool: +def get_openapi_schema() -> dict: + """Reads the JSON schema YAML file.""" + + schema_file = SCHEMASDIR / 'openapi' / 'openapi-3.0.x.json' + + with schema_file.open() as fh: + return json.load(fh) + + +def validate_openapi_document(openapi_dict: dict) -> bool: """ Validate an OpenAPI document against the OpenAPI schema @@ -1011,17 +1012,12 @@ def validate_openapi_document(instance_dict: dict) -> bool: :returns: `bool` of validation """ - schema_file = SCHEMASDIR / 'openapi' / 'openapi-3.0.x.json' + jsonschema_validate(openapi_dict, get_openapi_schema()) - LOGGER.debug(f'Validating against {schema_file}') - with schema_file.open() as fh2: - schema_dict = json.load(fh2) - jsonschema_validate(instance_dict, schema_dict) + return True - return True - -def generate_openapi_document(cfg_file: Union[Path, io.TextIOWrapper], +def generate_openapi_document(cfg_file: Path, output_format: OAPIFormat, fail_on_invalid_collection: bool = True) -> str: """ @@ -1038,45 +1034,47 @@ def generate_openapi_document(cfg_file: Union[Path, io.TextIOWrapper], LOGGER.debug(f'Loading configuration {cfg_file}') - if isinstance(cfg_file, Path): - with cfg_file.open(mode="r") as cf: - s = yaml_load(cf) - else: - s = yaml_load(cfg_file) + s = get_config(cfg_file) pretty_print = s['server'].get('pretty_print', False) oas = get_oas(s, fail_on_invalid_collection=fail_on_invalid_collection) - if output_format == 'yaml': + if output_format.endswith(('yaml', 'yml')): content = yaml.safe_dump(oas, default_flow_style=False) else: content = to_json(oas, pretty=pretty_print) + return content -def load_openapi_document() -> dict: +def get_openapi(file_path: str | None = None) -> dict: """ - Open OpenAPI document from `PYGEOAPI_OPENAPI` environment variable + Read pygeoapi openapi document + + :param file_path: `str` of path to configuration file; if `None`, + reads from `PYGEOAPI_OPENAPI` environment variable + :returns: `dict` of OpenAPI document """ - pygeoapi_openapi = os.environ.get('PYGEOAPI_OPENAPI') + if file_path is None: + file_path = os.environ.get('PYGEOAPI_OPENAPI') - if pygeoapi_openapi is None: - msg = 'PYGEOAPI_OPENAPI environment not set' + if not file_path: + msg = 'PYGEOAPI_OPENAPI file not specified' LOGGER.error(msg) raise RuntimeError(msg) - if not os.path.exists(pygeoapi_openapi): - msg = (f'OpenAPI document {pygeoapi_openapi} does not exist. ' + if not os.path.exists(file_path): + msg = (f'OpenAPI document {file_path} does not exist. ' 'Please generate before starting pygeoapi') LOGGER.error(msg) raise RuntimeError(msg) - with open(pygeoapi_openapi, encoding='utf8') as ff: - if pygeoapi_openapi.endswith(('.yaml', '.yml')): + with open(file_path, encoding='utf8') as ff: + if file_path.endswith(('.yaml', '.yml')): openapi_ = yaml_load(ff) else: # JSON string, do not transform openapi_ = ff.read() @@ -1092,44 +1090,58 @@ def openapi(): @click.command() @click.pass_context -@click.argument('config_file', type=click.File(encoding='utf-8')) +@cli_config +@click.option( + '--openapi-file', + '-of', + 'openapi_file', + type=click.File('w'), + envvar='PYGEOAPI_OPENAPI', + help='Name of openapi document (env: PYGEOAPI_OPENAPI)' +) +@click.option('--output-file', 'deprecated', type=click.File('w'), hidden=True) @click.option('--fail-on-invalid-collection/--no-fail-on-invalid-collection', '-fic', default=True, help='Fail on invalid collection') @click.option('--format', '-f', 'format_', type=click.Choice(['json', 'yaml']), - default='yaml', help='output format (json|yaml)') -@click.option('--output-file', '-of', type=click.File('w', encoding='utf-8'), - help='Name of output file') -def generate(ctx, config_file, output_file, format_='yaml', + help='output format (json|yaml); only applies to stdout.') +def generate(ctx, config_file, openapi_file, deprecated, format_, fail_on_invalid_collection=True): """Generate OpenAPI Document""" - if config_file is None: - raise click.ClickException('--config/-c required') + if deprecated is not None: + click.echo( + 'Warning: --output-file is deprecated; use --openapi-file', + err=True, + ) + if openapi_file is None: + openapi_file = deprecated + format_ = Path(openapi_file.name).suffix if openapi_file else format_ content = generate_openapi_document( - config_file, format_, fail_on_invalid_collection) + config_file, format_, fail_on_invalid_collection + ) - if output_file is None: + if openapi_file is None: click.echo(content) else: - click.echo(f'Generating {output_file.name}') - output_file.write(content) + click.echo(f'Generating {openapi_file.name}') + openapi_file.write(content) click.echo('Done') @click.command() @click.pass_context -@click.argument('openapi_file', type=click.File()) +@click.argument('openapi_file', type=click.File(), envvar='PYGEOAPI_OPENAPI') def validate(ctx, openapi_file): """Validate OpenAPI Document""" if openapi_file is None: - raise click.ClickException('--openapi/-o required') + raise click.ClickException('openapi file required') - click.echo(f'Validating {openapi_file}') + click.echo(f'Validating {openapi_file.name}') instance = yaml_load(openapi_file) - validate_openapi_document(instance) - click.echo('Valid OpenAPI document') + if validate_openapi_document(instance): + click.echo('Valid OpenAPI document') openapi.add_command(generate) diff --git a/pygeoapi/starlette_app.py b/pygeoapi/starlette_app.py index 6313cbfb3..dc1bf9109 100644 --- a/pygeoapi/starlette_app.py +++ b/pygeoapi/starlette_app.py @@ -59,8 +59,8 @@ import pygeoapi.api.processes as processes_api import pygeoapi.api.stac as stac_api import pygeoapi.api.tiles as tiles_api -from pygeoapi.asyncapi import load_asyncapi_document -from pygeoapi.openapi import load_openapi_document +from pygeoapi.asyncapi import get_asyncapi +from pygeoapi.openapi import get_openapi from pygeoapi.config import get_config from pygeoapi.util import get_api_rules @@ -69,8 +69,8 @@ if 'PYGEOAPI_OPENAPI' not in os.environ: raise RuntimeError('PYGEOAPI_OPENAPI environment variable not set') -OPENAPI = load_openapi_document() -ASYNCAPI = load_asyncapi_document() +OPENAPI = get_openapi() +ASYNCAPI = get_asyncapi() if CONFIG['server'].get('admin'): import pygeoapi.api.admin as admin_api diff --git a/tests/other/test_config.py b/tests/other/test_config.py index 90734b5ee..6673e22c5 100644 --- a/tests/other/test_config.py +++ b/tests/other/test_config.py @@ -33,7 +33,7 @@ import pytest from jsonschema.exceptions import ValidationError -from pygeoapi.config import validate_config +from pygeoapi.config import validate_config_document from pygeoapi.util import yaml_load from ..util import get_test_file_path @@ -76,11 +76,11 @@ def test_config_envvars(): def test_validate_config(config): - is_valid = validate_config(config) + is_valid = validate_config_document(config) assert is_valid with pytest.raises(ValidationError): - validate_config({'foo': 'bar'}) + validate_config_document({'foo': 'bar'}) # Test API rules cfg_copy = deepcopy(config) @@ -90,7 +90,7 @@ def test_validate_config(config): 'url_prefix': 'v{major_version}', 'version_header': 'API-Version' } - assert validate_config(cfg_copy) + assert validate_config_document(cfg_copy) cfg_copy['server']['api_rules'] = { 'api_version': 123, @@ -98,7 +98,7 @@ def test_validate_config(config): 'strict_slashes': 'bad_value' } with pytest.raises(ValidationError): - validate_config(cfg_copy) + validate_config_document(cfg_copy) def test_validate_config_process_manager(config): @@ -112,7 +112,7 @@ def test_validate_config_process_manager(config): 'connection': '/tmp/pygeoapi_test.db', 'output_dir': '/tmp/' } - assert validate_config(cfg_copy) + assert validate_config_document(cfg_copy) with pytest.raises(ValidationError): # make sure an int is validated as invalid @@ -121,7 +121,7 @@ def test_validate_config_process_manager(config): 'connection': 12345, 'output_dir': '/tmp/' } - validate_config(cfg_copy) + validate_config_document(cfg_copy) cfg_copy['server']['manager'] = { 'name': 'PostgreSQL', @@ -134,4 +134,4 @@ def test_validate_config_process_manager(config): }, 'output_dir': '/tmp/' } - assert validate_config(cfg_copy) + assert validate_config_document(cfg_copy) diff --git a/tests/other/test_openapi.py b/tests/other/test_openapi.py index ea99791cf..781f7683c 100644 --- a/tests/other/test_openapi.py +++ b/tests/other/test_openapi.py @@ -27,15 +27,21 @@ # # ================================================================= +from click.testing import CliRunner +import json from jsonschema.exceptions import ValidationError import pytest +import os +import yaml from pygeoapi.openapi import (get_oas, get_ogc_schemas_location, - validate_openapi_document) + validate_openapi_document, generate, validate) from pygeoapi.util import yaml_load from ..util import get_test_file_path +os.environ['PYGEOAPI_URL'] = 'http://localhost:5000' + @pytest.fixture() def config(): @@ -158,3 +164,65 @@ def test_i18n(config_i18n): def test_admin_empty_resources(config_admin_empty_resources): openapi_doc = get_oas(config_admin_empty_resources) assert '/admin/config' in openapi_doc['paths'] + + +def test_generate_openapi_document(): + runner = CliRunner() + file_path = get_test_file_path('pygeoapi-test-config.yml') + file_success = b'Generating tests/pygeoapi-test-openapi.yml' + + # Basic functionality + result = runner.invoke(generate, ['-c', file_path]) + assert result.stderr_bytes == b'' + assert file_success in result.stdout_bytes + assert b'Done' in result.stdout_bytes + + os.environ['PYGEOAPI_CONFIG'] = file_path + result2 = runner.invoke(generate) + assert result2.stderr_bytes == b'' + assert file_success in result2.stdout_bytes + assert b'Done' in result2.stdout_bytes + + assert result.stdout_bytes == result2.stdout_bytes + + # Format is ignored when writing to file + result = runner.invoke(generate, ['-c', file_path, '-f', 'json']) + + assert result.stderr_bytes == b'' + assert file_success in result.stdout_bytes + assert b'Done' in result.stdout_bytes + + +def test_validate(): + # Run and validate openapi document from CLI + runner = CliRunner() + runner.invoke(generate) + result = runner.invoke(validate) + assert result.stderr_bytes == b'' + assert result.stdout_bytes.endswith(b'Valid OpenAPI document\n') + + +def test_generate_openapi_stdout(): + runner = CliRunner() + file_path = get_test_file_path('pygeoapi-test-config.yml') + # Ensure openapi var not in env + openapi_ = os.environ.pop('PYGEOAPI_OPENAPI') + + # Format is recognized JSON + result = runner.invoke(generate, ['-c', file_path, '-f', 'json']) + assert result.stderr_bytes == b'' + assert result.stdout_bytes.startswith(b'{') + assert result.stdout_bytes.endswith(b'}\n') + result2 = json.loads(result.stdout_bytes[:]) + + # Format is recognized YAML + result = runner.invoke(generate, ['-c', file_path, '-f', 'yaml']) + assert result.stderr_bytes == b'' + assert result.stdout_bytes.startswith(b'components') + result3 = yaml.safe_load(result.stdout_bytes[:]) + + # Ensure parity between JSON to YAML + assert result2 == result3 + + # Add openapi var back to environment + os.environ['PYGEOAPI_OPENAPI'] = openapi_