Skip to content

Commit

Permalink
Refactor Operation into AbstractOperation and Swagger2Operation (#639)
Browse files Browse the repository at this point in the history
* refactor Operation into AbstractOperation ABC and SwaggerOperation

* remove defaults validation from swagger2.py (now handled in validator)

* update README 'new in 2.0' section
  • Loading branch information
dtkav authored and jmcs committed Aug 23, 2018
1 parent 07bcd0e commit 99ac95f
Show file tree
Hide file tree
Showing 23 changed files with 982 additions and 607 deletions.
3 changes: 3 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,12 @@ Other Sources/Mentions
New in Connexion 2.0:
---------------------
- App and Api options must be provided through the "options" argument (``old_style_options`` have been removed).
- The `Operation` interface has been formalized in the `AbstractOperation` class.
- The `Operation` class has been renamed to `Swagger2Operation`.
- Array parameter deserialization now follows the Swagger 2.0 spec more closely.
In situations when a query parameter is passed multiple times, and the collectionFormat is either csv or pipes, the right-most value will be used.
For example, `?q=1,2,3&q=4,5,6` will result in `q = [4, 5, 6]`.
The old behavior is available by setting the collectionFormat to `multi`, or by importing `decorators.uri_parsing.AlwaysMultiURIParser` and passing `parser_class=AlwaysMultiURIParser` to your Api.
- The spec validator library has changed from `swagger-spec-validator` to `openapi-spec-validator`.
- Errors that previously raised `SwaggerValidationError` now raise the `InvalidSpecification` exception.
All spec validation errors should be wrapped with `InvalidSpecification`.
Expand Down
18 changes: 3 additions & 15 deletions connexion/apis/abstract.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,14 @@

from ..exceptions import InvalidSpecification, ResolverError
from ..jsonref import resolve_refs
from ..operation import Swagger2Operation
from ..operations import Swagger2Operation
from ..options import ConnexionOptions
from ..resolver import Resolver
from ..utils import Jsonifier

MODULE_PATH = pathlib.Path(__file__).absolute().parent.parent
SWAGGER_UI_URL = 'ui'

RESOLVER_ERROR_ENDPOINT_RANDOM_DIGITS = 6

logger = logging.getLogger('connexion.apis.abstract')


Expand Down Expand Up @@ -225,18 +223,8 @@ def _add_resolver_error_handler(self, method, path, err):
Adds a handler for ResolverError for the given method and path.
"""
operation = self.resolver_error_handler(err,
method=method,
path=path,
app_produces=self.produces,
app_security=self.security,
security_definitions=self.security_definitions,
definitions=self.definitions,
parameter_definitions=self.parameter_definitions,
response_definitions=self.response_definitions,
validate_responses=self.validate_responses,
strict_validation=self.strict_validation,
resolver=self.resolver,
randomize_endpoint=RESOLVER_ERROR_ENDPOINT_RANDOM_DIGITS)
security=self.security,
security_definitions=self.security_definitions)
self._add_operation_internal(method, path, operation)

def add_paths(self, paths=None):
Expand Down
4 changes: 0 additions & 4 deletions connexion/apps/abstract.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,10 +154,6 @@ def add_api(self, specification, base_path=None, arguments=None,

def _resolver_error_handler(self, *args, **kwargs):
from connexion.handlers import ResolverErrorHandler
kwargs['operation'] = {
'operationId': 'connexion.handlers.ResolverErrorHandler',
}
kwargs.setdefault('app_consumes', ['application/json'])
return ResolverErrorHandler(self.api_cls, self.resolver_error, *args, **kwargs)

def add_url_rule(self, rule, endpoint=None, view_func=None, **options):
Expand Down
131 changes: 27 additions & 104 deletions connexion/decorators/parameter.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import copy
import functools
import inspect
import logging
Expand All @@ -7,8 +6,9 @@
import inflection
import six

from ..http_facts import FORM_CONTENT_TYPES
from ..lifecycle import ConnexionRequest # NOQA
from ..utils import all_json, boolean, is_null, is_nullable
from ..utils import all_json

try:
import builtins
Expand All @@ -24,14 +24,6 @@
except NameError: # pragma: no cover
py_string = str # pragma: no cover

# https://github.com/swagger-api/swagger-spec/blob/master/versions/2.0.md#data-types
TYPE_MAP = {'integer': int,
'number': float,
'string': py_string,
'boolean': boolean,
'array': list,
'object': dict} # map of swagger types to python types


def inspect_function_arguments(function): # pragma: no cover
"""
Expand All @@ -52,21 +44,6 @@ def inspect_function_arguments(function): # pragma: no cover
return argspec.args, bool(argspec.keywords)


def make_type(value, type):
type_func = TYPE_MAP[type] # convert value to right type
return type_func(value)


def get_val_from_param(value, query_param):
if is_nullable(query_param) and is_null(value):
return None

if query_param["type"] == "array":
return [make_type(v, query_param["items"]["type"]) for v in value]
else:
return make_type(value, query_param["type"])


def snake_and_shadow(name):
"""
Converts the given name into Pythonic form. Firstly it converts CamelCase names to snake_case. Secondly it looks to
Expand All @@ -81,43 +58,32 @@ def snake_and_shadow(name):
return snake


def parameter_to_arg(parameters, consumes, function, pythonic_params=False, pass_context_arg_name=None):
def parameter_to_arg(operation, function, pythonic_params=False,
pass_context_arg_name=None):
"""
Pass query and body parameters as keyword arguments to handler function.
See (https://github.com/zalando/connexion/issues/59)
:param parameters: All the parameters of the handler functions
:type parameters: dict|None
:param consumes: The list of content types the operation consumes
:type consumes: list
:param function: The handler function for the REST endpoint.
:type function: function|None
:param operation: The operation being called
:type operation: connexion.operations.AbstractOperation
:param pythonic_params: When True CamelCase parameters are converted to snake_case and an underscore is appended to
any shadowed built-ins
:type pythonic_params: bool
:param pass_context_arg_name: If not None URL and function has an argument matching this name, the framework's
request context will be passed as that argument.
:type pass_context_arg_name: str|None
"""
def sanitize_param(name):
if name and pythonic_params:
name = snake_and_shadow(name)
consumes = operation.consumes

def sanitized(name):
return name and re.sub('^[^a-zA-Z_]+', '', re.sub('[^0-9a-zA-Z_]', '', name))

body_parameters = [parameter for parameter in parameters if parameter['in'] == 'body'] or [{}]
body_name = sanitize_param(body_parameters[0].get('name'))
default_body = body_parameters[0].get('schema', {}).get('default')
query_types = {sanitize_param(parameter['name']): parameter
for parameter in parameters if parameter['in'] == 'query'} # type: dict[str, str]
form_types = {sanitize_param(parameter['name']): parameter
for parameter in parameters if parameter['in'] == 'formData'}
path_types = {parameter['name']: parameter
for parameter in parameters if parameter['in'] == 'path'}
def pythonic(name):
name = name and snake_and_shadow(name)
return sanitized(name)

sanitize = pythonic if pythonic_params else sanitized
arguments, has_kwargs = inspect_function_arguments(function)
default_query_params = {sanitize_param(param['name']): param['default']
for param in parameters if param['in'] == 'query' and 'default' in param}
default_form_params = {sanitize_param(param['name']): param['default']
for param in parameters if param['in'] == 'formData' and 'default' in param}

@functools.wraps(function)
def wrapper(request):
Expand All @@ -127,67 +93,24 @@ def wrapper(request):

if all_json(consumes):
request_body = request.json
elif consumes[0] in FORM_CONTENT_TYPES:
request_body = {sanitize(k): v for k, v in request.form.items()}
else:
request_body = request.body

if default_body and not request_body:
request_body = default_body
# accept formData even even if mimetype is wrong for backwards
# compatability :/
request_body = request_body or {sanitize(k): v for k, v in request.form.items()}

# Parse path parameters
path_params = request.path_params
for key, value in path_params.items():
if key in path_types:
kwargs[key] = get_val_from_param(value, path_types[key])
else: # Assume path params mechanism used for injection
kwargs[key] = value
try:
query = request.query.to_dict(flat=False)
except AttributeError:
query = dict(request.query.items())

# Add body parameters
if not has_kwargs and body_name not in arguments:
logger.debug("Body parameter '%s' not in function arguments", body_name)
elif body_name:
logger.debug("Body parameter '%s' in function arguments", body_name)
kwargs[body_name] = request_body

# Add query parameters
query_arguments = copy.deepcopy(default_query_params)
query_arguments.update(request.query)
for key, value in query_arguments.items():
key = sanitize_param(key)
if not has_kwargs and key not in arguments:
logger.debug("Query Parameter '%s' not in function arguments", key)
else:
logger.debug("Query Parameter '%s' in function arguments", key)
try:
query_param = query_types[key]
except KeyError: # pragma: no cover
logger.error("Function argument '{}' not defined in specification".format(key))
else:
logger.debug('%s is a %s', key, query_param)
kwargs[key] = get_val_from_param(value, query_param)

# Add formData parameters
form_arguments = copy.deepcopy(default_form_params)
form_arguments.update({sanitize_param(k): v for k, v in request.form.items()})
for key, value in form_arguments.items():
if not has_kwargs and key not in arguments:
logger.debug("FormData parameter '%s' not in function arguments", key)
else:
logger.debug("FormData parameter '%s' in function arguments", key)
try:
form_param = form_types[key]
except KeyError: # pragma: no cover
logger.error("Function argument '{}' not defined in specification".format(key))
else:
kwargs[key] = get_val_from_param(value, form_param)

# Add file parameters
file_arguments = request.files
for key, value in file_arguments.items():
if not has_kwargs and key not in arguments:
logger.debug("File parameter (formData) '%s' not in function arguments", key)
else:
logger.debug("File parameter (formData) '%s' in function arguments", key)
kwargs[key] = value
kwargs.update(
operation.get_arguments(request.path_params, query, request_body,
request.files, arguments, has_kwargs, sanitize)
)

# optionally convert parameter variable names to un-shadowed, snake_case form
if pythonic_params:
Expand Down
25 changes: 13 additions & 12 deletions connexion/decorators/response.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@


class ResponseValidator(BaseDecorator):
def __init__(self, operation, mimetype):
def __init__(self, operation, mimetype):
"""
:type operation: Operation
:type mimetype: str
Expand All @@ -32,13 +32,15 @@ def validate_response(self, data, status_code, headers, url):
:type headers: dict
:rtype bool | None
"""
response_definitions = self.operation.operation["responses"]
response_definition = response_definitions.get(str(status_code), response_definitions.get("default", {}))
response_definition = self.operation.with_definitions(response_definition)
# check against returned header, fall back to expected mimetype
content_type = headers.get("Content-Type", self.mimetype)
content_type = content_type.rsplit(";", 1)[0] # remove things like utf8 metadata

if self.is_json_schema_compatible(response_definition):
schema = response_definition.get("schema")
v = ResponseBodyValidator(schema)
response_definition = self.operation.response_definition(str(status_code), content_type)
response_schema = self.operation.response_schema(str(status_code), content_type)

if self.is_json_schema_compatible(response_schema):
v = ResponseBodyValidator(response_schema)
try:
data = self.operation.json_loads(data)
v.validate_schema(data, url)
Expand All @@ -57,7 +59,7 @@ def validate_response(self, data, status_code, headers, url):
raise NonConformingResponseHeaders(message=msg)
return True

def is_json_schema_compatible(self, response_definition):
def is_json_schema_compatible(self, response_schema):
"""
Verify if the specified operation responses are JSON schema
compatible.
Expand All @@ -66,13 +68,12 @@ def is_json_schema_compatible(self, response_definition):
type "application/json" or "text/plain" can be validated using
json_schema package.
:type response_definition: dict
:type response_schema: dict
:rtype bool
"""
if not response_definition:
if not response_schema:
return False
return ('schema' in response_definition and
(all_json([self.mimetype]) or self.mimetype == 'text/plain'))
return all_json([self.mimetype]) or self.mimetype == 'text/plain'

def __call__(self, function):
"""
Expand Down
10 changes: 9 additions & 1 deletion connexion/decorators/uri_parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@

logger = logging.getLogger('connexion.decorators.uri_parsing')

QUERY_STRING_DELIMITERS = {
'spaceDelimited': ' ',
'pipeDelimited': '|',
'simple': ',',
'form': ','
}


@six.add_metaclass(abc.ABCMeta)
class AbstractURIParser(BaseDecorator):
Expand Down Expand Up @@ -43,7 +50,8 @@ def __repr__(self):
"""
:rtype: str
"""
return "<{classname}>".format(classname=self.__class__.__name__)
return "<{classname}>".format(
classname=self.__class__.__name__) # pragma: no cover

@abc.abstractmethod
def _resolve_param_duplicates(self, values, param_defn):
Expand Down
14 changes: 10 additions & 4 deletions connexion/decorators/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,14 @@ def __str__(self):


def validate_type(param, value, parameter_type, parameter_name=None):
param_type = param.get('type')
param_schema = param.get("schema", param)
param_type = param_schema.get('type')
parameter_name = parameter_name if parameter_name else param['name']
if param_type == "array":
converted_params = []
for v in value:
try:
converted = make_type(v, param["items"]["type"])
converted = make_type(v, param_schema["items"]["type"])
except (ValueError, TypeError):
converted = v
converted_params.append(converted)
Expand All @@ -74,21 +75,25 @@ def validate_parameter_list(request_params, spec_params):


class RequestBodyValidator(object):
def __init__(self, schema, consumes, api, is_null_value_valid=False, validator=None):
def __init__(self, schema, consumes, api, is_null_value_valid=False, validator=None,
strict_validation=False):
"""
:param schema: The schema of the request body
:param consumes: The list of content types the operation consumes
:param is_null_value_valid: Flag to indicate if null is accepted as valid value.
:param validator: Validator class that should be used to validate passed data
against API schema. Default is jsonschema.Draft4Validator.
:type validator: jsonschema.IValidator
:param strict_validation: Flag indicating if parameters not in spec are allowed
"""
self.consumes = consumes
self.schema = schema
self.has_default = schema.get('default', False)
self.is_null_value_valid = is_null_value_valid
validatorClass = validator or Draft4Validator
self.validator = validatorClass(schema, format_checker=draft4_format_checker)
self.api = api
self.strict_validation = strict_validation

def __call__(self, function):
"""
Expand Down Expand Up @@ -173,6 +178,7 @@ class ParameterValidator(object):
def __init__(self, parameters, api, strict_validation=False):
"""
:param parameters: List of request parameter dictionaries
:param api: api that the validator is attached to
:param strict_validation: Flag indicating if parameters not in spec are allowed
"""
self.parameters = collections.defaultdict(list)
Expand Down Expand Up @@ -249,7 +255,7 @@ def validate_header_parameter(self, param, request):
return self.validate_parameter('header', val, param)

def validate_formdata_parameter(self, param, request):
if param.get('type') == 'file':
if param.get('type') == 'file' or param.get('format') == 'binary':
val = request.files.get(param['name'])
else:
val = request.form.get(param['name'])
Expand Down
Loading

0 comments on commit 99ac95f

Please sign in to comment.