Skip to content

Commit

Permalink
Merge pull request #117 from arjunrn/master
Browse files Browse the repository at this point in the history
Support for default fields in operation parameters
  • Loading branch information
jmcs committed Jan 12, 2016
2 parents 0674822 + f1e89f1 commit c5eea22
Show file tree
Hide file tree
Showing 7 changed files with 281 additions and 10 deletions.
15 changes: 13 additions & 2 deletions connexion/decorators/parameter.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import werkzeug.exceptions as exceptions
import copy
import flask
import functools
import inspect
Expand Down Expand Up @@ -50,14 +51,19 @@ def parameter_to_arg(parameters, function):
Pass query and body parameters as keyword arguments to handler function.
See (https://github.com/zalando/connexion/issues/59)
:type body_schema: dict|None
:param parameters: All the parameters of the handler functions
:type parameters: dict|None
:param function: The handler function for the REST endpoint.
:type function: function|None
"""
body_parameters = [parameter for parameter in parameters if parameter['in'] == 'body'] or [{}]
body_name = body_parameters[0].get('name')
default_body = body_parameters[0].get('default')
query_types = {parameter['name']: parameter
for parameter in parameters if parameter['in'] == 'query'} # type: dict[str, str]
arguments = get_function_arguments(function)
default_query_params = {param['name']: param['default'] for param in parameters if param['in'] == 'query'
and 'default' in param}

@functools.wraps(function)
def wrapper(*args, **kwargs):
Expand All @@ -68,6 +74,9 @@ def wrapper(*args, **kwargs):
except exceptions.BadRequest:
request_body = None

if default_body and not request_body:
request_body = default_body

# Add body parameters
if request_body is not None:
if body_name not in arguments:
Expand All @@ -77,7 +86,9 @@ def wrapper(*args, **kwargs):
kwargs[body_name] = request_body

# Add query parameters
for key, value in flask.request.args.items():
query_arguments = copy.deepcopy(default_query_params)
query_arguments.update(flask.request.args.items())
for key, value in query_arguments.items():
if key not in arguments:
logger.debug("Query Parameter '%s' not in function arguments", key)
else:
Expand Down
9 changes: 7 additions & 2 deletions connexion/decorators/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,13 @@ def validate_type(param, value, parameter_type, parameter_name=None):


class RequestBodyValidator:
def __init__(self, schema):
def __init__(self, schema, has_default=False):
"""
:param schema: The schema of the request body
:param has_default: Flag to indicate if default value is present.
"""
self.schema = schema
self.has_default = has_default

def __call__(self, function):
"""
Expand All @@ -95,7 +100,7 @@ def wrapper(*args, **kwargs):

logger.debug("%s validating schema...", flask.request.url)
error = self.validate_schema(data, self.schema)
if error:
if error and not self.has_default:
return error

response = function(*args, **kwargs)
Expand Down
41 changes: 37 additions & 4 deletions connexion/operation.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,17 @@
import functools
import logging
import os

import jsonschema
from jsonschema import ValidationError

from .decorators import validation
from .decorators.metrics import UWSGIMetricsCollector
from .decorators.parameter import parameter_to_arg
from .decorators.produces import BaseSerializer, Produces, Jsonifier
from .decorators.security import security_passthrough, verify_oauth
from .decorators.validation import RequestBodyValidator, ParameterValidator
from .decorators.metrics import UWSGIMetricsCollector
from .decorators.response import ResponseValidator
from .decorators.security import security_passthrough, verify_oauth
from .decorators.validation import RequestBodyValidator, ParameterValidator, TypeValidationError
from .exceptions import InvalidSpecification
from .utils import flaskify_endpoint, produces_json

Expand Down Expand Up @@ -87,6 +92,34 @@ def __init__(self, method, path, operation, app_produces, app_security, security
self.endpoint_name = flaskify_endpoint(self.operation_id)
self.__undecorated_function = resolution.function

for param in self.parameters:
if param['in'] == 'body' and 'default' in param:
self.default_body = param
break
else:
self.default_body = None

self.validate_defaults()

def validate_defaults(self):
for param in self.parameters:
try:
if param['in'] == 'body' and 'default' in param:
param = param.copy()
if 'required' in param:
del param['required']
if param['type'] == 'object':
jsonschema.validate(param['default'], self.body_schema,
format_checker=jsonschema.draft4_format_checker)
else:
jsonschema.validate(param['default'], param, format_checker=jsonschema.draft4_format_checker)
elif param['in'] == 'query' and 'default' in param:
validation.validate_type(param, param['default'], 'query', param['name'])
except (TypeValidationError, ValidationError):
raise InvalidSpecification('The parameter \'{param_name}\' has a default value which is not of'
' type \'{param_type}\''.format(param_name=param['name'],
param_type=param['type']))

def resolve_reference(self, schema):
schema = schema.copy() # avoid changing the original schema
reference = schema.get('$ref') # type: str
Expand Down Expand Up @@ -293,7 +326,7 @@ def __validation_decorators(self):
if self.parameters:
yield ParameterValidator(self.parameters)
if self.body_schema:
yield RequestBodyValidator(self.body_schema)
yield RequestBodyValidator(self.body_schema, self.default_body is not None)

@property
def __response_validation_decorator(self):
Expand Down
45 changes: 45 additions & 0 deletions tests/fakeapi/api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -571,6 +571,51 @@ paths:
- name: somefloat
in: path
type: number


/test-default-query-parameter:
get:
summary: Test if default parameter is passed to function
operationId: fakeapi.hello.test_default_param
parameters:
- name: name
in: query
type: string
default: connexion

/test-default-object-body:
post:
summary: Test if default object body param is passed to handler.
operationId: fakeapi.hello.test_default_object_body
parameters:
- name: stack
type: object
in: body
default:
'image_version': 'default_image'
schema:
$ref: '#/definitions/new_stack'

/test-default-integer-body:
post:
summary: Test if default integer body param is passed to handler.
operationId: fakeapi.hello.test_default_integer_body
parameters:
- name: stack_version
type: integer
in: body
default: 1

/test-falsy-param:
get:
summary: Test if default value when argument is falsy.
operationId: fakeapi.hello.test_falsy_param
parameters:
- name: falsy
type: integer
in: query
default: 1

definitions:
new_stack:
type: object
Expand Down
16 changes: 16 additions & 0 deletions tests/fakeapi/hello.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,3 +202,19 @@ def test_get_someint(someint):

def test_get_somefloat(somefloat):
return type(somefloat).__name__


def test_default_param(name):
return {"app_name": name}


def test_default_object_body(stack):
return {"stack": stack}


def test_default_integer_body(stack_version):
return stack_version


def test_falsy_param(falsy):
return falsy
35 changes: 35 additions & 0 deletions tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -544,3 +544,38 @@ def test_path_parameter_somefloat(app):
# non-float values will not match Flask route
resp = app_client.get('/v1.0/test-float-path/123,45') # type: flask.Response
assert resp.status_code == 404


def test_default_param(app):
app_client = app.app.test_client()
resp = app_client.get('/v1.0/test-default-query-parameter')
assert resp.status_code == 200
response = json.loads(resp.data.decode())
assert response['app_name'] == 'connexion'


def test_default_object_body(app):
app_client = app.app.test_client()
resp = app_client.post('/v1.0/test-default-object-body')
assert resp.status_code == 200
response = json.loads(resp.data.decode())
assert response['stack'] == {'image_version': 'default_image'}

resp = app_client.post('/v1.0/test-default-integer-body')
assert resp.status_code == 200
response = json.loads(resp.data.decode())
assert response == 1


def test_falsy_param(app):
app_client = app.app.test_client()
resp = app_client.get('/v1.0/test-falsy-param', query_string={'falsy': 0})
assert resp.status_code == 200
response = json.loads(resp.data.decode())
assert response == 0

resp = app_client.get('/v1.0/test-falsy-param')
assert resp.status_code == 200
response = json.loads(resp.data.decode())
assert response == 1

130 changes: 128 additions & 2 deletions tests/test_operation.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import pathlib
import pytest
import types

import pytest

from connexion.decorators.security import security_passthrough, verify_oauth
from connexion.exceptions import InvalidSpecification
from connexion.operation import Operation
from connexion.decorators.security import security_passthrough, verify_oauth
from connexion.resolver import Resolver

TEST_FOLDER = pathlib.Path(__file__).parent
Expand Down Expand Up @@ -99,6 +101,83 @@
OPERATION5 = {'operationId': 'fakeapi.hello.post_greeting',
'parameters': [{'$ref': '/parameters/fail'}]}

OPERATION6 = {'description': 'Adds a new stack to be created by lizzy and returns the '
'information needed to keep track of deployment',
'operationId': 'fakeapi.hello.post_greeting',
'parameters': [
{
'in': 'body',
'name': 'new_stack',
'required': True,
'schema': {'$ref': '#/definitions/new_stack'}
},
{
'in': 'query',
'name': 'stack_version',
'default': 'one',
'type': 'number'
}
],
'responses': {201: {'description': 'Stack to be created. The '
'CloudFormation Stack creation can '
"still fail if it's rejected by senza "
'or AWS CF.',
'schema': {'$ref': '#/definitions/stack'}},
400: {'description': 'Stack was not created because request '
'was invalid',
'schema': {'$ref': '#/definitions/problem'}},
401: {'description': 'Stack was not created because the '
'access token was not provided or was '
'not valid for this operation',
'schema': {'$ref': '#/definitions/problem'}}},
'summary': 'Create new stack'}

OPERATION7 = {
'description': 'Adds a new stack to be created by lizzy and returns the '
'information needed to keep track of deployment',
'operationId': 'fakeapi.hello.post_greeting',
'parameters': [
{
'in': 'body',
'name': 'new_stack',
'required': True,
'type': 'integer',
'default': 'stack'
}
],
'responses': {201: {'description': 'Stack to be created. The '
'CloudFormation Stack creation can '
"still fail if it's rejected by senza "
'or AWS CF.',
'schema': {'$ref': '#/definitions/stack'}},
400: {'description': 'Stack was not created because request '
'was invalid',
'schema': {'$ref': '#/definitions/problem'}},
401: {'description': 'Stack was not created because the '
'access token was not provided or was '
'not valid for this operation',
'schema': {'$ref': '#/definitions/problem'}}},
'security': [{'oauth': ['uid']}],
'summary': 'Create new stack'
}

OPERATION8 = {
'operationId': 'fakeapi.hello.schema',
'parameters': [
{
'type': 'object',
'in': 'body',
'name': 'new_stack',
'default': {'keep_stack': 1, 'image_version': 1, 'senza_yaml': 'senza.yaml',
'new_traffic': 100},
'schema': {'$ref': '#/definitions/new_stack'}
}
],
'responses': {},
'security': [{'oauth': ['uid']}],
'summary': 'Create new stack'
}

SECURITY_DEFINITIONS = {'oauth': {'type': 'oauth2',
'flow': 'password',
'x-tokenInfoUrl': 'https://ouath.example/token_info',
Expand Down Expand Up @@ -227,3 +306,50 @@ def test_resolve_invalid_reference():
assert exception.reason == "GET endpoint '$ref' needs to start with '#/'"


def test_bad_default():
with pytest.raises(InvalidSpecification) as exc_info:
Operation(method='GET', path='endpoint', operation=OPERATION6, app_produces=['application/json'],
app_security=[], security_definitions={}, definitions={}, parameter_definitions=PARAMETER_DEFINITIONS,
resolver=Resolver())
exception = exc_info.value
assert str(exception) == "<InvalidSpecification: The parameter 'stack_version' has a default value which " \
"is not of type 'number'>"
assert repr(exception) == "<InvalidSpecification: The parameter 'stack_version' has a default value which " \
"is not of type 'number'>"

with pytest.raises(InvalidSpecification) as exc_info:
Operation(method='GET', path='endpoint', operation=OPERATION7, app_produces=['application/json'],
app_security=[], security_definitions={}, definitions=DEFINITIONS, parameter_definitions={},
resolver=Resolver())
exception = exc_info.value
assert str(exception) == "<InvalidSpecification: The parameter 'new_stack' has a default value which " \
"is not of type 'integer'>"
assert repr(exception) == "<InvalidSpecification: The parameter 'new_stack' has a default value which " \
"is not of type 'integer'>"

with pytest.raises(InvalidSpecification) as exc_info:
Operation(
method='GET', path='endpoint', operation=OPERATION8, app_produces=['application/json'],
app_security=[], security_definitions={}, definitions=DEFINITIONS, parameter_definitions={},
resolver=Resolver()
)
exception = exc_info.value
assert str(exception) == "<InvalidSpecification: The parameter 'new_stack' has a default value which " \
"is not of type 'object'>"
assert repr(exception) == "<InvalidSpecification: The parameter 'new_stack' has a default value which " \
"is not of type 'object'>"


def test_default():
op = OPERATION6.copy()
op['parameters'][1]['default'] = 1
Operation(method='GET', path='endpoint', operation=op, app_produces=['application/json'], app_security=[],
security_definitions={}, definitions=DEFINITIONS, parameter_definitions=PARAMETER_DEFINITIONS,
resolver=Resolver())
op = OPERATION8.copy()
op['parameters'][0]['default'] = {
'keep_stacks': 1, 'image_version': 'one', 'senza_yaml': 'senza.yaml', 'new_traffic': 100
}
Operation(method='POST', path='endpoint', operation=op, app_produces=['application/json'],
app_security=[], security_definitions={}, definitions=DEFINITIONS, parameter_definitions={},
resolver=Resolver())

0 comments on commit c5eea22

Please sign in to comment.