diff --git a/connexion/api.py b/connexion/api.py index 51cdf4346..b051a1ab1 100644 --- a/connexion/api.py +++ b/connexion/api.py @@ -62,7 +62,8 @@ class Api(object): def __init__(self, specification, base_url=None, arguments=None, swagger_json=None, swagger_ui=None, swagger_path=None, swagger_url=None, validate_responses=False, strict_validation=False, resolver=None, - auth_all_paths=False, debug=False, resolver_error_handler=None, validator_map=None): + auth_all_paths=False, debug=False, resolver_error_handler=None, + validator_map=None, pythonic_params=False): """ :type specification: pathlib.Path | dict :type base_url: str | None @@ -81,6 +82,9 @@ def __init__(self, specification, base_url=None, arguments=None, :param resolver_error_handler: If given, a callable that generates an Operation used for handling ResolveErrors :type resolver_error_handler: callable | None + :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 """ self.debug = debug self.validator_map = validator_map @@ -142,6 +146,9 @@ def __init__(self, specification, base_url=None, arguments=None, logger.debug('Strict Request Validation: %s', str(validate_responses)) self.strict_validation = strict_validation + logger.debug('Pythonic params: %s', str(pythonic_params)) + self.pythonic_params = pythonic_params + # Create blueprint and endpoints self.blueprint = self.create_blueprint() @@ -186,7 +193,8 @@ def add_operation(self, method, path, swagger_operation, path_parameters): validate_responses=self.validate_responses, validator_map=self.validator_map, strict_validation=self.strict_validation, - resolver=self.resolver) + resolver=self.resolver, + pythonic_params=self.pythonic_params) self._add_operation_internal(method, path, operation) def _add_resolver_error_handler(self, method, path, err): diff --git a/connexion/app.py b/connexion/app.py index fda91cb83..cf836fa78 100644 --- a/connexion/app.py +++ b/connexion/app.py @@ -100,7 +100,7 @@ def common_error_handler(exception): def add_api(self, specification, base_path=None, arguments=None, auth_all_paths=None, swagger_json=None, swagger_ui=None, swagger_path=None, swagger_url=None, validate_responses=False, - strict_validation=False, resolver=Resolver(), resolver_error=None): + strict_validation=False, resolver=Resolver(), resolver_error=None, pythonic_params=False): """ Adds an API to the application based on a swagger file or API dict @@ -129,6 +129,8 @@ def add_api(self, specification, base_path=None, arguments=None, auth_all_paths= :param resolver_error: If specified, turns ResolverError into error responses with the given status code. :type resolver_error: int | None + :param pythonic_params: When True CamelCase parameters are converted to snake_case + :type pythonic_params: bool :rtype: Api """ # Turn the resolver_error code into a handler object @@ -165,7 +167,8 @@ def add_api(self, specification, base_path=None, arguments=None, auth_all_paths= strict_validation=strict_validation, auth_all_paths=auth_all_paths, debug=self.debug, - validator_map=self.validator_map) + validator_map=self.validator_map, + pythonic_params=pythonic_params) self.app.register_blueprint(api.blueprint) return api diff --git a/connexion/decorators/parameter.py b/connexion/decorators/parameter.py index 655846889..b7b6bd2cb 100644 --- a/connexion/decorators/parameter.py +++ b/connexion/decorators/parameter.py @@ -5,11 +5,18 @@ import re import flask +import inflection import six import werkzeug.exceptions as exceptions from ..utils import all_json, boolean, is_null, is_nullable +try: + import builtins +except ImportError: + import __builtin__ as builtins + + logger = logging.getLogger(__name__) # https://github.com/swagger-api/swagger-spec/blob/master/versions/2.0.md#data-types @@ -59,11 +66,21 @@ def get_val_from_param(value, query_param): return make_type(value, query_param["type"]) -def sanitize_param(name): - return name and re.sub('^[^a-zA-Z_]+', '', re.sub('[^0-9a-zA-Z_]', '', name)) +def snake_and_shadow(name): + """ + Converts the given name into Pythonic form. Firstly it converts CamelCase names to snake_case. Secondly it looks to + see if the name matches a known built-in and if it does it appends an underscore to the name. + :param name: The parameter name + :type name: str + :return: + """ + snake = inflection.underscore(name) + if snake in builtins.__dict__.keys(): + return "{}_".format(snake) + return snake -def parameter_to_arg(parameters, consumes, function): +def parameter_to_arg(parameters, consumes, function, pythonic_params=False): """ Pass query and body parameters as keyword arguments to handler function. @@ -73,8 +90,16 @@ def parameter_to_arg(parameters, consumes, function): :param consumes: The list of content types the operation consumes :type consumes: list :param function: The handler function for the REST endpoint. + :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 :type function: function|None """ + def sanitize_param(name): + if name and pythonic_params: + name = snake_and_shadow(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') @@ -158,6 +183,9 @@ def wrapper(*args, **kwargs): logger.debug("File parameter (formData) '%s' in function arguments", key) kwargs[key] = value + # optionally convert parameter variable names to un-shadowed, snake_case form + if pythonic_params: + kwargs = {snake_and_shadow(k): v for k, v in kwargs.items()} return function(*args, **kwargs) return wrapper diff --git a/connexion/operation.py b/connexion/operation.py index 9b8ed360b..c68280f1f 100644 --- a/connexion/operation.py +++ b/connexion/operation.py @@ -133,7 +133,7 @@ def __init__(self, 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): + validator_map=None, pythonic_params=False): """ This class uses the OperationID identify the module and function that will handle the operation @@ -177,6 +177,9 @@ def __init__(self, method, path, operation, resolver, app_produces, app_consumes :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 """ self.method = method @@ -196,6 +199,7 @@ def __init__(self, method, path, operation, resolver, app_produces, app_consumes self.strict_validation = strict_validation self.operation = operation self.randomize_endpoint = randomize_endpoint + self.pythonic_params = pythonic_params # todo support definition references # todo support references to application level parameters @@ -361,7 +365,7 @@ def function(self): """ function = parameter_to_arg( - self.parameters, self.consumes, self.__undecorated_function) + self.parameters, self.consumes, self.__undecorated_function, self.pythonic_params) function = self._request_begin_lifecycle_decorator(function) if self.validate_responses: diff --git a/setup.py b/setup.py index 8b2334eae..632003e16 100755 --- a/setup.py +++ b/setup.py @@ -33,6 +33,7 @@ def read_version(package): 'six>=1.9', 'strict-rfc3339>=0.6', 'swagger-spec-validator>=2.0.2', + 'inflection>=0.3.1' ] if py_major_minor_version < (3, 4): diff --git a/tests/api/test_parameters.py b/tests/api/test_parameters.py index 16c34b5d1..952835470 100644 --- a/tests/api/test_parameters.py +++ b/tests/api/test_parameters.py @@ -309,3 +309,24 @@ def test_param_sanitization(simple_app): headers={'Content-Type': 'application/json'}) assert resp.status_code == 200 assert json.loads(resp.data.decode('utf-8', 'replace')) == body + + +def test_parameters_snake_case(snake_case_app): + app_client = snake_case_app.app.test_client() + headers = {'Content-type': 'application/json'} + resp = app_client.post('/v1.0/test-post-path-snake/123', headers=headers, data=json.dumps({"a": "test"})) + assert resp.status_code == 200 + resp = app_client.post('/v1.0/test-post-path-shadow/123', headers=headers, data=json.dumps({"a": "test"})) + assert resp.status_code == 200 + resp = app_client.post('/v1.0/test-post-query-snake?someId=123', headers=headers, data=json.dumps({"a": "test"})) + assert resp.status_code == 200 + resp = app_client.post('/v1.0/test-post-query-shadow?id=123', headers=headers, data=json.dumps({"a": "test"})) + assert resp.status_code == 200 + resp = app_client.get('/v1.0/test-get-path-snake/123') + assert resp.status_code == 200 + resp = app_client.get('/v1.0/test-get-path-shadow/123') + assert resp.status_code == 200 + resp = app_client.get('/v1.0/test-get-query-snake?someId=123') + assert resp.status_code == 200 + resp = app_client.get('/v1.0/test-get-query-shadow?list=123') + assert resp.status_code == 200 diff --git a/tests/conftest.py b/tests/conftest.py index c43268132..d1bbc7091 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -95,6 +95,11 @@ def simple_app(): return build_app_from_fixture('simple', validate_responses=True) +@pytest.fixture(scope="session") +def snake_case_app(): + return build_app_from_fixture('snake_case', validate_responses=True, pythonic_params=True) + + @pytest.fixture(scope="session") def invalid_resp_allowed_app(): return build_app_from_fixture('simple', validate_responses=False) diff --git a/tests/fakeapi/snake_case.py b/tests/fakeapi/snake_case.py new file mode 100755 index 000000000..93e53b104 --- /dev/null +++ b/tests/fakeapi/snake_case.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +def get_path_snake(some_id): + data = {'SomeId': some_id} + return data + + +def get_path_shadow(id_): + data = {'id': id_} + return data + + +def get_query_snake(some_id): + data = {'someId': some_id} + return data + + +def get_query_shadow(list_): + data = {'list': list_} + return data + + +def post_path_snake(some_id, some_other_id): + data = {'SomeId': some_id, 'SomeOtherId': some_other_id} + return data + + +def post_path_shadow(id_, round_): + data = {'id': id_, 'reduce': round_} + return data + + +def post_query_snake(some_id, some_other_id): + data = {'someId': some_id, 'someOtherId': some_other_id} + return data + + +def post_query_shadow(id_, next_): + data = {'id': id_, 'next': next_} + return data diff --git a/tests/fixtures/snake_case/swagger.yaml b/tests/fixtures/snake_case/swagger.yaml new file mode 100644 index 000000000..52fe3c12b --- /dev/null +++ b/tests/fixtures/snake_case/swagger.yaml @@ -0,0 +1,166 @@ +swagger: "2.0" +info: + title: "{{title}}" + version: "1.0" +basePath: /v1.0 +paths: + /test-get-path-snake/{SomeId}: + get: + summary: Test converting to snake_case in path + description: Test converting to snake_case in path + operationId: fakeapi.snake_case.get_path_snake + produces: + - application/json + responses: + '200': + description: Success response + schema: + type: object + parameters: + - name: SomeId + in: path + description: SomeId parameter + required: true + type: integer + /test-get-path-shadow/{id}: + get: + summary: Test converting to un-shadowed parameter in path + description: Test converting to un-shadowed parameter in path + operationId: fakeapi.snake_case.get_path_shadow + produces: + - application/json + responses: + '200': + description: Success response + schema: + type: object + parameters: + - name: id + in: path + description: id parameter + required: true + type: integer + /test-get-query-snake: + get: + summary: Test converting to snake_case parameter in query + description: Test converting to snake_case parameter in query + operationId: fakeapi.snake_case.get_query_snake + produces: + - application/json + responses: + '200': + description: Success response + schema: + type: object + parameters: + - name: someId + in: query + description: id parameter + required: true + type: integer + /test-get-query-shadow: + get: + summary: Test converting to un-shadowed parameter in query + description: est converting to un-shadowed parameter in query + operationId: fakeapi.snake_case.get_query_shadow + produces: + - application/json + responses: + '200': + description: Success response + schema: + type: object + parameters: + - name: list + in: query + description: id parameter + required: true + type: integer + /test-post-path-snake/{SomeId}: + post: + summary: Test converting to snake_case in path + description: Test converting to snake_case in path + operationId: fakeapi.snake_case.post_path_snake + responses: + '200': + description: greeting response + schema: + type: object + parameters: + - name: SomeId + in: path + description: SomeId parameter + required: true + type: integer + - name: SomeOtherId + in: body + description: SomeOtherId parameter + required: true + schema: + type: object + /test-post-path-shadow/{id}: + post: + summary: Test converting to un-shadowed in path + description: Test converting to un-shadowed in path + operationId: fakeapi.snake_case.post_path_shadow + responses: + '200': + description: greeting response + schema: + type: object + parameters: + - name: id + in: path + description: id parameter + required: true + type: integer + - name: round + in: body + description: round parameter + required: true + schema: + type: object + /test-post-query-snake: + post: + summary: Test converting to snake_case in query + description: Test converting to snake_case in query + operationId: fakeapi.snake_case.post_query_snake + responses: + '200': + description: greeting response + schema: + type: object + parameters: + - name: someId + in: query + description: someId parameter + required: true + type: integer + - name: SomeOtherId + in: body + description: someOtherId parameter + required: true + schema: + type: object + /test-post-query-shadow: + post: + summary: Test converting to un-shadowed in query + description: Test converting to un-shadowed in query + operationId: fakeapi.snake_case.post_query_shadow + responses: + '200': + description: greeting response + schema: + type: object + parameters: + - name: id + in: query + description: id parameter + required: true + type: integer + - name: next + in: body + description: next parameter + required: true + schema: + type: object