diff --git a/README.rst b/README.rst index 4871a346a..03c6ae00c 100644 --- a/README.rst +++ b/README.rst @@ -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`. diff --git a/connexion/apis/abstract.py b/connexion/apis/abstract.py index 25e9b1a69..103d47281 100644 --- a/connexion/apis/abstract.py +++ b/connexion/apis/abstract.py @@ -13,7 +13,7 @@ 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 @@ -21,8 +21,6 @@ MODULE_PATH = pathlib.Path(__file__).absolute().parent.parent SWAGGER_UI_URL = 'ui' -RESOLVER_ERROR_ENDPOINT_RANDOM_DIGITS = 6 - logger = logging.getLogger('connexion.apis.abstract') @@ -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): diff --git a/connexion/apps/abstract.py b/connexion/apps/abstract.py index 2e80f6a92..3767f1d54 100644 --- a/connexion/apps/abstract.py +++ b/connexion/apps/abstract.py @@ -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): diff --git a/connexion/decorators/parameter.py b/connexion/decorators/parameter.py index 4d9bd87ba..9cfcd9b8e 100644 --- a/connexion/decorators/parameter.py +++ b/connexion/decorators/parameter.py @@ -1,4 +1,3 @@ -import copy import functools import inspect import logging @@ -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 @@ -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 """ @@ -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 @@ -81,17 +58,14 @@ 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 @@ -99,25 +73,17 @@ def parameter_to_arg(parameters, consumes, function, pythonic_params=False, pass 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): @@ -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: diff --git a/connexion/decorators/response.py b/connexion/decorators/response.py index c277539a9..3557d24a0 100644 --- a/connexion/decorators/response.py +++ b/connexion/decorators/response.py @@ -15,7 +15,7 @@ class ResponseValidator(BaseDecorator): - def __init__(self, operation, mimetype): + def __init__(self, operation, mimetype): """ :type operation: Operation :type mimetype: str @@ -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) @@ -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. @@ -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): """ diff --git a/connexion/decorators/uri_parsing.py b/connexion/decorators/uri_parsing.py index c81eb3134..c5998fc70 100644 --- a/connexion/decorators/uri_parsing.py +++ b/connexion/decorators/uri_parsing.py @@ -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): @@ -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): diff --git a/connexion/decorators/validation.py b/connexion/decorators/validation.py index 1a2e2d27b..c0e9c9389 100644 --- a/connexion/decorators/validation.py +++ b/connexion/decorators/validation.py @@ -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) @@ -74,7 +75,8 @@ 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 @@ -82,13 +84,16 @@ def __init__(self, schema, consumes, api, is_null_value_valid=False, validator=N :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): """ @@ -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) @@ -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']) diff --git a/connexion/handlers.py b/connexion/handlers.py index 37f56724c..a695e8ef1 100644 --- a/connexion/handlers.py +++ b/connexion/handlers.py @@ -1,10 +1,12 @@ import logging -from .operation import SecureOperation, Swagger2Operation +from .operations.secure import SecureOperation from .problem import problem logger = logging.getLogger('connexion.handlers') +RESOLVER_ERROR_ENDPOINT_RANDOM_DIGITS = 6 + class AuthErrorHandler(SecureOperation): """ @@ -25,7 +27,7 @@ def __init__(self, api, exception, security, security_definitions): :type security_definitions: dict """ self.exception = exception - SecureOperation.__init__(self, api, security, security_definitions) + super(AuthErrorHandler, self).__init__(api, security, security_definitions) @property def function(self): @@ -52,15 +54,15 @@ def handle(self, *args, **kwargs): return self.api.get_response(response) -class ResolverErrorHandler(Swagger2Operation): +class ResolverErrorHandler(SecureOperation): """ Handler for responding to ResolverError. """ - def __init__(self, api, status_code, exception, *args, **kwargs): + def __init__(self, api, status_code, exception, security, security_definitions): self.status_code = status_code self.exception = exception - Swagger2Operation.__init__(self, api, *args, **kwargs) + super(ResolverErrorHandler, self).__init__(api, security, security_definitions) @property def function(self): @@ -73,3 +75,14 @@ def handle(self, *args, **kwargs): status=self.status_code ) return self.api.get_response(response) + + @property + def operation_id(self): + return "noop" + + @property + def randomize_endpoint(self): + return RESOLVER_ERROR_ENDPOINT_RANDOM_DIGITS + + def get_path_parameter_types(self): + return [] diff --git a/connexion/http_facts.py b/connexion/http_facts.py new file mode 100644 index 000000000..be7756c04 --- /dev/null +++ b/connexion/http_facts.py @@ -0,0 +1,4 @@ +FORM_CONTENT_TYPES = [ + 'application/x-www-form-urlencoded', + 'multipart/form-data' +] diff --git a/connexion/mock.py b/connexion/mock.py index 41ebc8935..7c5f23436 100644 --- a/connexion/mock.py +++ b/connexion/mock.py @@ -27,7 +27,7 @@ def resolve(self, operation): """ Mock operation resolver - :type operation: connexion.operation.Operation + :type operation: connexion.operations.AbstractOperation """ operation_id = self.resolve_operation_id(operation) if not operation_id: @@ -51,23 +51,7 @@ def resolve(self, operation): return Resolution(func, operation_id) def mock_operation(self, operation, *args, **kwargs): - response_definitions = operation.operation["responses"] - # simply use the first/lowest status code, this is probably 200 or 201 - status_code = sorted(response_definitions.keys())[0] - response_definition = response_definitions.get(status_code, {}) - try: - status_code = int(status_code) - except ValueError: - status_code = 200 - response_definition = operation.with_definitions(response_definition) - examples = response_definition.get('examples') - if examples: - return list(examples.values())[0], status_code - else: - # No response example, check for schema example - response_schema = response_definition.get('schema', {}) - schema_example = response_schema.get('example') - if schema_example: - return schema_example, status_code - else: - return 'No example response was defined.', status_code + resp, code = operation.example_response() + if resp is not None: + return resp, code + return 'No example response was defined.', code diff --git a/connexion/operation.py b/connexion/operation.py deleted file mode 100644 index 8ae5e1bbc..000000000 --- a/connexion/operation.py +++ /dev/null @@ -1,396 +0,0 @@ -import functools -import logging - -from .decorators.decorator import (BeginOfRequestLifecycleDecorator, - EndOfRequestLifecycleDecorator) -from .decorators.metrics import UWSGIMetricsCollector -from .decorators.parameter import parameter_to_arg -from .decorators.produces import BaseSerializer, Produces -from .decorators.response import ResponseValidator -from .decorators.security import (get_tokeninfo_func, get_tokeninfo_url, - security_passthrough, verify_oauth_local, - verify_oauth_remote) -from .decorators.uri_parsing import Swagger2URIParser -from .decorators.validation import ParameterValidator, RequestBodyValidator -from .exceptions import InvalidSpecification -from .utils import all_json, is_nullable - -logger = logging.getLogger('connexion.operation') - -DEFAULT_MIMETYPE = 'application/json' - - -VALIDATOR_MAP = { - 'parameter': ParameterValidator, - 'body': RequestBodyValidator, - 'response': ResponseValidator, -} - - -class SecureOperation(object): - - def __init__(self, api, security, security_definitions): - """ - :param security: list of security rules the application uses by default - :type security: list - :param security_definitions: `Security Definitions Object - `_ - :type security_definitions: dict - """ - self.api = api - self.security = security - self.security_definitions = security_definitions - - @property - def security_decorator(self): - """ - Gets the security decorator for operation - - From Swagger Specification: - - **Security Definitions Object** - - A declaration of the security schemes available to be used in the specification. - - This does not enforce the security schemes on the operations and only serves to provide the relevant details - for each scheme. - - - **Security Requirement Object** - - Lists the required security schemes to execute this operation. The object can have multiple security schemes - declared in it which are all required (that is, there is a logical AND between the schemes). - - The name used for each property **MUST** correspond to a security scheme declared in the Security Definitions. - - :rtype: types.FunctionType - """ - logger.debug('... Security: %s', self.security, extra=vars(self)) - if self.security: - if len(self.security) > 1: - logger.debug("... More than one security requirement defined. **IGNORING SECURITY REQUIREMENTS**", - extra=vars(self)) - return security_passthrough - - security = self.security[0] # type: dict - # the following line gets the first (and because of the previous condition only) scheme and scopes - # from the operation's security requirements - - scheme_name, scopes = next(iter(security.items())) # type: str, list - security_definition = self.security_definitions[scheme_name] - if security_definition['type'] == 'oauth2': - token_info_url = get_tokeninfo_url(security_definition) - token_info_func = get_tokeninfo_func(security_definition) - scopes = set(scopes) # convert scopes to set because this is needed for verify_oauth_remote - - if token_info_url and token_info_func: - logger.warning("... Both x-tokenInfoUrl and x-tokenInfoFunc are defined, using x-tokenInfoFunc", - extra=vars(self)) - if token_info_func: - return functools.partial(verify_oauth_local, token_info_func, scopes) - if token_info_url: - return functools.partial(verify_oauth_remote, token_info_url, scopes) - else: - logger.warning("... OAuth2 token info URL missing. **IGNORING SECURITY REQUIREMENTS**", - extra=vars(self)) - elif security_definition['type'] in ('apiKey', 'basic'): - logger.debug( - "... Security type '%s' not natively supported by Connexion; you should handle it yourself", - security_definition['type'], extra=vars(self)) - - # if we don't know how to handle the security or it's not defined we will usa a passthrough decorator - return security_passthrough - - def get_mimetype(self): - return DEFAULT_MIMETYPE - - @property - def _request_begin_lifecycle_decorator(self): - """ - Transforms the result of the operation handler in a internal - representation (connexion.lifecycle.ConnexionRequest) to be - used by internal Connexion decorators. - - :rtype: types.FunctionType - """ - return BeginOfRequestLifecycleDecorator(self.api, self.get_mimetype()) - - @property - def _request_end_lifecycle_decorator(self): - """ - Guarantees that instead of the internal representation of the - operation handler response - (connexion.lifecycle.ConnexionRequest) a framework specific - object is returned. - :rtype: types.FunctionType - """ - return EndOfRequestLifecycleDecorator(self.api, self.get_mimetype()) - - -class Swagger2Operation(SecureOperation): - - """ - A single API operation on a path. - """ - - def __init__(self, api, method, path, operation, resolver, app_produces, app_consumes, - path_parameters=None, app_security=None, security_definitions=None, - definitions=None, parameter_definitions=None, response_definitions=None, - validate_responses=False, strict_validation=False, randomize_endpoint=None, - validator_map=None, pythonic_params=False, uri_parser_class=None, - pass_context_arg_name=None): - """ - This class uses the OperationID identify the module and function that will handle the operation - - From Swagger Specification: - - **OperationID** - - A friendly name for the operation. The id MUST be unique among all operations described in the API. - Tools and libraries MAY use the operation id to uniquely identify an operation. - - :param method: HTTP method - :type method: str - :param path: - :type path: str - :param operation: swagger operation object - :type operation: dict - :param resolver: Callable that maps operationID to a function - :param app_produces: list of content types the application can return by default - :type app_produces: list - :param app_consumes: list of content types the application consumes by default - :type app_consumes: list - :param validator_map: map of validators - :type validator_map: dict - :param path_parameters: Parameters defined in the path level - :type path_parameters: list - :param app_security: list of security rules the application uses by default - :type app_security: list - :param security_definitions: `Security Definitions Object - `_ - :type security_definitions: dict - :param definitions: `Definitions Object - `_ - :type definitions: dict - :param parameter_definitions: Global parameter definitions - :type parameter_definitions: dict - :param response_definitions: Global response definitions - :type response_definitions: dict - :param validator_map: Custom validators for the types "parameter", "body" and "response". - :type validator_map: dict - :param validate_responses: True enables validation. Validation errors generate HTTP 500 responses. - :type validate_responses: bool - :param strict_validation: True enables validation on invalid request parameters - :type strict_validation: bool - :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 uri_parser_class: A URI parser class that inherits from AbstractURIParser - :type uri_parser_class: AbstractURIParser - :param pass_context_arg_name: If not None will try to inject the request context to the function using this - name. - :type pass_context_arg_name: str|None - """ - - self.api = api - self.method = method - self.path = path - self.validator_map = dict(VALIDATOR_MAP) - self.validator_map.update(validator_map or {}) - self.security_definitions = security_definitions or {} - self.definitions = definitions or {} - self.parameter_definitions = parameter_definitions or {} - self.response_definitions = response_definitions or {} - self.operation = operation - self.validate_responses = validate_responses - self.strict_validation = strict_validation - self.randomize_endpoint = randomize_endpoint - self.pythonic_params = pythonic_params - self.uri_parser_class = uri_parser_class or Swagger2URIParser - self.pass_context_arg_name = pass_context_arg_name - - # todo support definition references - # todo support references to application level parameters - self.parameters = list(self.operation.get('parameters', [])) - if path_parameters: - self.parameters += list(path_parameters) - - self.security = operation.get('security', app_security) - self.produces = operation.get('produces', app_produces) - self.consumes = operation.get('consumes', app_consumes) - - resolution = resolver.resolve(self) - self.operation_id = resolution.operation_id - self.__undecorated_function = resolution.function - - def with_definitions(self, schema): - if 'schema' in schema: - schema['schema']['definitions'] = self.definitions - return schema - return schema - - def get_mimetype(self): - """ - If the endpoint has no 'produces' then the default is - 'application/json'. - - :rtype str - """ - if all_json(self.produces): - try: - return self.produces[0] - except IndexError: - return DEFAULT_MIMETYPE - elif len(self.produces) == 1: - return self.produces[0] - else: - return DEFAULT_MIMETYPE - - def get_path_parameter_types(self): - return {p['name']: 'path' if p.get('type') == 'string' and p.get('format') == 'path' else p.get('type') - for p in self.parameters if p['in'] == 'path'} - - @property - def body_schema(self): - """ - The body schema definition for this operation. - """ - return self.with_definitions(self.body_definition).get('schema') - - @property - def body_definition(self): - """ - The body complete definition for this operation. - - **There can be one "body" parameter at most.** - - :rtype: dict - """ - body_parameters = [parameter for parameter in self.parameters if parameter['in'] == 'body'] - if len(body_parameters) > 1: - raise InvalidSpecification( - "{method} {path} There can be one 'body' parameter at most".format(**vars(self))) - - return body_parameters[0] if body_parameters else {} - - @property - def function(self): - """ - Operation function with decorators - - :rtype: types.FunctionType - """ - - function = parameter_to_arg( - self.parameters, self.consumes, self.__undecorated_function, self.pythonic_params, - self.pass_context_arg_name) - function = self._request_begin_lifecycle_decorator(function) - - if self.validate_responses: - logger.debug('... Response validation enabled.') - response_decorator = self.__response_validation_decorator - logger.debug('... Adding response decorator (%r)', response_decorator) - function = response_decorator(function) - - produces_decorator = self.__content_type_decorator - logger.debug('... Adding produces decorator (%r)', produces_decorator) - function = produces_decorator(function) - - for validation_decorator in self.__validation_decorators: - function = validation_decorator(function) - - uri_parsing_decorator = self.__uri_parsing_decorator - logging.debug('... Adding uri parsing decorator (%r)', uri_parsing_decorator) - function = uri_parsing_decorator(function) - - # NOTE: the security decorator should be applied last to check auth before anything else :-) - security_decorator = self.security_decorator - logger.debug('... Adding security decorator (%r)', security_decorator) - function = security_decorator(function) - - if UWSGIMetricsCollector.is_available(): # pragma: no cover - decorator = UWSGIMetricsCollector(self.path, self.method) - function = decorator(function) - - function = self._request_end_lifecycle_decorator(function) - - return function - - @property - def __uri_parsing_decorator(self): - """ - Get uri parsing decorator - - This decorator handles query and path parameter deduplication and - array types. - """ - return self.uri_parser_class(self.parameters) - - @property - def __content_type_decorator(self): - """ - Get produces decorator. - - If the operation mimetype format is json then the function return value is jsonified - - From Swagger Specification: - - **Produces** - - A list of MIME types the operation can produce. This overrides the produces definition at the Swagger Object. - An empty value MAY be used to clear the global definition. - - :rtype: types.FunctionType - """ - - logger.debug('... Produces: %s', self.produces, extra=vars(self)) - - mimetype = self.get_mimetype() - if all_json(self.produces): # endpoint will return json - logger.debug('... Produces json', extra=vars(self)) - # TODO: Refactor this. - return lambda f: f - - elif len(self.produces) == 1: - logger.debug('... Produces %s', mimetype, extra=vars(self)) - decorator = Produces(mimetype) - return decorator - - else: - return BaseSerializer() - - @property - def __validation_decorators(self): - """ - :rtype: types.FunctionType - """ - ParameterValidator = self.validator_map['parameter'] - RequestBodyValidator = self.validator_map['body'] - if self.parameters: - yield ParameterValidator(self.parameters, - self.api, - strict_validation=self.strict_validation) - if self.body_schema: - yield RequestBodyValidator(self.body_schema, self.consumes, self.api, - is_nullable(self.body_definition)) - - @property - def __response_validation_decorator(self): - """ - Get a decorator for validating the generated Response. - :rtype: types.FunctionType - """ - ResponseValidator = self.validator_map['response'] - return ResponseValidator(self, self.get_mimetype()) - - def json_loads(self, data): - """ - A wrapper for calling the API specific JSON loader. - - :param data: The JSON data in textual form. - :type data: bytes - """ - return self.api.json_loads(data) - - -Operation = Swagger2Operation diff --git a/connexion/operations/__init__.py b/connexion/operations/__init__.py new file mode 100644 index 000000000..39124c155 --- /dev/null +++ b/connexion/operations/__init__.py @@ -0,0 +1,3 @@ +from .abstract import AbstractOperation # noqa +from .swagger2 import Swagger2Operation # noqa +from .secure import SecureOperation # noqa diff --git a/connexion/operations/abstract.py b/connexion/operations/abstract.py new file mode 100644 index 000000000..e60f94a89 --- /dev/null +++ b/connexion/operations/abstract.py @@ -0,0 +1,403 @@ +import abc +import logging + +import six + +from connexion.operations.secure import SecureOperation + +from ..decorators.metrics import UWSGIMetricsCollector +from ..decorators.parameter import parameter_to_arg +from ..decorators.produces import BaseSerializer, Produces +from ..decorators.response import ResponseValidator +from ..decorators.validation import ParameterValidator, RequestBodyValidator +from ..utils import all_json, is_nullable + +logger = logging.getLogger('connexion.operations.abstract') + +DEFAULT_MIMETYPE = 'application/json' + +VALIDATOR_MAP = { + 'parameter': ParameterValidator, + 'body': RequestBodyValidator, + 'response': ResponseValidator, +} + + +@six.add_metaclass(abc.ABCMeta) +class AbstractOperation(SecureOperation): + + """ + An API routes requests to an Operation by a (path, method) pair. + The operation uses a resolver to resolve its handler function. + We use the provided spec to do a bunch of heavy lifting before + (and after) we call security_schemes handler. + The registered handler function ends up looking something like: + + @secure_endpoint + @validate_inputs + @deserialize_function_inputs + @serialize_function_outputs + @validate_outputs + def user_provided_handler_function(important, stuff): + if important: + serious_business(stuff) + """ + + def __init__(self, api, method, path, operation, resolver, + app_security=None, security_schemes=None, + validate_responses=False, strict_validation=False, + randomize_endpoint=None, validator_map=None, + pythonic_params=False, uri_parser_class=None, + pass_context_arg_name=None): + """ + :param api: api that this operation is attached to + :type api: apis.AbstractAPI + :param method: HTTP method + :type method: str + :param path: + :type path: str + :param operation: swagger operation object + :type operation: dict + :param resolver: Callable that maps operationID to a function + :param app_produces: list of content types the application can return by default + :param app_security: list of security rules the application uses by default + :type app_security: list + :param security_schemes: `Security Definitions Object + `_ + :type security_schemes: dict + :param validate_responses: True enables validation. Validation errors generate HTTP 500 responses. + :type validate_responses: bool + :param strict_validation: True enables validation on invalid request parameters + :type strict_validation: bool + :param randomize_endpoint: number of random characters to append to operation name + :type randomize_endpoint: integer + :param validator_map: Custom validators for the types "parameter", "body" and "response". + :type validator_map: dict + :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 uri_parser_class: class to use for uri parseing + :type uri_parser_class: AbstractURIParser + :param pass_context_arg_name: If not None will try to inject the request context to the function using this + name. + :type pass_context_arg_name: str|None + """ + self._api = api + self._method = method + self._path = path + self._operation = operation + self._resolver = resolver + self._security = app_security + self._security_schemes = security_schemes + self._validate_responses = validate_responses + self._strict_validation = strict_validation + self._pythonic_params = pythonic_params + self._uri_parser_class = uri_parser_class + self._pass_context_arg_name = pass_context_arg_name + self._randomize_endpoint = randomize_endpoint + + self._router_controller = self._operation.get('x-swagger-router-controller') + + self._operation_id = self._operation.get("operationId") + self._resolution = resolver.resolve(self) + self._operation_id = self._resolution.operation_id + + self._validator_map = dict(VALIDATOR_MAP) + self._validator_map.update(validator_map or {}) + + @property + def method(self): + """ + The HTTP method for this operation (ex. GET, POST) + """ + return self._method + + @property + def path(self): + """ + The path of the operation, relative to the API base path + """ + return self._path + + @property + def validator_map(self): + """ + Validators to use for parameter, body, and response validation + """ + return self._validator_map + + @property + def operation_id(self): + """ + The operation id used to indentify the operation internally to the app + """ + return self._operation_id + + @property + def randomize_endpoint(self): + """ + number of random digits to generate and append to the operation_id. + """ + return self._randomize_endpoint + + @property + def router_controller(self): + """ + The router controller to use (python module where handler functions live) + """ + return self._router_controller + + @property + def strict_validation(self): + """ + If True, validate all requests against the spec + """ + return self._strict_validation + + @property + def pythonic_params(self): + """ + If True, convert CamelCase into pythonic_variable_names + """ + return self._pythonic_params + + @property + def validate_responses(self): + """ + If True, check the response against the response schema, and return an + error if the response does not validate. + """ + return self._validate_responses + + def _get_file_arguments(self, files, arguments, has_kwargs=False): + return {k: v for k, v in files.items() if not has_kwargs and k in arguments} + + @abc.abstractmethod + def _get_val_from_param(self, value, query_defn): + """ + Convert input parameters into the correct type + """ + + @abc.abstractmethod + def _get_query_arguments(self, query, arguments, has_kwargs, sanitize): + """ + extract handler function arguments from the query parameters + """ + + def _get_path_arguments(self, path_params, sanitize): + """ + extract handler function arguments from path parameters + """ + kwargs = {} + path_defns = {p["name"]: p for p in self.parameters if p["in"] == "path"} + for key, value in path_params.items(): + key = sanitize(key) + if key in path_defns: + kwargs[key] = self._get_val_from_param(value, path_defns[key]) + else: # Assume path params mechanism used for injection + kwargs[key] = value + return kwargs + + @abc.abstractproperty + def parameters(self): + """ + Returns the parameters for this operation + """ + + @abc.abstractproperty + def produces(self): + """ + Content-Types that the operation produces + """ + + @abc.abstractproperty + def consumes(self): + """ + Content-Types that the operation consumes + """ + + @abc.abstractproperty + def body_schema(self): + """ + The body schema definition for this operation. + """ + + @abc.abstractproperty + def body_definition(self): + """ + The body definition for this operation. + :rtype: dict + """ + + @abc.abstractmethod + def get_arguments(self, path_params, query_params, body, files, arguments, + has_kwargs, sanitize): + """ + get all of the arguments for handler function + """ + + @abc.abstractmethod + def response_definition(self, code=None, mimetype=None): + """ + response definition for this endpoint + """ + + @abc.abstractmethod + def response_schema(self, code=None, mimetype=None): + """ + response schema for this endpoint + """ + + @abc.abstractmethod + def example_response(self, code=None, mimetype=None): + """ + Returns an example from the spec + """ + + @abc.abstractmethod + def get_path_parameter_types(self): + """ + Returns the types for parameters in the path + """ + + @abc.abstractmethod + def with_definitions(self, schema): + """ + Returns the given schema, but with the definitions from the spec + attached. This allows any remaining references to be resolved by a + validator (for example). + """ + + def get_mimetype(self): + """ + If the endpoint has no 'produces' then the default is + 'application/json'. + + :rtype str + """ + if all_json(self.produces): + try: + return self.produces[0] + except IndexError: + return DEFAULT_MIMETYPE + elif len(self.produces) == 1: + return self.produces[0] + else: + return DEFAULT_MIMETYPE + + @property + def _uri_parsing_decorator(self): + """ + Returns a decorator that parses request data and handles things like + array types, and duplicate parameter definitions. + """ + return self._uri_parser_class(self.parameters) + + @property + def function(self): + """ + Operation function with decorators + + :rtype: types.FunctionType + """ + function = parameter_to_arg( + self, self._resolution.function, self.pythonic_params, + self._pass_context_arg_name + ) + function = self._request_begin_lifecycle_decorator(function) + + if self.validate_responses: + logger.debug('... Response validation enabled.') + response_decorator = self.__response_validation_decorator + logger.debug('... Adding response decorator (%r)', response_decorator) + function = response_decorator(function) + + produces_decorator = self.__content_type_decorator + logger.debug('... Adding produces decorator (%r)', produces_decorator) + function = produces_decorator(function) + + for validation_decorator in self.__validation_decorators: + function = validation_decorator(function) + + uri_parsing_decorator = self._uri_parsing_decorator + function = uri_parsing_decorator(function) + + # NOTE: the security decorator should be applied last to check auth before anything else :-) + security_decorator = self.security_decorator + logger.debug('... Adding security decorator (%r)', security_decorator) + function = security_decorator(function) + + if UWSGIMetricsCollector.is_available(): # pragma: no cover + decorator = UWSGIMetricsCollector(self.path, self.method) + function = decorator(function) + + function = self._request_end_lifecycle_decorator(function) + + return function + + @property + def __content_type_decorator(self): + """ + Get produces decorator. + + If the operation mimetype format is json then the function return value is jsonified + + From Swagger Specification: + + **Produces** + + A list of MIME types the operation can produce. This overrides the produces definition at the Swagger Object. + An empty value MAY be used to clear the global definition. + + :rtype: types.FunctionType + """ + + logger.debug('... Produces: %s', self.produces, extra=vars(self)) + + mimetype = self.get_mimetype() + if all_json(self.produces): # endpoint will return json + logger.debug('... Produces json', extra=vars(self)) + # TODO: Refactor this. + return lambda f: f + + elif len(self.produces) == 1: + logger.debug('... Produces %s', mimetype, extra=vars(self)) + decorator = Produces(mimetype) + return decorator + + else: + return BaseSerializer() + + @property + def __validation_decorators(self): + """ + :rtype: types.FunctionType + """ + ParameterValidator = self.validator_map['parameter'] + RequestBodyValidator = self.validator_map['body'] + if self.parameters: + yield ParameterValidator(self.parameters, + self.api, + strict_validation=self.strict_validation) + if self.body_schema: + yield RequestBodyValidator(self.body_schema, self.consumes, self.api, + is_nullable(self.body_definition), + strict_validation=self.strict_validation) + + @property + def __response_validation_decorator(self): + """ + Get a decorator for validating the generated Response. + :rtype: types.FunctionType + """ + ResponseValidator = self.validator_map['response'] + return ResponseValidator(self, self.get_mimetype()) + + def json_loads(self, data): + """ + A wrapper for calling the API specific JSON loader. + + :param data: The JSON data in textual form. + :type data: bytes + """ + return self.api.json_loads(data) diff --git a/connexion/operations/secure.py b/connexion/operations/secure.py new file mode 100644 index 000000000..bdbe8f451 --- /dev/null +++ b/connexion/operations/secure.py @@ -0,0 +1,124 @@ +import functools +import logging + +from ..decorators.decorator import (BeginOfRequestLifecycleDecorator, + EndOfRequestLifecycleDecorator) +from ..decorators.security import (get_tokeninfo_func, get_tokeninfo_url, + security_passthrough, verify_oauth_local, + verify_oauth_remote) + +logger = logging.getLogger("connexion.operations.secure") + +DEFAULT_MIMETYPE = 'application/json' + + +class SecureOperation(object): + + def __init__(self, api, security, security_schemes): + """ + :param security: list of security rules the application uses by default + :type security: list + :param security_definitions: `Security Definitions Object + `_ + :type security_definitions: dict + """ + self._api = api + self._security = security + self._security_schemes = security_schemes + + @property + def api(self): + return self._api + + @property + def security(self): + return self._security + + @property + def security_schemes(self): + return self._security_schemes + + @property + def security_decorator(self): + """ + Gets the security decorator for operation + + From Swagger Specification: + + **Security Definitions Object** + + A declaration of the security schemes available to be used in the specification. + + This does not enforce the security schemes on the operations and only serves to provide the relevant details + for each scheme. + + + **Security Requirement Object** + + Lists the required security schemes to execute this operation. The object can have multiple security schemes + declared in it which are all required (that is, there is a logical AND between the schemes). + + The name used for each property **MUST** correspond to a security scheme declared in the Security Definitions. + + :rtype: types.FunctionType + """ + logger.debug('... Security: %s', self.security, extra=vars(self)) + if self.security: + if len(self.security) > 1: + logger.debug("... More than one security requirement defined. **IGNORING SECURITY REQUIREMENTS**", + extra=vars(self)) + return security_passthrough + + security = self.security[0] # type: dict + # the following line gets the first (and because of the previous condition only) scheme and scopes + # from the operation's security requirements + + scheme_name, scopes = next(iter(security.items())) # type: str, list + security_definition = self.security_schemes[scheme_name] + if security_definition['type'] == 'oauth2': + token_info_url = get_tokeninfo_url(security_definition) + token_info_func = get_tokeninfo_func(security_definition) + scopes = set(scopes) # convert scopes to set because this is needed for verify_oauth_remote + + if token_info_url and token_info_func: + logger.warning("... Both x-tokenInfoUrl and x-tokenInfoFunc are defined, using x-tokenInfoFunc", + extra=vars(self)) + if token_info_func: + return functools.partial(verify_oauth_local, token_info_func, scopes) + if token_info_url: + return functools.partial(verify_oauth_remote, token_info_url, scopes) + else: + logger.warning("... OAuth2 token info URL missing. **IGNORING SECURITY REQUIREMENTS**", + extra=vars(self)) + elif security_definition['type'] in ('apiKey', 'basic'): + logger.debug( + "... Security type '%s' not natively supported by Connexion; you should handle it yourself", + security_definition['type'], extra=vars(self)) + + # if we don't know how to handle the security or it's not defined we will usa a passthrough decorator + return security_passthrough + + def get_mimetype(self): + return DEFAULT_MIMETYPE + + @property + def _request_begin_lifecycle_decorator(self): + """ + Transforms the result of the operation handler in a internal + representation (connexion.lifecycle.ConnexionRequest) to be + used by internal Connexion decorators. + + :rtype: types.FunctionType + """ + return BeginOfRequestLifecycleDecorator(self.api, self.get_mimetype()) + + @property + def _request_end_lifecycle_decorator(self): + """ + Guarantees that instead of the internal representation of the + operation handler response + (connexion.lifecycle.ConnexionRequest) a framework specific + object is returned. + :rtype: types.FunctionType + """ + return EndOfRequestLifecycleDecorator(self.api, self.get_mimetype()) diff --git a/connexion/operations/swagger2.py b/connexion/operations/swagger2.py new file mode 100644 index 000000000..6764cc174 --- /dev/null +++ b/connexion/operations/swagger2.py @@ -0,0 +1,291 @@ +import logging +from copy import deepcopy + +from connexion.operations.abstract import AbstractOperation + +from ..decorators.uri_parsing import Swagger2URIParser +from ..exceptions import InvalidSpecification +from ..utils import deep_get, is_null, is_nullable, make_type + +logger = logging.getLogger("connexion.operations.swagger2") + + +class Swagger2Operation(AbstractOperation): + + """ + Exposes a Swagger 2.0 operation under the AbstractOperation interface. + The primary purpose of this class is to provide the `function()` method + to the API. A Swagger2Operation is plugged into the API with the provided + (path, method) pair. It resolves the handler function for this operation + with the provided resolver, and wraps the handler function with multiple + decorators that provide security, validation, serialization, + and deserialization. + """ + + def __init__(self, api, method, path, operation, resolver, app_produces, app_consumes, + path_parameters=None, app_security=None, security_definitions=None, + definitions=None, parameter_definitions=None, + response_definitions=None, validate_responses=False, strict_validation=False, + randomize_endpoint=None, validator_map=None, pythonic_params=False, + uri_parser_class=None, pass_context_arg_name=None): + """ + :param api: api that this operation is attached to + :type api: apis.AbstractAPI + :param method: HTTP method + :type method: str + :param path: relative path to this operation + :type path: str + :param operation: swagger operation object + :type operation: dict + :param resolver: Callable that maps operationID to a function + :type resolver: resolver.Resolver + :param app_produces: list of content types the application can return by default + :type app_produces: list + :param app_consumes: list of content types the application consumes by default + :type app_consumes: list + :param path_parameters: Parameters defined in the path level + :type path_parameters: list + :param app_security: list of security rules the application uses by default + :type app_security: list + :param security_definitions: `Security Definitions Object + `_ + :type security_definitions: dict + :param definitions: `Definitions Object + `_ + :type definitions: dict + :param parameter_definitions: Global parameter definitions + :type parameter_definitions: dict + :param response_definitions: Global response definitions + :type response_definitions: dict + :param validate_responses: True enables validation. Validation errors generate HTTP 500 responses. + :type validate_responses: bool + :param strict_validation: True enables validation on invalid request parameters + :type strict_validation: bool + :param randomize_endpoint: number of random characters to append to operation name + :type randomize_endpoint: integer + :param validator_map: Custom validators for the types "parameter", "body" and "response". + :type validator_map: dict + :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 uri_parser_class: class to use for uri parseing + :type uri_parser_class: AbstractURIParser + :param pass_context_arg_name: If not None will try to inject the request context to the function using this + name. + :type pass_context_arg_name: str|None + """ + app_security = operation.get('security', app_security) + uri_parser_class = uri_parser_class or Swagger2URIParser + + super(Swagger2Operation, self).__init__( + api=api, + method=method, + path=path, + operation=operation, + resolver=resolver, + app_security=app_security, + security_schemes=security_definitions, + validate_responses=validate_responses, + strict_validation=strict_validation, + randomize_endpoint=randomize_endpoint, + validator_map=validator_map, + pythonic_params=pythonic_params, + uri_parser_class=uri_parser_class, + pass_context_arg_name=pass_context_arg_name + ) + + self._produces = operation.get('produces', app_produces) + self._consumes = operation.get('consumes', app_consumes) + + self.definitions = definitions or {} + + self.definitions_map = { + 'definitions': self.definitions, + 'parameters': parameter_definitions, + 'responses': response_definitions + } + + self._parameters = operation.get('parameters', []) + if path_parameters: + self._parameters += path_parameters + + self._responses = operation.get('responses', {}) + logger.debug(self._responses) + + logger.debug('consumes: %s', self.consumes) + logger.debug('produces: %s', self.produces) + + @property + def parameters(self): + return self._parameters + + @property + def consumes(self): + return self._consumes + + @property + def produces(self): + return self._produces + + def get_path_parameter_types(self): + types = {} + path_parameters = (p for p in self.parameters if p["in"] == "path") + for path_defn in path_parameters: + if path_defn.get('type') == 'string' and path_defn.get('format') == 'path': + # path is special case for type 'string' + path_type = 'path' + else: + path_type = path_defn.get('type') + types[path_defn['name']] = path_type + return types + + def with_definitions(self, schema): + if "schema" in schema: + schema['schema']['definitions'] = self.definitions + return schema + + def response_definition(self, status_code=None, content_type=None): + content_type = content_type or self.get_mimetype() + response_definitions = self._responses + response_definition = response_definitions.get(str(status_code), response_definitions.get("default", {})) + response_definition = self.with_definitions(response_definition) + return response_definition + + def response_schema(self, status_code=None, content_type=None): + response_definition = self.response_definition(status_code, content_type) + return self.with_definitions(response_definition.get("schema", {})) + + def example_response(self, code=None, *args, **kwargs): + """ + Returns example response from spec + """ + # simply use the first/lowest status code, this is probably 200 or 201 + code = code or sorted(self._responses.keys())[0] + examples_path = [str(code), 'examples'] + schema_example_path = [str(code), 'schema', 'example'] + try: + code = int(code) + except ValueError: + code = 200 + try: + return (list(deep_get(self._responses, examples_path).values())[0], code) + except KeyError: + pass + try: + return (deep_get(self._responses, schema_example_path), code) + except KeyError: + return (None, code) + + @property + def body_schema(self): + """ + The body schema definition for this operation. + """ + return self.with_definitions(self.body_definition).get('schema', {}) + + @property + def body_definition(self): + """ + The body complete definition for this operation. + + **There can be one "body" parameter at most.** + + :rtype: dict + """ + body_parameters = [p for p in self.parameters if p['in'] == 'body'] + if len(body_parameters) > 1: + raise InvalidSpecification( + "{method} {path} There can be one 'body' parameter at most".format( + method=self.method, + path=self.path)) + return body_parameters[0] if body_parameters else {} + + def get_arguments(self, path_params, query_params, body, files, arguments, + has_kwargs, sanitize): + """ + get arguments for handler function + """ + ret = {} + ret.update(self._get_path_arguments(path_params, sanitize)) + ret.update(self._get_query_arguments(query_params, arguments, has_kwargs, sanitize)) + ret.update(self._get_body_argument(body, arguments, has_kwargs, sanitize)) + ret.update(self._get_file_arguments(files, arguments, has_kwargs)) + return ret + + def _get_query_arguments(self, query, arguments, has_kwargs, sanitize): + query_defns = {sanitize(p["name"]): p + for p in self.parameters + if p["in"] == "query"} + default_query_params = {k: v['default'] + for k, v in query_defns.items() + if 'default' in v} + query_arguments = deepcopy(default_query_params) + + query_arguments.update(query) + res = {} + for key, value in query_arguments.items(): + key = sanitize(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_defn = query_defns[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_defn) + res[key] = self._get_val_from_param(value, query_defn) + return res + + def _get_body_argument(self, body, arguments, has_kwargs, sanitize): + kwargs = {} + body_parameters = [p for p in self.parameters if p['in'] == 'body'] or [{}] + default_body = body_parameters[0].get('schema', {}).get('default') + body_name = sanitize(body_parameters[0].get('name')) + + body = body or default_body + + form_defns = {sanitize(p['name']): p + for p in self.parameters + if p['in'] == 'formData'} + + default_form_params = {sanitize(p['name']): p['default'] + for p in form_defns + if 'default' in p} + + # Add body parameters + if body_name: + if not has_kwargs and body_name not in arguments: + logger.debug("Body parameter '%s' not in function arguments", body_name) + else: + logger.debug("Body parameter '%s' in function arguments", body_name) + kwargs[body_name] = body + + # Add formData parameters + form_arguments = deepcopy(default_form_params) + if form_defns and body: + form_arguments.update(body) + 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_defn = form_defns[key] + except KeyError: # pragma: no cover + logger.error("Function argument '{}' not defined in specification".format(key)) + else: + kwargs[key] = self._get_val_from_param(value, form_defn) + return kwargs + + def _get_val_from_param(self, value, query_defn): + if is_nullable(query_defn) and is_null(value): + return None + + query_schema = query_defn + + if query_schema["type"] == "array": + return [make_type(part, query_defn["items"]["type"]) for part in value] + else: + return make_type(value, query_defn["type"]) diff --git a/connexion/resolver.py b/connexion/resolver.py index eacabafec..5fe1434f5 100644 --- a/connexion/resolver.py +++ b/connexion/resolver.py @@ -34,7 +34,7 @@ def resolve(self, operation): """ Default operation resolver - :type operation: connexion.operation.Operation + :type operation: connexion.operations.AbstractOperation """ operation_id = self.resolve_operation_id(operation) return Resolution(self.resolve_function_from_operation_id(operation_id), operation_id) @@ -43,14 +43,13 @@ def resolve_operation_id(self, operation): """ Default operationId resolver - :type operation: connexion.operation.Operation + :type operation: connexion.operations.AbstractOperation """ - spec = operation.operation - operation_id = spec.get('operationId', '') - x_router_controller = spec.get('x-swagger-router-controller') - if x_router_controller is None: + operation_id = operation.operation_id + router_controller = operation.router_controller + if operation.router_controller is None: return operation_id - return '{}.{}'.format(x_router_controller, operation_id) + return '{}.{}'.format(router_controller, operation_id) def resolve_function_from_operation_id(self, operation_id): """ @@ -85,9 +84,9 @@ def resolve_operation_id(self, operation): """ Resolves the operationId using REST semantics unless explicitly configured in the spec - :type operation: connexion.operation.Operation + :type operation: connexion.operations.AbstractOperation """ - if operation.operation.get('operationId'): + if operation.operation_id: return Resolver.resolve_operation_id(self, operation) return self.resolve_operation_id_using_rest_semantics(operation) @@ -96,14 +95,14 @@ def resolve_operation_id_using_rest_semantics(self, operation): """ Resolves the operationId using REST semantics - :type operation: connexion.operation.Operation + :type operation: connexion.operations.AbstractOperation """ path_match = re.search( '^/?(?P([\w\-](?/*)(?P.*)$', operation.path ) def get_controller_name(): - x_router_controller = operation.operation.get('x-swagger-router-controller') + x_router_controller = operation.router_controller name = self.default_module_name resource_name = path_match.group('resource_name') diff --git a/connexion/utils.py b/connexion/utils.py index e12af8505..984f012bb 100644 --- a/connexion/utils.py +++ b/connexion/utils.py @@ -3,6 +3,48 @@ import six +# Python 2/3 compatibility: +try: + py_string = unicode +except NameError: # pragma: no cover + py_string = str # pragma: no cover + + +def boolean(s): + ''' + Convert JSON/Swagger boolean value to Python, raise ValueError otherwise + + >>> boolean('true') + True + + >>> boolean('false') + False + ''' + if isinstance(s, bool): + return s + elif not hasattr(s, 'lower'): + raise ValueError('Invalid boolean value') + elif s.lower() == 'true': + return True + elif s.lower() == 'false': + return False + else: + raise ValueError('Invalid boolean value') + + +# 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 make_type(value, type): + type_func = TYPE_MAP[type] # convert value to right type + return type_func(value) + def deep_getattr(obj, attr): """ @@ -94,30 +136,11 @@ def all_json(mimetypes): return all(is_json_mimetype(mimetype) for mimetype in mimetypes) -def boolean(s): - ''' - Convert JSON/Swagger boolean value to Python, raise ValueError otherwise - - >>> boolean('true') - True - - >>> boolean('false') - False - ''' - if isinstance(s, bool): - return s - elif not hasattr(s, 'lower'): - raise ValueError('Invalid boolean value') - elif s.lower() == 'true': - return True - elif s.lower() == 'false': - return False - else: - raise ValueError('Invalid boolean value') - - def is_nullable(param_def): - return param_def.get('x-nullable', False) + return ( + param_def.get('schema', param_def).get('nullable', False) or + param_def.get('x-nullable', False) # swagger2 + ) def is_null(value): diff --git a/requirements-devel.txt b/requirements-devel.txt index a575ad4ce..cc6cd32c1 100644 --- a/requirements-devel.txt +++ b/requirements-devel.txt @@ -1,7 +1,6 @@ --e git+https://github.com/Julian/jsonschema.git#egg=jsonschema +-e git+https://github.com/Julian/jsonschema.git@v2.6.0#egg=jsonschema -e git+https://github.com/pallets/flask.git#egg=flask -e git+https://github.com/kennethreitz/requests#egg=requests --e git+https://github.com/Yelp/swagger_spec_validator.git#egg=swagger-spec-validator -e git+https://github.com/zalando/python-clickclick.git#egg=clickclick inflection mock diff --git a/tests/api/test_responses.py b/tests/api/test_responses.py index 6840d4b1d..7bda20a4a 100644 --- a/tests/api/test_responses.py +++ b/tests/api/test_responses.py @@ -17,7 +17,7 @@ def test_app(simple_app): assert b"Swagger UI" in swagger_ui.data # test return Swagger UI static files - swagger_icon = app_client.get('/v1.0/ui/images/favicon.ico') # type: flask.Response + swagger_icon = app_client.get('/v1.0/ui/swagger-ui.js') # type: flask.Response assert swagger_icon.status_code == 200 post_greeting_url = app_client.post('/v1.0/greeting/jsantos/the/third/of/his/name', data={}) # type: flask.Response diff --git a/tests/decorators/test_parameter.py b/tests/decorators/test_parameter.py index d08d82790..e66ab4e4e 100644 --- a/tests/decorators/test_parameter.py +++ b/tests/decorators/test_parameter.py @@ -1,8 +1,6 @@ - from connexion.decorators.parameter import parameter_to_arg # we are using "mock" module here for Py 2.7 support from mock import MagicMock -from testfixtures import LogCapture def test_injection(): @@ -16,9 +14,14 @@ def test_injection(): def handler(**kwargs): func(**kwargs) - parameter_to_arg({}, [], handler)(request) + class Op(object): + consumes = ['application/json'] + + def get_arguments(self, *args, **kwargs): + return {"p1": "123"} + parameter_to_arg(Op(), handler)(request) func.assert_called_with(p1='123') - parameter_to_arg({}, [], handler, pass_context_arg_name='framework_request_ctx')(request) + parameter_to_arg(Op(), handler, pass_context_arg_name='framework_request_ctx')(request) func.assert_called_with(p1='123', framework_request_ctx=request.context) diff --git a/tests/test_mock.py b/tests/test_mock.py index 37b7f87ff..ed1a5f3c1 100644 --- a/tests/test_mock.py +++ b/tests/test_mock.py @@ -1,5 +1,5 @@ from connexion.mock import MockResolver, partial -from connexion.operation import Swagger2Operation +from connexion.operations import Swagger2Operation def test_partial(): @@ -152,4 +152,4 @@ def test_mock_resolver_notimplemented(): resolver=resolver) # check if it is using the mock function - assert operation._Swagger2Operation__undecorated_function() == ('No example response was defined.', 418) + assert operation._resolution.function() == ('No example response was defined.', 418) diff --git a/tests/test_operation.py b/tests/test_operation2.py similarity index 99% rename from tests/test_operation.py rename to tests/test_operation2.py index 4aeff5ff6..f27187c3d 100644 --- a/tests/test_operation.py +++ b/tests/test_operation2.py @@ -11,7 +11,7 @@ verify_oauth_remote) from connexion.exceptions import InvalidSpecification from connexion.jsonref import resolve_refs -from connexion.operation import Swagger2Operation +from connexion.operations import Swagger2Operation from connexion.resolver import Resolver TEST_FOLDER = pathlib.Path(__file__).parent diff --git a/tests/test_resolver.py b/tests/test_resolver.py index 7753b24a0..5b17eed8e 100644 --- a/tests/test_resolver.py +++ b/tests/test_resolver.py @@ -1,7 +1,7 @@ import connexion.apps import pytest from connexion.exceptions import ResolverError -from connexion.operation import Swagger2Operation +from connexion.operations import Swagger2Operation from connexion.resolver import Resolver, RestyResolver PARAMETER_DEFINITIONS = {'myparam': {'in': 'path', 'type': 'integer'}}