Skip to content
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

Merged
merged 25 commits into from
Sep 15, 2016
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
c74d8cd
Provide CLI support for runnning specifications
rafaelcaricio Sep 12, 2016
e9d5a61
Run with specification stubs
rafaelcaricio Sep 12, 2016
9c5f6bc
Refine stub option
rafaelcaricio Sep 12, 2016
82dcb61
Pass along all options available
rafaelcaricio Sep 12, 2016
e7f96e4
Sort imports
rafaelcaricio Sep 12, 2016
97adb25
Sort imports
rafaelcaricio Sep 12, 2016
8ef5794
Merge master into cli branch
rafaelcaricio Sep 13, 2016
d9b59c6
Fix imports
rafaelcaricio Sep 13, 2016
7ecb585
Fix imports
rafaelcaricio Sep 13, 2016
186f33b
Use clickclick>=1.2 with support to Python 2.7
rafaelcaricio Sep 14, 2016
dbe20e9
Remove cnx alias to CLI
rafaelcaricio Sep 14, 2016
07c6eb7
Fix parameters in the CLI interface
rafaelcaricio Sep 14, 2016
2208a95
Directories to be considered modules in Python2.7 needs to contain a …
rafaelcaricio Sep 14, 2016
b024a90
Add basic logging to CLI for troubleshooting
rafaelcaricio Sep 14, 2016
fec010b
Document connexion run command
rafaelcaricio Sep 14, 2016
6076f96
Fix typo
rafaelcaricio Sep 14, 2016
9b7cb99
Check that all options are passed correctly from the CLI module
rafaelcaricio Sep 15, 2016
a2c074d
Make connexion executable
rafaelcaricio Sep 15, 2016
b3fc3ff
Very simple code to run module, no tests :(
rafaelcaricio Sep 15, 2016
b57ebfa
No coverage for those two lines
rafaelcaricio Sep 15, 2016
46fa019
Use INFO log level by default
rafaelcaricio Sep 15, 2016
c1d55c1
Support different log levels in CLI
rafaelcaricio Sep 15, 2016
d71674b
Make sure very verbose is the same as debug
rafaelcaricio Sep 15, 2016
08b20e5
Fix flake8 checks
rafaelcaricio Sep 15, 2016
2d23ac9
Nicer verbosity impl.
rafaelcaricio Sep 15, 2016
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 4 additions & 16 deletions connexion/api.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,3 @@
"""
Copyright 2015 Zalando SE

Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the
License. You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific
language governing permissions and limitations under the License.
"""

import copy
import logging
import pathlib
Expand All @@ -23,10 +10,11 @@
import yaml
from swagger_spec_validator.validator20 import validate_spec

from . import resolver, utils
from . import utils
from .exceptions import ResolverError
from .handlers import AuthErrorHandler
from .operation import Operation
from .resolver import Resolver

MODULE_PATH = pathlib.Path(__file__).absolute().parent
SWAGGER_UI_PATH = MODULE_PATH / 'vendor' / 'swagger-ui'
Expand Down Expand Up @@ -65,7 +53,7 @@ class Api(object):

def __init__(self, swagger_yaml_path, base_url=None, arguments=None,
swagger_json=None, swagger_ui=None, swagger_path=None, swagger_url=None,
validate_responses=False, strict_validation=False, resolver=resolver.Resolver(),
validate_responses=False, strict_validation=False, resolver=None,
auth_all_paths=False, debug=False, resolver_error_handler=None):
"""
:type swagger_yaml_path: pathlib.Path
Expand Down Expand Up @@ -136,7 +124,7 @@ def __init__(self, swagger_yaml_path, base_url=None, arguments=None,
self.swagger_path = swagger_path or SWAGGER_UI_PATH
self.swagger_url = swagger_url or SWAGGER_UI_URL

self.resolver = resolver
self.resolver = resolver or Resolver()

logger.debug('Validate Responses: %s', str(validate_responses))
self.validate_responses = validate_responses
Expand Down
13 changes: 0 additions & 13 deletions connexion/app.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,3 @@
"""
Copyright 2015 Zalando SE

Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the
License. You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific
language governing permissions and limitations under the License.
"""

import logging
import pathlib

Expand Down
104 changes: 104 additions & 0 deletions connexion/cli.py
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
Copy link
Contributor

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).

Copy link
Collaborator Author

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.

if debug:
logging_level = logging.DEBUG
logging.basicConfig(level=logging_level)

sys.path.insert(1, path.abspath(base_path or '.'))
Copy link
Collaborator Author

@rafaelcaricio rafaelcaricio Sep 14, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think instead of using '.' (current directory, where connexion command is run) we should probably use path.dirname(path.abspath(spec_file)). Probably that will be the expected behavior by users.


resolver_error = None
if stub:
Copy link
Contributor

Choose a reason for hiding this comment

The 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.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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.

Copy link
Contributor

Choose a reason for hiding this comment

The 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)
13 changes: 0 additions & 13 deletions connexion/resolver.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,3 @@
"""
Copyright 2015 Zalando SE

Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the
License. You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific
language governing permissions and limitations under the License.
"""

import logging
import re

Expand Down
1 change: 0 additions & 1 deletion connexion/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@
import flask
import werkzeug.wrappers


PATH_PARAMETER = re.compile(r'\{([^}]*)\}')

# map Swagger type to flask path converter
Expand Down
1 change: 1 addition & 0 deletions examples/sqlalchemy/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import connexion
from connexion import NoContent

import orm

db_session = None
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ requests>=2.9.1
six>=1.7
strict-rfc3339>=0.6
swagger_spec_validator>=2.0.2
clickclick>=1.1
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you can now require clickclick>=1.2 for Python 2.7 support

3 changes: 2 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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']}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as already mentioned: I would remove the cnx alias --- I think it adds no value here (users can always create as many aliases as they wish via their CLI...)

)
16 changes: 16 additions & 0 deletions tests/fixtures/missing_implementation/swagger.yaml
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
16 changes: 16 additions & 0 deletions tests/fixtures/module_does_not_exist/swagger.yaml
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
101 changes: 101 additions & 0 deletions tests/test_cli.py
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
1 change: 0 additions & 1 deletion tests/test_operation.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@

import pytest


TEST_FOLDER = pathlib.Path(__file__).parent

DEFINITIONS = {'new_stack': {'required': ['image_version', 'keep_stacks', 'new_traffic', 'senza_yaml'],
Expand Down