From 702142ec488254380269a4e3cefdfc8b297bec8b Mon Sep 17 00:00:00 2001 From: Dan Ballance Date: Thu, 9 Mar 2017 16:42:00 +0000 Subject: [PATCH 1/6] Fixes #340 | Convert camelcased parameter names to Python style --- connexion/api.py | 12 +- connexion/app.py | 7 +- connexion/decorators/parameter.py | 34 ++++- connexion/operation.py | 8 +- setup.py | 1 + tests/api/test_parameters.py | 21 ++++ tests/conftest.py | 5 + tests/fakeapi/snake_case.py | 39 ++++++ tests/fixtures/snake_case/swagger.yaml | 166 +++++++++++++++++++++++++ 9 files changed, 283 insertions(+), 10 deletions(-) create mode 100755 tests/fakeapi/snake_case.py create mode 100644 tests/fixtures/snake_case/swagger.yaml 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..039171d6c 100644 --- a/connexion/decorators/parameter.py +++ b/connexion/decorators/parameter.py @@ -3,6 +3,11 @@ import inspect import logging import re +import inflection +try: + import builtins +except ImportError: + import __builtin__ as builtins import flask import six @@ -59,11 +64,20 @@ 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 "{0}_".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 +87,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 +180,10 @@ 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..6b681c655 --- /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_, reduce_): + data = {'id': id_, 'reduce': reduce_} + 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_, format_): + data = {'id': id_, 'format': format_} + return data diff --git a/tests/fixtures/snake_case/swagger.yaml b/tests/fixtures/snake_case/swagger.yaml new file mode 100644 index 000000000..90a0aa90c --- /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: reduce + in: body + description: reduce 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: format + in: body + description: format parameter + required: true + schema: + type: object From fd4a1c4da0ab5faa80ca6c76e445b0868edc4d1b Mon Sep 17 00:00:00 2001 From: Dan Ballance Date: Mon, 13 Mar 2017 10:25:39 +0000 Subject: [PATCH 2/6] Modified format to comply with project standards. --- connexion/decorators/parameter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/connexion/decorators/parameter.py b/connexion/decorators/parameter.py index 039171d6c..a4b793a27 100644 --- a/connexion/decorators/parameter.py +++ b/connexion/decorators/parameter.py @@ -74,7 +74,7 @@ def snake_and_shadow(name): """ snake = inflection.underscore(name) if snake in builtins.__dict__.keys(): - return "{0}_".format(snake) + return "{}_".format(snake) return snake def parameter_to_arg(parameters, consumes, function, pythonic_params=False): From 5f78226f5e0b04641c59cdb9264f5a99c99b9ed1 Mon Sep 17 00:00:00 2001 From: Dan Ballance Date: Mon, 13 Mar 2017 10:41:52 +0000 Subject: [PATCH 3/6] Modified unit tests to use example built-ins from both Python 2 & 3 --- tests/fakeapi/snake_case.py | 8 ++++---- tests/fixtures/snake_case/swagger.yaml | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/fakeapi/snake_case.py b/tests/fakeapi/snake_case.py index 6b681c655..93e53b104 100755 --- a/tests/fakeapi/snake_case.py +++ b/tests/fakeapi/snake_case.py @@ -24,8 +24,8 @@ def post_path_snake(some_id, some_other_id): return data -def post_path_shadow(id_, reduce_): - data = {'id': id_, 'reduce': reduce_} +def post_path_shadow(id_, round_): + data = {'id': id_, 'reduce': round_} return data @@ -34,6 +34,6 @@ def post_query_snake(some_id, some_other_id): return data -def post_query_shadow(id_, format_): - data = {'id': id_, 'format': format_} +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 index 90a0aa90c..52fe3c12b 100644 --- a/tests/fixtures/snake_case/swagger.yaml +++ b/tests/fixtures/snake_case/swagger.yaml @@ -114,9 +114,9 @@ paths: description: id parameter required: true type: integer - - name: reduce + - name: round in: body - description: reduce parameter + description: round parameter required: true schema: type: object @@ -158,9 +158,9 @@ paths: description: id parameter required: true type: integer - - name: format + - name: next in: body - description: format parameter + description: next parameter required: true schema: type: object From 87e9892e14660520bdd3597098beb03d305cf18d Mon Sep 17 00:00:00 2001 From: Dan Ballance Date: Mon, 13 Mar 2017 11:08:27 +0000 Subject: [PATCH 4/6] PEP8 white spaces fixes --- connexion/decorators/parameter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/connexion/decorators/parameter.py b/connexion/decorators/parameter.py index a4b793a27..0f3c85dfd 100644 --- a/connexion/decorators/parameter.py +++ b/connexion/decorators/parameter.py @@ -77,6 +77,7 @@ def snake_and_shadow(name): return "{}_".format(snake) return snake + def parameter_to_arg(parameters, consumes, function, pythonic_params=False): """ Pass query and body parameters as keyword arguments to handler function. @@ -180,7 +181,6 @@ 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()} From 2248314fb1f435bf9f43c88b3fc9a7a84394503b Mon Sep 17 00:00:00 2001 From: Dan Ballance Date: Mon, 13 Mar 2017 11:13:46 +0000 Subject: [PATCH 5/6] Import order altered to meet isort requirements --- connexion/decorators/parameter.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/connexion/decorators/parameter.py b/connexion/decorators/parameter.py index 0f3c85dfd..70e373b1e 100644 --- a/connexion/decorators/parameter.py +++ b/connexion/decorators/parameter.py @@ -3,17 +3,20 @@ import inspect import logging 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 -import flask -import six -import werkzeug.exceptions as exceptions -from ..utils import all_json, boolean, is_null, is_nullable logger = logging.getLogger(__name__) From cbd64549f4ee5857807352637876532c3d6a49fd Mon Sep 17 00:00:00 2001 From: Dan Ballance Date: Mon, 13 Mar 2017 11:24:27 +0000 Subject: [PATCH 6/6] Fix for PEP8 white space issue introduced by isort --- connexion/decorators/parameter.py | 1 - 1 file changed, 1 deletion(-) diff --git a/connexion/decorators/parameter.py b/connexion/decorators/parameter.py index 70e373b1e..b7b6bd2cb 100644 --- a/connexion/decorators/parameter.py +++ b/connexion/decorators/parameter.py @@ -17,7 +17,6 @@ import __builtin__ as builtins - logger = logging.getLogger(__name__) # https://github.com/swagger-api/swagger-spec/blob/master/versions/2.0.md#data-types