-
-
Notifications
You must be signed in to change notification settings - Fork 762
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Run an API spec, no boilerplate needed #284
Changes from 9 commits
c74d8cd
e9d5a61
9c5f6bc
82dcb61
e7f96e4
97adb25
8ef5794
d9b59c6
7ecb585
186f33b
dbe20e9
07c6eb7
2208a95
b024a90
fec010b
6076f96
9b7cb99
a2c074d
b3fc3ff
b57ebfa
46fa019
c1d55c1
d71674b
08b20e5
2d23ac9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,104 @@ | ||
import logging | ||
import sys | ||
from os import path | ||
|
||
import click | ||
from clickclick import AliasedGroup, fatal_error | ||
from connexion import App | ||
|
||
main = AliasedGroup(context_settings=dict(help_option_names=[ | ||
'-h', '--help'])) | ||
|
||
|
||
def validate_wsgi_server_requirements(ctx, param, value): | ||
if value == 'gevent': | ||
try: | ||
import gevent # NOQA | ||
except: | ||
fatal_error('gevent library is not installed') | ||
elif value == 'tornado': | ||
try: | ||
import tornado # NOQA | ||
except: | ||
fatal_error('tornado library is not installed') | ||
|
||
|
||
@main.command() | ||
@click.argument('spec_file') | ||
@click.argument('base_path', required=False) | ||
@click.option('--port', '-p', default=5000, type=int, help='Port to listen.') | ||
@click.option('--wsgi-server', '-w', default='flask', | ||
type=click.Choice(['flask', 'gevent', 'tornado']), | ||
callback=validate_wsgi_server_requirements, | ||
help='Which WSGI server container to use.') | ||
@click.option('--stub', | ||
help='Returns status code 501, and `Not Implemented Yet` payload, for ' | ||
'the endpoints which handlers are not found.', | ||
is_flag=True, default=False) | ||
@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 the API console UI which is by default available at `/ui`.', | ||
is_flag=True, default=False) | ||
@click.option('--console-ui-url', metavar='URL', | ||
help='Personalize what URL path the API console UI will be mounted.') | ||
@click.option('--console-ui-from', metavar='PATH', | ||
help='Path to a customized API console UI dashboard.') | ||
@click.option('--auth-all-paths', | ||
help='Enable authentication to paths not defined in the spec.', | ||
is_flag=True, default=False) | ||
@click.option('--validate-responses', | ||
help='Enable validation of response values from operation handlers.', | ||
is_flag=True, default=False) | ||
@click.option('--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) | ||
def run(spec_file, | ||
base_path, | ||
port, | ||
wsgi_server, | ||
stub, | ||
hide_spec, | ||
hide_console_ui, | ||
console_ui_url, | ||
console_ui_from, | ||
auth_all_paths, | ||
validate_responses, | ||
strict_validation, | ||
debug): | ||
""" | ||
Runs a server compliant with a OpenAPI/Swagger 2.0 Specification file. | ||
|
||
Arguments: | ||
|
||
- SPEC_FILE: specification file that describes the server endpoints. | ||
|
||
- BASE_PATH (optional): filesystem path where the API endpoints handlers are going to be imported from. | ||
""" | ||
logging_level = logging.ERROR | ||
if debug: | ||
logging_level = logging.DEBUG | ||
logging.basicConfig(level=logging_level) | ||
|
||
sys.path.insert(1, path.abspath(base_path or '.')) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think instead of using |
||
|
||
resolver_error = None | ||
if stub: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As a future improvement we could implement a resolver that mocks a response based on the specification. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That is definitely interesting. There are some open source tools that do that, but bringing together the possibility to have partially mocked API's, that can morph in a fully implemented one, is a new approach. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What I'm looking to do is use my resolving swagger parser prance to generate test cases for an API. That's coming from realizing that swagger-tester isn't quite what I'd like it to be. Some of that code might well be useful for generating the mocked response. One way or another it's going through the spec and generating stuff. Just a thought. But I can't guarantee I'll be able to spend time on that. |
||
resolver_error = 501 | ||
|
||
app = App(__name__) | ||
app.add_api(path.abspath(spec_file), resolver_error=resolver_error) | ||
app.run( | ||
port=port, | ||
server=wsgi_server, | ||
swagger_json=hide_spec or None, | ||
swagger_ui=hide_console_ui or None, | ||
swagger_path=console_ui_from or None, | ||
swagger_url=console_ui_url or None, | ||
strict_validation=strict_validation, | ||
validate_responses=validate_responses, | ||
auth_all_paths=auth_all_paths, | ||
debug=debug) |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -4,6 +4,7 @@ | |
|
||
import connexion | ||
from connexion import NoContent | ||
|
||
import orm | ||
|
||
db_session = None | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -5,3 +5,4 @@ requests>=2.9.1 | |
six>=1.7 | ||
strict-rfc3339>=0.6 | ||
swagger_spec_validator>=2.0.2 | ||
clickclick>=1.1 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. you can now require |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -88,5 +88,6 @@ def readme(): | |
'Topic :: Software Development :: Libraries :: Application Frameworks' | ||
], | ||
include_package_data=True, # needed to include swagger-ui (see MANIFEST.in) | ||
|
||
entry_points={'console_scripts': ['connexion = connexion.cli:main', | ||
'cnx = connexion.cli:main']} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. as already mentioned: I would remove the |
||
) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
swagger: "2.0" | ||
|
||
info: | ||
title: "Testing API" | ||
version: "1.0" | ||
|
||
basePath: "/testing" | ||
|
||
paths: | ||
/operation-not-implemented: | ||
get: | ||
summary: Operation function does not exist. | ||
operationId: api.this_function_does_not_exist | ||
responses: | ||
200: | ||
description: OK |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
swagger: "2.0" | ||
|
||
info: | ||
title: "Not Exist API" | ||
version: "1.0" | ||
|
||
basePath: '/na' | ||
|
||
paths: | ||
/module-not-implemented: | ||
get: | ||
summary: Operation function does not exist. | ||
operationId: m.module_does_not_exist | ||
responses: | ||
200: | ||
description: OK |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,101 @@ | ||
import logging | ||
|
||
from click.testing import CliRunner | ||
from connexion import App | ||
from connexion.cli import main | ||
from connexion.exceptions import ResolverError | ||
|
||
import pytest | ||
from conftest import FIXTURES_FOLDER | ||
from mock import MagicMock | ||
|
||
|
||
@pytest.fixture() | ||
def mock_app_run(monkeypatch): | ||
test_server = MagicMock(wraps=App(__name__)) | ||
test_server.run = MagicMock(return_value=True) | ||
test_app = MagicMock(return_value=test_server) | ||
monkeypatch.setattr('connexion.cli.App', test_app) | ||
return test_server | ||
|
||
|
||
@pytest.fixture() | ||
def spec_file(): | ||
return str(FIXTURES_FOLDER / 'simple/swagger.yaml') | ||
|
||
|
||
def test_run_missing_spec(): | ||
runner = CliRunner() | ||
result = runner.invoke(main, ['run'], catch_exceptions=False) | ||
assert "Missing argument" in result.output | ||
|
||
|
||
def test_run_simple_spec(mock_app_run, spec_file): | ||
default_port = 5000 | ||
runner = CliRunner() | ||
runner.invoke(main, ['run', spec_file], catch_exceptions=False) | ||
|
||
mock_app_run.run.assert_called_with( | ||
port=default_port, | ||
server=None, | ||
strict_validation=False, | ||
swagger_json=None, | ||
swagger_path=None, | ||
swagger_ui=None, | ||
swagger_url=None, | ||
auth_all_paths=False, | ||
validate_responses=False, | ||
debug=False) | ||
|
||
|
||
def test_run_in_debug_mode(mock_app_run, spec_file, monkeypatch): | ||
logging_config = MagicMock(name='connexion.cli.logging.basicConfig') | ||
monkeypatch.setattr('connexion.cli.logging.basicConfig', | ||
logging_config) | ||
|
||
runner = CliRunner() | ||
runner.invoke(main, ['run', spec_file, '-d'], catch_exceptions=False) | ||
|
||
logging_config.assert_called_with(level=logging.DEBUG) | ||
|
||
|
||
def test_run_unimplemented_operations_and_stub(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) | ||
# yet can be run with --stub option | ||
result = runner.invoke(main, ['run', spec_file, '--stub'], catch_exceptions=False) | ||
assert result.exit_code == 0 | ||
|
||
spec_file = str(FIXTURES_FOLDER / 'module_does_not_exist/swagger.yaml') | ||
with pytest.raises(ImportError): | ||
runner.invoke(main, ['run', spec_file], catch_exceptions=False) | ||
# yet can be run with --stub option | ||
result = runner.invoke(main, ['run', spec_file, '--stub'], catch_exceptions=False) | ||
assert result.exit_code == 0 | ||
|
||
|
||
def test_run_with_wsgi_containers(mock_app_run, spec_file): | ||
runner = CliRunner() | ||
|
||
# missing gevent | ||
result = runner.invoke(main, | ||
['run', spec_file, '-w', 'gevent'], | ||
catch_exceptions=False) | ||
assert 'gevent library is not installed' in result.output | ||
assert result.exit_code == 1 | ||
|
||
# missing tornado | ||
result = runner.invoke(main, | ||
['run', spec_file, '-w', 'tornado'], | ||
catch_exceptions=False) | ||
assert 'tornado library is not installed' in result.output | ||
assert result.exit_code == 1 | ||
|
||
# using flask | ||
result = runner.invoke(main, | ||
['run', spec_file, '-w', 'flask'], | ||
catch_exceptions=False) | ||
assert result.exit_code == 0 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would use
INFO
by default.Or alternatively: ues
WARN
by default and make it more verbose with-v
(INFO) and-vv
(DEBUG).There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I like the idea of changing levels by adding -v or -vv. Will do that.