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

Refactor Operation into AbstractOperation and Swagger2Operation #639

Merged
merged 5 commits into from
Aug 23, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
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