diff --git a/connexion/cli.py b/connexion/cli.py index 9af86c66f..73fd2726b 100644 --- a/connexion/cli.py +++ b/connexion/cli.py @@ -3,18 +3,19 @@ starting point for developing your API with Connexion. """ +import argparse import importlib.metadata import logging +import os import sys -from os import path - -import click -from clickclick import AliasedGroup +import typing as t import connexion +from connexion.apps import AbstractApp from connexion.mock import MockResolver +from connexion.options import SwaggerUIOptions -logger = logging.getLogger("connexion.cli") +logger = logging.getLogger(__name__) FLASK_APP = "flask" ASYNC_APP = "async" @@ -23,182 +24,144 @@ ASYNC_APP: "connexion.apps.asynchronous.AsyncApp", } -# app is defined globally so it can be passed as an import_string to `app.run`, which is needed -# to enable reloading -app = None +def run(app: AbstractApp, args: argparse.Namespace): + app.run("connexion.cli:create_app", port=args.port, host=args.host, factory=True) -def print_version(ctx, param, value): - if not value or ctx.resilient_parsing: - return - click.echo(f"Connexion {importlib.metadata.version('connexion')}") - ctx.exit() +parser = argparse.ArgumentParser() -@click.group(cls=AliasedGroup, context_settings={"help_option_names": ["-h", "--help"]}) -@click.option( - "-V", +parser.add_argument( "--version", - is_flag=True, - callback=print_version, - expose_value=False, - is_eager=True, - help="Print the current version number and exit.", + action="version", + version=f"Connexion {importlib.metadata.version('connexion')}", ) -def main(): - pass +subparsers = parser.add_subparsers() +run_parser = subparsers.add_parser("run") +run_parser.set_defaults(func=run) -@main.command() -@click.argument("spec_file") -@click.argument("base_module_path", required=False) -@click.option("--port", "-p", default=5000, type=int, help="Port to listen.") -@click.option( - "--host", "-H", default="127.0.0.1", type=str, help="Host interface to bind on." +run_parser.add_argument("spec_file", help="Path to OpenAPI specification.") +run_parser.add_argument( + "base_module_path", nargs="?", help="Root directory of handler code." +) +run_parser.add_argument( + "-p", "--port", default=5000, type=int, help="Port to listen on." +) +run_parser.add_argument( + "-H", "--host", default="127.0.0.1", type=str, help="Host interface to bind on." ) -@click.option( +run_parser.add_argument( "--stub", - help="Returns status code 501, and `Not Implemented Yet` payload, for " - "the endpoints which handlers are not found.", - is_flag=True, - default=False, + action="store_true", + help="Returns status code 501, and `Not Implemented Yet` payload, for the endpoints which " + "handlers are not found.", ) -@click.option( +run_parser.add_argument( "--mock", - type=click.Choice(["all", "notimplemented"]), + choices=["all", "notimplemented"], help="Returns example data for all endpoints or for which handlers are not found.", ) -@click.option( - "--hide-spec", - help="Hides the API spec in JSON format which is by default available at `/swagger.json`.", - is_flag=True, - default=False, -) -@click.option( - "--hide-console-ui", - help="Hides the API console UI which is by default available at `/ui`.", - is_flag=True, - default=False, -) -@click.option( - "--console-ui-url", - metavar="URL", +run_parser.add_argument( + "--swagger-ui-path", help="Personalize what URL path the API console UI will be mounted.", + default="/ui", ) -@click.option( - "--console-ui-from", - metavar="PATH", +run_parser.add_argument( + "--swagger-ui-template-dir", help="Path to a customized API console UI dashboard.", ) -@click.option( +run_parser.add_argument( "--auth-all-paths", help="Enable authentication to paths not defined in the spec.", - is_flag=True, - default=False, + action="store_true", ) -@click.option( +run_parser.add_argument( "--validate-responses", help="Enable validation of response values from operation handlers.", - is_flag=True, - default=False, + action="store_true", ) -@click.option( +run_parser.add_argument( "--strict-validation", help="Enable strict validation of request payloads.", - is_flag=True, - default=False, -) -@click.option( - "--debug", "-d", help="Show debugging information.", is_flag=True, default=False + action="store_true", ) -@click.option("--verbose", "-v", help="Show verbose information.", count=True) -@click.option( - "--base-path", metavar="PATH", help="Override the basePath in the API spec." +run_parser.add_argument( + "-v", + "--verbose", + help="Show verbose information.", + action="count", + default=0, ) -@click.option( +run_parser.add_argument("--base-path", help="Override the basePath in the API spec.") +run_parser.add_argument( "--app-framework", "-f", + choices=list(AVAILABLE_APPS), default=ASYNC_APP, - type=click.Choice(list(AVAILABLE_APPS)), help="The app framework used to run the server", ) -def run( - spec_file, - base_module_path, - port, - host, - stub, - mock, - hide_spec, - hide_console_ui, - console_ui_url, - console_ui_from, - auth_all_paths, - validate_responses, - strict_validation, - debug, - verbose, - base_path, - app_framework, -): - """ - Runs a server compliant with a OpenAPI/Swagger 2.0 Specification file. - - Arguments: - - - SPEC_FILE: specification file that describes the server endpoints. - - - BASE_MODULE_PATH (optional): filesystem path where the API endpoints handlers are going to be imported from. - """ - logging_level = logging.WARN - if verbose > 0: - logging_level = logging.INFO - if debug or verbose > 1: + +def create_app(args: t.Optional[argparse.Namespace] = None) -> AbstractApp: + """Runs a server compliant with a OpenAPI/Swagger Specification file.""" + if args is None: + args = parser.parse_args() + + if args.verbose == 1: + logging_level = logging.INFO + elif args.verbose >= 2: logging_level = logging.DEBUG - debug = True + else: + logging_level = logging.WARN logging.basicConfig(level=logging_level) - spec_file_full_path = path.abspath(spec_file) - py_module_path = base_module_path or path.dirname(spec_file_full_path) - sys.path.insert(1, path.abspath(py_module_path)) + spec_file_full_path = os.path.abspath(args.spec_file) + py_module_path = args.base_module_path or os.path.dirname(spec_file_full_path) + sys.path.insert(1, os.path.abspath(py_module_path)) logger.debug(f"Added {py_module_path} to system path.") resolver_error = None - if stub: + if args.stub: resolver_error = 501 api_extra_args = {} - if mock: - resolver = MockResolver(mock_all=mock == "all") + if args.mock: + resolver = MockResolver(mock_all=args.mock == "all") api_extra_args["resolver"] = resolver - app_cls = connexion.utils.get_function_from_name(AVAILABLE_APPS[app_framework]) + app_cls = connexion.utils.get_function_from_name(AVAILABLE_APPS[args.app_framework]) - swagger_ui_options = { - "serve_spec": not hide_spec, - "swagger_path": console_ui_from or None, - "swagger_ui": not hide_console_ui, - "swagger_url": console_ui_url or None, - } + swagger_ui_options = SwaggerUIOptions( + swagger_ui_path=args.swagger_ui_path, + swagger_ui_template_dir=args.swagger_ui_template_dir, + ) - global app app = app_cls( - __name__, auth_all_paths=auth_all_paths, swagger_ui_options=swagger_ui_options + __name__, + auth_all_paths=args.auth_all_paths, + swagger_ui_options=swagger_ui_options, ) app.add_api( spec_file_full_path, - base_path=base_path, + base_path=args.base_path, resolver_error=resolver_error, - validate_responses=validate_responses, - strict_validation=strict_validation, + validate_responses=args.validate_responses, + strict_validation=args.strict_validation, **api_extra_args, ) - app.run("connexion.cli:app", port=port, host=host, debug=debug) + return app + +def main(argv: t.Optional[t.List[str]] = None) -> None: + if argv is None: + argv = sys.argv[1:] + if not argv: + argv = ["--help"] -if __name__ == "__main__": # pragma: no cover - main() + args = parser.parse_args(argv) + app = create_app(args) + args.func(app, args) diff --git a/connexion/options.py b/connexion/options.py index 1abf8ec14..7ee13afdb 100644 --- a/connexion/options.py +++ b/connexion/options.py @@ -58,6 +58,12 @@ def __init__( else: self.spec_path = "/swagger.json" + if options is not None and not isinstance(options, SwaggerUIOptions): + raise ValueError( + f"`swaggger_ui_options` should be of type `SwaggerUIOptions`, " + f"but received {type(options)} instead." + ) + self._options = options or SwaggerUIOptions() @property diff --git a/pyproject.toml b/pyproject.toml index 9f56ca8e9..7c12fbc9e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,6 @@ connexion = 'connexion.cli:main' [tool.poetry.dependencies] python = '^3.8' asgiref = ">= 3.4" -clickclick = ">= 1.2" httpx = ">= 0.23" inflection = ">= 0.3.1" jsonschema = ">= 4.0.1" diff --git a/tests/test_cli.py b/tests/test_cli.py index 98375daa2..e697dded3 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,10 +1,12 @@ +import contextlib +import io import logging from unittest.mock import MagicMock import pytest -from click.testing import CliRunner from connexion.cli import main from connexion.exceptions import ResolverError +from connexion.options import SwaggerUIOptions from conftest import FIXTURES_FOLDER @@ -52,12 +54,10 @@ def expected_arguments(): Default values arguments used to call `connexion.App` by cli. """ return { - "swagger_ui_options": { - "serve_spec": True, - "swagger_ui": True, - "swagger_path": None, - "swagger_url": None, - }, + "swagger_ui_options": SwaggerUIOptions( + swagger_ui_path="/ui", + swagger_ui_template_dir=None, + ), "auth_all_paths": False, } @@ -68,82 +68,61 @@ def spec_file(): def test_print_version(): - runner = CliRunner() - result = runner.invoke(main, ["--version"], catch_exceptions=False) - assert f"Connexion {importlib_metadata.version('connexion')}" in result.output + + output = io.StringIO() + with pytest.raises(SystemExit) as e_info, contextlib.redirect_stdout(output): + main(["--version"]) + + assert e_info.value.code == 0 + assert f"Connexion {importlib_metadata.version('connexion')}" in output.getvalue() def test_run_missing_spec(): - runner = CliRunner() - result = runner.invoke(main, ["run"], catch_exceptions=False) - assert "Missing argument" in result.output + output = io.StringIO() + with pytest.raises(SystemExit) as e_info, contextlib.redirect_stderr(output): + main(["run"]) + + assert e_info.value.code != 0 + assert "the following arguments are required: spec_file" in output.getvalue() def test_run_simple_spec(mock_app_run, spec_file): - runner = CliRunner() - runner.invoke(main, ["run", spec_file], catch_exceptions=False) + main(["run", spec_file]) app_instance = mock_app_run() app_instance.run.assert_called() def test_run_spec_with_host(mock_app_run, spec_file): - runner = CliRunner() - runner.invoke( - main, ["run", spec_file, "--host", "custom.host"], catch_exceptions=False - ) + main(["run", spec_file, "--host", "custom.host"]) app_instance = mock_app_run() app_instance.run.assert_called() def test_run_no_options_all_default(mock_app_run, expected_arguments, spec_file): - runner = CliRunner() - runner.invoke(main, ["run", spec_file], catch_exceptions=False) - mock_app_run.assert_called_with("connexion.cli", **expected_arguments) - - -def test_run_using_option_hide_spec(mock_app_run, expected_arguments, spec_file): - runner = CliRunner() - runner.invoke(main, ["run", spec_file, "--hide-spec"], catch_exceptions=False) - - expected_arguments["swagger_ui_options"]["serve_spec"] = False - mock_app_run.assert_called_with("connexion.cli", **expected_arguments) - - -def test_run_using_option_hide_console_ui(mock_app_run, expected_arguments, spec_file): - runner = CliRunner() - runner.invoke(main, ["run", spec_file, "--hide-console-ui"], catch_exceptions=False) - - expected_arguments["swagger_ui_options"]["swagger_ui"] = False + main(["run", spec_file]) mock_app_run.assert_called_with("connexion.cli", **expected_arguments) def test_run_using_option_console_ui_from(mock_app_run, expected_arguments, spec_file): user_path = "/some/path/here" - runner = CliRunner() - runner.invoke( - main, ["run", spec_file, "--console-ui-from", user_path], catch_exceptions=False - ) + main(["run", spec_file, "--swagger-ui-template-dir", user_path]) - expected_arguments["swagger_ui_options"]["swagger_path"] = user_path + expected_arguments["swagger_ui_options"].swagger_ui_template_dir = user_path mock_app_run.assert_called_with("connexion.cli", **expected_arguments) def test_run_using_option_console_ui_url(mock_app_run, expected_arguments, spec_file): user_url = "/console_ui_test" - runner = CliRunner() - runner.invoke( - main, ["run", spec_file, "--console-ui-url", user_url], catch_exceptions=False - ) + main(["run", spec_file, "--swagger-ui-path", user_url]) - expected_arguments["swagger_ui_options"]["swagger_url"] = user_url + expected_arguments["swagger_ui_options"].swagger_ui_path = user_url mock_app_run.assert_called_with("connexion.cli", **expected_arguments) def test_run_using_option_auth_all_paths(mock_app_run, expected_arguments, spec_file): - runner = CliRunner() - runner.invoke(main, ["run", spec_file, "--auth-all-paths"], catch_exceptions=False) + main(["run", spec_file, "--auth-all-paths"]) expected_arguments["auth_all_paths"] = True mock_app_run.assert_called_with("connexion.cli", **expected_arguments) @@ -155,8 +134,7 @@ def test_run_in_very_verbose_mode( logging_config = MagicMock(name="connexion.cli.logging.basicConfig") monkeypatch.setattr("connexion.cli.logging.basicConfig", logging_config) - runner = CliRunner() - runner.invoke(main, ["run", spec_file, "-vv"], catch_exceptions=False) + main(["run", spec_file, "-vv"]) logging_config.assert_called_with(level=logging.DEBUG) @@ -167,8 +145,7 @@ def test_run_in_verbose_mode(mock_app_run, expected_arguments, spec_file, monkey logging_config = MagicMock(name="connexion.cli.logging.basicConfig") monkeypatch.setattr("connexion.cli.logging.basicConfig", logging_config) - runner = CliRunner() - runner.invoke(main, ["run", spec_file, "-v"], catch_exceptions=False) + main(["run", spec_file, "-v"]) logging_config.assert_called_with(level=logging.INFO) @@ -176,10 +153,7 @@ def test_run_in_verbose_mode(mock_app_run, expected_arguments, spec_file, monkey def test_run_using_option_base_path(mock_app_run, expected_arguments, spec_file): - runner = CliRunner() - runner.invoke( - main, ["run", spec_file, "--base-path", "/foo"], catch_exceptions=False - ) + main(["run", spec_file, "--base-path", "/foo"]) expected_arguments = dict( base_path="/foo", @@ -192,38 +166,25 @@ def test_run_using_option_base_path(mock_app_run, expected_arguments, spec_file) def test_run_unimplemented_operations(mock_app_run): - runner = CliRunner() - spec_file = str(FIXTURES_FOLDER / "missing_implementation/swagger.yaml") with pytest.raises(ResolverError): - runner.invoke(main, ["run", spec_file], catch_exceptions=False) + main(["run", spec_file]) spec_file = str(FIXTURES_FOLDER / "module_does_not_exist/swagger.yaml") with pytest.raises(ResolverError): - runner.invoke(main, ["run", spec_file], catch_exceptions=False) + main(["run", spec_file]) def test_run_unimplemented_operations_with_stub1(mock_app_run): - runner = CliRunner() - spec_file = str(FIXTURES_FOLDER / "missing_implementation/swagger.yaml") - result = runner.invoke(main, ["run", spec_file, "--stub"], catch_exceptions=False) - assert result.exit_code == 0 + main(["run", spec_file, "--stub"]) def test_run_unimplemented_operations_with_stub2(mock_app_run): - runner = CliRunner() - spec_file = str(FIXTURES_FOLDER / "module_does_not_exist/swagger.yaml") - result = runner.invoke(main, ["run", spec_file, "--stub"], catch_exceptions=False) - assert result.exit_code == 0 + main(["run", spec_file, "--stub"]) def test_run_unimplemented_operations_and_mock(mock_app_run): - runner = CliRunner() - spec_file = str(FIXTURES_FOLDER / "missing_implementation/swagger.yaml") - result = runner.invoke( - main, ["run", spec_file, "--mock=all"], catch_exceptions=False - ) - assert result.exit_code == 0 + main(["run", spec_file, "--mock=all"])