Skip to content

Commit

Permalink
Merge fff5066 into 576c248
Browse files Browse the repository at this point in the history
  • Loading branch information
mauler committed Feb 17, 2016
2 parents 576c248 + fff5066 commit a4a7c0f
Show file tree
Hide file tree
Showing 4 changed files with 273 additions and 4 deletions.
82 changes: 81 additions & 1 deletion tests/func_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,39 @@ def delete(self):
self.db_conn


class FoobarHandler(requesthandlers.APIHandler):
""" No use_defaults defined, so it will raise errors normally
despite default key being declared in the schema.
"""
@schema.validate(
input_schema={
"type": "object",
"properties": {
"times": {'type': "integer", "default": 1},
},
"required": ['times'],
}
)
def post(self):
return self.body['times'] * "foobar"


class EchoContentHandler(requesthandlers.APIHandler):

@schema.validate(
input_schema={
"type": "object",
"properties": {
"title": {'type': "string"},
"published": {'type': "boolean", "default": False},
}
},
use_defaults=True
)
def post(self):
return self.body


class DBTestHandler(requesthandlers.APIHandler):
"""APIHandler for testing db_conn"""
def get(self):
Expand Down Expand Up @@ -100,17 +133,64 @@ class APIFunctionalTest(AsyncHTTPTestCase):
def get_app(self):
rts = routes.get_routes(helloworld)
rts += [
("/api/foobar", FoobarHandler),
("/api/echocontent", EchoContentHandler),
("/api/explodinghandler", ExplodingHandler),
("/api/notfoundhandler", NotFoundHandler),
("/views/someview", DummyView),
("/api/dbtest", DBTestHandler)
("/api/dbtest", DBTestHandler),
]
return application.Application(
routes=rts,
settings={"debug": True},
db_conn=None
)

def test_post_schema_with_default_but_use_defaults_false(self):
""" Test if defaul key will be used when use_defaults its set o False.
"""
r = self.fetch(
"/api/foobar",
method="POST",
body=jd({})
)
self.assertEqual(r.code, 400)

def test_post_use_defaults(self):
r = self.fetch(
"/api/echocontent",
method="POST",
body=jd({
"title": "Exciting News !",
})
)
self.assertEqual(r.code, 200)
self.assertEqual(
jl(r.body)["data"],
{
'title': "Exciting News !",
'published': False,
}
)

def test_post_use_defaults_no_need_of_default(self):
r = self.fetch(
"/api/echocontent",
method="POST",
body=jd({
"title": "Breaking News !",
"published": True,
})
)
self.assertEqual(r.code, 200)
self.assertEqual(
jl(r.body)["data"],
{
'title': "Breaking News !",
'published': True,
}
)

