Skip to content

Commit

Permalink
Merge pull request #114 from zalando/json-schema
Browse files Browse the repository at this point in the history
Use jsonschema
  • Loading branch information
ainmosni committed Dec 21, 2015
2 parents 7b69374 + 466de9f commit 934e41d
Show file tree
Hide file tree
Showing 7 changed files with 64 additions and 227 deletions.
199 changes: 48 additions & 151 deletions connexion/decorators/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,32 +15,19 @@
import functools
import itertools
import logging
import numbers
import re
import six
import strict_rfc3339
from jsonschema import draft4_format_checker, validate, ValidationError

from ..problem import problem
from ..utils import validate_date, boolean
from ..utils import boolean

logger = logging.getLogger('connexion.decorators.validation')

# https://github.com/swagger-api/swagger-spec/blob/master/versions/2.0.md#data-types
TYPE_MAP = {'integer': int,
'number': numbers.Number,
'string': six.string_types[0],
'boolean': bool,
'array': list,
'object': dict} # map of swagger types to python types

TYPE_VALIDATION_MAP = {
TYPE_MAP = {
'integer': int,
'number': float,
'boolean': boolean
}

FORMAT_MAP = {('string', 'date-time'): strict_rfc3339.validate_rfc3339,
('string', 'date'): validate_date}


class TypeValidationError(Exception):
def __init__(self, schema_type, parameter_type, parameter_name):
Expand All @@ -61,97 +48,35 @@ def __str__(self):
return msg.format(**vars(self))


def validate_type(schema, data, parameter_type, parameter_name=None):
schema_type = schema.get('type')
parameter_name = parameter_name if parameter_name else schema['name']
expected_type = TYPE_VALIDATION_MAP.get(schema_type)
if expected_type:
try:
return expected_type(data)
except ValueError:
raise TypeValidationError(schema_type, parameter_type, parameter_name)
return data


def validate_format(schema, data):
schema_type = schema.get('type')
schema_format = schema.get('format')
func = FORMAT_MAP.get((schema_type, schema_format))
if func and not func(data):
return "Invalid value, expected {schema_type} in '{schema_format}' format".format(**locals())


def validate_pattern(schema, data):
pattern = schema.get('pattern')
# TODO: check Swagger pattern format
if pattern is not None and not re.match(pattern, data):
return 'Invalid value, pattern "{}" does not match'.format(pattern)


def validate_minimum(schema, data):
minimum = schema.get('minimum')
if minimum is not None and data < minimum:
return 'Invalid value, must be at least {}'.format(minimum)


def validate_maximum(schema, data):
maximum = schema.get('maximum')
if maximum is not None and data > maximum:
return 'Invalid value, must be at most {}'.format(maximum)


def validate_min_length(schema, data):
minimum = schema.get('minLength')
if minimum is not None and len(data) < minimum:
return 'Length must be at least {}'.format(minimum)


def validate_max_length(schema, data):
maximum = schema.get('maxLength')
if maximum is not None and len(data) > maximum:
return 'Length must be at most {}'.format(maximum)


def validate_enum(schema, data):
enum_values = schema.get('enum')
if enum_values is not None and data not in enum_values:
return 'Enum value must be one of {}'.format(enum_values)


def validate_array(schema, data):
if schema.get('type') != 'array' or not schema.get('items'):
return
col_map = {'csv': ',',
'ssv': ' ',
'tsv': '\t',
'pipes': '|',
'multi': '&'}
col_fmt = schema.get('collectionFormat', 'csv')
delimiter = col_map.get(col_fmt)
if not delimiter:
logger.debug("Unrecognized collectionFormat, cannot validate: %s", col_fmt)
return
if col_fmt == 'multi':
logger.debug("collectionFormat 'multi' is not validated by Connexion")
return
subschema = schema.get('items')
items = data.split(delimiter)
for subval in items:
try:
converted_value = validate_type(subschema, subval, schema['in'], schema['name'])
except TypeValidationError as e:
return str(e)
# Run each sub-item through the list of validators.
for func in VALIDATORS:
error = func(subschema, converted_value)
if error:
return error
def make_type(value, type):
type_func = TYPE_MAP.get(type) # convert value to right type
return type_func(value)


VALIDATORS = [validate_format, validate_pattern,
validate_minimum, validate_maximum,
validate_min_length, validate_max_length,
validate_enum, validate_array]
def validate_type(param, value, parameter_type, parameter_name=None):
param_type = param.get('type')
parameter_name = parameter_name if parameter_name else param['name']
if param_type == "array": # then logic is more complex
if param.get("collectionFormat") and param.get("collectionFormat") == "pipes":
parts = value.split("|")
else: # default: csv
parts = value.split(",")

converted_parts = []
for part in parts:
try:
converted = make_type(part, param["items"]["type"])
except (ValueError, TypeError):
converted = part
converted_parts.append(converted)
return converted_parts
else:
try:
return make_type(value, param_type)
except ValueError:
raise TypeValidationError(param_type, parameter_type, parameter_name)
except TypeError:
return value


class RequestBodyValidator:
Expand Down Expand Up @@ -183,62 +108,34 @@ def validate_schema(self, data, schema):
:type schema: dict
:rtype: flask.Response | None
"""
schema_type = schema.get('type')
log_extra = {'url': flask.request.url, 'schema_type': schema_type}

expected_type = TYPE_MAP.get(schema_type) # type: type
actual_type = type(data) # type: type
if expected_type and not isinstance(data, expected_type):
expected_type_name = expected_type.__name__
actual_type_name = actual_type.__name__
logger.debug("'%s' is not a '%s'", data, expected_type_name)
error_template = "Wrong type, expected '{schema_type}' got '{actual_type_name}'"
error_message = error_template.format(schema_type=schema_type, actual_type_name=actual_type_name)
return problem(400, 'Bad Request', error_message)

if schema_type == 'array':
for item in data:
error = self.validate_schema(item, schema.get('items'))
if error:
return error
elif schema_type == 'object':
# verify if required keys are present
required_keys = schema.get('required', [])
logger.debug('... required keys: %s', required_keys)
log_extra['required_keys'] = required_keys
for required_key in schema.get('required', required_keys):
if required_key not in data:
logger.debug("Missing parameter '%s'", required_key, extra=log_extra)
return problem(400, 'Bad Request', "Missing parameter '{}'".format(required_key))

# verify if value types are correct
for key in data.keys():
key_properties = schema.get('properties', {}).get(key)
if key_properties:
error = self.validate_schema(data[key], key_properties)
if error:
return error
else:
for func in VALIDATORS:
error = func(schema, data)
if error:
return problem(400, 'Bad Request', error)
try:
validate(data, schema, format_checker=draft4_format_checker)
except ValidationError as exception:
return problem(400, 'Bad Request', str(exception))

return None


class ParameterValidator():
def __init__(self, parameters):
self.parameters = {k: list(g) for k, g in itertools.groupby(parameters, key=lambda p: p['in'])}

def validate_parameter(self, parameter_type, value, param):
@staticmethod
def validate_parameter(parameter_type, value, param):
if value is not None:
try:
converted_value = validate_type(param, value, parameter_type)
except TypeValidationError as e:
return str(e)
for func in VALIDATORS:
error = func(param, converted_value)
if error:
return error

if 'required' in param:
del param['required']
try:
validate(converted_value, param, format_checker=draft4_format_checker)
except ValidationError as exception:
print(converted_value, type(converted_value), param.get('type'), param, '<--------------------------')
return str(exception)

elif param.get('required'):
return "Missing {parameter_type} parameter '{param[name]}'".format(**locals())

Expand Down
14 changes: 0 additions & 14 deletions connexion/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
import functools
import importlib
import re
import strict_rfc3339

PATH_PARAMETER = re.compile(r'\{([^}]*)\}')

Expand Down Expand Up @@ -122,19 +121,6 @@ def produces_json(produces):
return all(is_json_mimetype(mimetype) for mimetype in produces)


def validate_date(s):
'''
Validate date as defined by "full-date" on http://xml2rfc.ietf.org/public/rfc/html/rfc3339.html#anchor14
>>> validate_date('foo')
False
>>> validate_date('2015-07-31')
True
'''
return strict_rfc3339.validate_rfc3339(s + 'T00:00:00Z')


def boolean(s):
'''
Convert JSON/Swagger boolean value to Python, raise ValueError otherwise
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
jsonschema
flask
PyYAML
requests
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ class PyTest(TestCommand):
def initialize_options(self):
TestCommand.initialize_options(self)
self.cov = None
self.pytest_args = ['--cov', 'connexion', '--cov-report', 'term-missing']
self.pytest_args = ['--cov', 'connexion', '--cov-report', 'term-missing', '-v']
self.cov_html = False

def finalize_options(self):
Expand Down
18 changes: 6 additions & 12 deletions tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -299,15 +299,15 @@ def test_schema(app):
assert empty_request.content_type == 'application/problem+json'
empty_request_response = json.loads(empty_request.data.decode()) # type: dict
assert empty_request_response['title'] == 'Bad Request'
assert empty_request_response['detail'] == "Missing parameter 'image_version'"
assert empty_request_response['detail'].startswith("'image_version' is a required property")

bad_type = app_client.post('/v1.0/test_schema', headers=headers,
data=json.dumps({'image_version': 22})) # type: flask.Response
assert bad_type.status_code == 400
assert bad_type.content_type == 'application/problem+json'
bad_type_response = json.loads(bad_type.data.decode()) # type: dict
assert bad_type_response['title'] == 'Bad Request'
assert bad_type_response['detail'] == "Wrong type, expected 'string' got 'int'"
assert bad_type_response['detail'].startswith("22 is not of type 'string'")

good_request = app_client.post('/v1.0/test_schema', headers=headers,
data=json.dumps({'image_version': 'version'})) # type: flask.Response
Expand All @@ -327,7 +327,7 @@ def test_schema(app):
assert wrong_type.content_type == 'application/problem+json'
wrong_type_response = json.loads(wrong_type.data.decode()) # type: dict
assert wrong_type_response['title'] == 'Bad Request'
assert wrong_type_response['detail'] == "Wrong type, expected 'object' got 'int'"
assert wrong_type_response['detail'].startswith("42 is not of type 'object'")


def test_schema_response(app):
Expand Down Expand Up @@ -390,15 +390,15 @@ def test_schema_list(app):
assert wrong_type.content_type == 'application/problem+json'
wrong_type_response = json.loads(wrong_type.data.decode()) # type: dict
assert wrong_type_response['title'] == 'Bad Request'
assert wrong_type_response['detail'] == "Wrong type, expected 'array' got 'int'"
assert wrong_type_response['detail'].startswith("42 is not of type 'array'")

wrong_items = app_client.post('/v1.0/test_schema_list', headers=headers,
data=json.dumps([42])) # type: flask.Response
assert wrong_items.status_code == 400
assert wrong_items.content_type == 'application/problem+json'
wrong_items_response = json.loads(wrong_items.data.decode()) # type: dict
assert wrong_items_response['title'] == 'Bad Request'
assert wrong_items_response['detail'] == "Wrong type, expected 'string' got 'int'"
assert wrong_items_response['detail'].startswith("42 is not of type 'string'")


def test_schema_format(app):
Expand All @@ -411,7 +411,7 @@ def test_schema_format(app):
assert wrong_type.content_type == 'application/problem+json'
wrong_type_response = json.loads(wrong_type.data.decode()) # type: dict
assert wrong_type_response['title'] == 'Bad Request'
assert wrong_type_response['detail'] == "Invalid value, expected string in 'date-time' format"
assert "'xy' is not a 'date-time'" in wrong_type_response['detail']


def test_single_route(app):
Expand Down Expand Up @@ -444,11 +444,6 @@ def test_parameter_validation(app):

url = '/v1.0/test_parameter_validation'

for invalid_date in '', 'foo', '2015-01-01T12:00:00Z':
response = app_client.get(url, query_string={'date': invalid_date}) # type: flask.Response
assert response.status_code == 400
assert response.content_type == 'application/problem+json'

response = app_client.get(url, query_string={'date': '2015-08-26'}) # type: flask.Response
assert response.status_code == 200

Expand Down Expand Up @@ -549,4 +544,3 @@ 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

7 changes: 0 additions & 7 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,6 @@ def test_get_function_from_name_for_class_method():
assert function == connexion.app.App.common_error_handler


def test_validate_date():
assert not utils.validate_date('foo')
assert utils.validate_date('2015-07-31')
assert not utils.validate_date('2015-07-31T19:51:00Z')
assert utils.validate_date('9999-12-31')


def test_boolean():
assert utils.boolean('true')
assert not utils.boolean('false')
Loading

0 comments on commit 934e41d

Please sign in to comment.