def test_synchronous_handler(self):
r = self.fetch(
"/api/helloworld"
Expand Down
95 changes: 95 additions & 0 deletions tests/test_schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import unittest

from tornado_json.schema import get_object_defaults
from tornado_json.schema import input_schema_clean
from tornado_json.schema import NoObjectDefaults


class TestSchemaMethods(unittest.TestCase):

def test_input_schema_clean_ignore_other_types(self):
self.assertEqual(input_schema_clean('ABC-123', {'type': "string"}),
"ABC-123")

def test_input_schema_clean_no_defaults(self):
self.assertEqual(input_schema_clean({}, {'type': "object"}),
{})

def test_input_schema_clean(self):
self.assertEqual(
input_schema_clean(
{},
{
'type': "object",
'properties': {
'published': {
'default': True,
'type': 'boolean',
},
}
}
),
{
'published': True,
}
)

def test_defaults_basic(self):
self.assertEqual(
get_object_defaults({
'type': 'object',
'properties': {
'title': {"type": 'string'},
'published': {"type": 'boolean', "default": True},
}
}),
{
'published': True,
}
)

def test_defaults_no_defaults(self):
with self.assertRaises(NoObjectDefaults):
get_object_defaults({
'type': 'object',
'properties': {
'title': {"type": 'string'},
}
})

def test_defaults_nested_object_default(self):
self.assertEqual(
get_object_defaults({
'type': 'object',
'properties': {
'title': {"type": 'string'},
'published': {"type": 'boolean', "default": True},
'driver_license': {
'default': {'category': "C"},
'type': 'object',
'properties': {
'category': {
"type": "string",
"maxLength": 1,
"minLength": 1,
},
'shipping_city': {
"type": "string",
"default": "Belo Horizonte",
},
}
}
}
}),
{
'published': True,
'driver_license': {
"category": "C",
"shipping_city": "Belo Horizonte",
}
}
)


if __name__ == '__main__':
unittest.main()
80 changes: 78 additions & 2 deletions tornado_json/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from functools import wraps

import jsonschema

import tornado.gen

from tornado_json.exceptions import APIError
Expand All @@ -13,18 +14,89 @@
from tornado.concurrent import Future
is_future = lambda x: isinstance(x, Future)

from tornado_json.utils import container
from tornado_json.utils import container, deep_update


class NoObjectDefaults(Exception):
""" Raised when a schema type object ({"type": "object"}) has no "default"
key and one of their properties also don't have a "default" key.
"""


def get_object_defaults(object_schema):
"""
Extracts default values dict (nested) from an type object schema.
:param object_schema: Schema type object
:type object_schema: dict
:returns: Nested dict with defaults values
"""
default = {}
for k, schema in object_schema.get('properties', {}).items():

if schema.get('type') == 'object':
if 'default' in schema:
default[k] = schema['default']

try:
object_defaults = get_object_defaults(schema)
except NoObjectDefaults:
if 'default' not in schema:
raise NoObjectDefaults
else:
if 'default' not in schema:
default[k] = {}

default[k].update(object_defaults)
else:
if 'default' in schema:
default[k] = schema['default']

if default:
return default

raise NoObjectDefaults


def input_schema_clean(input_, input_schema):
"""
Updates schema default values with input data.
:param input_: Input data
:type input_: dict
:param input_schema: Input schema
:type input_schema: dict
:returns: Nested dict with data (defaul values updated with input data)
"""
if input_schema.get('type') == 'object':
try:
defaults = get_object_defaults(input_schema)
except NoObjectDefaults:
pass
else:
return deep_update(defaults, input_)
return input_


def validate(input_schema=None, output_schema=None,
input_example=None, output_example=None,
format_checker=None, on_empty_404=False):
format_checker=None, on_empty_404=False,
use_defaults=False):
"""Parameterized decorator for schema validation
:type format_checker: jsonschema.FormatChecker or None
:type on_empty_404: bool
:param on_empty_404: If this is set, and the result from the
decorated method is a falsy value, a 404 will be raised.
:type use_defaults: bool
:param use_defaults: If this is set, will put 'default' keys
from schema to self.body (If schema type is object). Example:
{
'published': {'type': 'bool', 'default': False}
}
self.body will contains 'published' key with value False if no one comes
from request, also works with nested schemas.
"""
@container
def _validate(rh_method):
Expand Down Expand Up @@ -65,6 +137,10 @@ def _wrapper(self, *args, **kwargs):
raise jsonschema.ValidationError(
"Input is malformed; could not decode JSON object."
)

if use_defaults:
input_ = input_schema_clean(input_, input_schema)

# Validate the received input
jsonschema.validate(
input_,
Expand Down
20 changes: 19 additions & 1 deletion tornado_json/utils.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,26 @@
import types
import collections
import pyclbr
import types
from functools import wraps


def deep_update(source, overrides):
"""Update a nested dictionary or similar mapping.
Modify ``source`` in place.
:type source: collections.Mapping
:type overrides: collections.Mapping
"""
for key, value in overrides.items():
if isinstance(value, collections.Mapping) and value:
returned = deep_update(source.get(key, {}), value)
source[key] = returned
else:
source[key] = overrides[key]
return source


def container(dec):
"""Meta-decorator (for decorating decorators)
Expand Down

0 comments on commit a4a7c0f

Please sign in to comment.