Skip to content
This repository has been archived by the owner on Aug 2, 2019. It is now read-only.

[WIP] Add Json schema support #195

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions daybed/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
RootFactory, DaybedAuthorizationPolicy, check_api_token,
)
from daybed.views.errors import forbidden_view
from daybed.renderers import GeoJSON
from daybed.renderers import GeoJSON, JSONSchema
from daybed.backends.exceptions import TokenNotFound


Expand Down Expand Up @@ -108,6 +108,7 @@ def add_default_accept(event):
config.add_subscriber(add_default_accept, NewRequest)

config.add_renderer('jsonp', JSONP(param_name='callback'))

config.add_renderer('geojson', GeoJSON())
config.add_renderer('jsonschema', JSONSchema())

return config.make_wsgi_app()
77 changes: 77 additions & 0 deletions daybed/renderers.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,86 @@
except ImportError:
from ordereddict import OrderedDict

from collections import defaultdict

from pyramid.renderers import JSONP


class JSONSchema(JSONP):
"""Renderer for JSON Schema"""

def __init__(self, *args, **kwargs):
self.fields = {
'int': lambda x: {'type': 'integer'},
'text': lambda x: {'type': 'string'},
'decimal': lambda x: {'type': 'number'},
'email': lambda x: {'type': 'string', 'format': 'email'},
'regex': lambda x: {'type': 'string', 'pattern': x['regex']},
'url': lambda x: {'type': 'string', 'format': 'uri'},
'enum': self.serialize_enum,
'range': self.serialize_range,
'datetime': self.serialize_datetime,
}
super(JSONSchema, self).__init__(*args, **kwargs)

def __call__(self, info):
def _get_field(field):
output_field = {
'description': field.get('label', field.get('name')),
}

if field['type'] in self.fields:
output_field.update(self.fields[field['type']](field))
else:
output_field['type'] = field['type']

return output_field

def _render(definition, system):
properties = defaultdict(dict)
required = []

for field in definition['fields']:
properties[field['name']] = _get_field(field)

if field['required']:
required.append(field['name'])

jsonschema = {
'$schema': 'http://json-schema.org/schema#',
'title': definition['title'],
'description': definition['description'],
'type': 'object',
'properties': properties,
'required': required
}
jsonp = super(JSONSchema, self).__call__(info)
return jsonp(jsonschema, system)

return _render

def serialize_enum(self, field):
pattern = '^%s$' % '|'.join(field['choices'])
return {
'type': 'string',
'pattern': pattern
}

def serialize_range(self, field):
return {
'type': 'integer',
'minimum': field['min'],
'maximum': field['max']
}

def serialize_datetime(self, field):
return {
'type': 'string',
'pattern': '(\d{4})-(\d{2})-(\d{2})T(\d{2})\:(\d{2})\:(\d{2})'
'[+-](\d{2})\:(\d{2})'
}


class GeoJSON(JSONP):
def __call__(self, info):
def _render(value, system):
Expand Down
159 changes: 143 additions & 16 deletions daybed/tests/test_renderers.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,149 @@
import mock
from pyramid import testing

from daybed.renderers import GeoJSON
from daybed.renderers import GeoJSON, JSONSchema
from .support import BaseWebTest, force_unicode


class TestGeoJSONRenderer(BaseWebTest):
class BaseRendererTest(BaseWebTest):
def _build_request(self, name=None):
request = testing.DummyRequest()
if name is not None:
request.matchdict['model_id'] = name
request.db = self.db
return request

def _rendered(self, data, request=None):
request = request or self._build_request()
system = {'request': request}
return self.renderer(data, system)


class TestJSONSchemaRenderer(BaseRendererTest):

def setUp(self):
super(TestJSONSchemaRenderer, self).setUp()
self.jsonschema = JSONSchema()
self.renderer = self.jsonschema(None)

def _get_definition(self, type, **args):
field = {
"name": "field",
"type": type,
"required": False
}
field.update(args)
return {
"title": "simple",
"description": "One field",
"fields": [field]
}

def _get_rendered_field(self, type, **args):
return json.loads(
self._rendered(self._get_definition(type, **args))
)['properties']['field']

def test_int_type(self):
self.assertEquals(
self._get_rendered_field('int')['type'],
'integer'
)

def test_test_type(self):
self.assertEquals(
self._get_rendered_field('text')['type'],
'string'
)

def test_bool_type(self):
self.assertEquals(
self._get_rendered_field('boolean')['type'],
'boolean'
)

def test_regex_type(self):
self.assertEquals(
self._get_rendered_field('regex', regex='^[abc]$'),
{
'description': 'field',
'type': 'string',
'pattern': '^[abc]$'
}
)

def test_email_type(self):
self.assertEquals(
self._get_rendered_field('email'),
{
'description': 'field',
'type': 'string',
'format': 'email'
}
)

def test_datetime_type(self):
self.assertEquals(
self._get_rendered_field('datetime'),
{
'description': 'field',
'type': 'string',
'pattern': '(\d{4})-(\d{2})-(\d{2})T(\d{2})\:(\d{2})\:(\d{2})'
'[+-](\d{2})\:(\d{2})'
}
)

def test_anyof_type(self):
pass

def test_oneof_type(self):
pass

def test_url(self):
self.assertEquals(
self._get_rendered_field('url'),
{
'description': 'field',
'type': 'string',
'format': 'uri'
}
)

def test_decimal(self):
self.assertEquals(
self._get_rendered_field('decimal')['type'],
'number'
)

def test_enum_type(self):
self.assertEquals(
self._get_rendered_field('enum', choices=('foo', 'bar')),
{
'description': 'field',
'type': 'string',
'pattern': '^foo|bar$'
}
)

def test_list(self):
pass

def test_choices(self):
pass

def test_range(self):
self.assertEquals(
self._get_rendered_field('range', min=1, max=10),
{
'description': 'field',
'type': 'integer',
'minimum': 1,
'maximum': 10
}
)


class TestGeoJSONRenderer(BaseRendererTest):

def setUp(self):
super(TestGeoJSONRenderer, self).setUp()
Expand Down Expand Up @@ -40,17 +178,6 @@ def setUp(self):
def assertJSONEqual(self, a, b):
self.assertDictEqual(json.loads(a), force_unicode(b))

def _build_request(self, name=None):
request = testing.DummyRequest()
request.matchdict['model_id'] = name or self.name
request.db = self.db
return request

def _rendered(self, data, request=None):
request = request or self._build_request()
system = {'request': request}
return self.renderer(data, system)

def test_geojson_renderer_with_empty_collection(self):
geojson = self._rendered({'records': []})
self.assertJSONEqual(geojson, {'type': 'FeatureCollection',
Expand Down Expand Up @@ -87,7 +214,7 @@ def test_geojson_renderer_renames_only_first_geometry_field(self):
'coordinates': [[0, 0], [1, 1]]})

def test_geojson_renderer_works_with_jsonp(self):
request = self._build_request()
request = self._build_request(name=self.name)
request.GET['callback'] = 'func'
geojsonp = self._rendered({'records': [{'location': [0, 0]}]}, request)
self.assertIn('func(', geojsonp)
Expand All @@ -107,7 +234,7 @@ def test_geojson_renderer_works_with_geojson_field(self):
]})

def test_geojson_renderer_serves_with_official_mimetype(self):
request = self._build_request()
request = self._build_request(name=self.name)
response = mock.MagicMock()
response.default_content_type = response.content_type = ''
request.response = response
Expand All @@ -116,7 +243,7 @@ def test_geojson_renderer_serves_with_official_mimetype(self):
'application/vnd.geo+json')

def test_geojson_renderer_does_not_override_existing_mimetype(self):
request = self._build_request()
request = self._build_request(name=self.name)
response = mock.MagicMock()
response.content_type = 'application/octet-stream'
request.response = response
Expand Down
24 changes: 24 additions & 0 deletions daybed/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,30 @@ def test_definition_retrieval(self):
definition = force_unicode(MODEL_DEFINITION['definition'])
self.assertDictEqual(resp.json, definition)

def test_definition_retrieval_as_json_schema(self):
self.app.put_json('/models/test',
MODEL_DEFINITION,
headers=self.headers)

self.headers['Accept'] = 'application/schema+json'

resp = self.app.get('/models/test/definition',
headers=self.headers)

self.assertDictEqual(resp.json, {
'$schema': 'http://json-schema.org/schema#',
'title': 'simple',
'description': u'One optional field',
'type': 'object',
'properties': {
'age': {
'description': 'age',
'type': 'integer',
}
},
'required': []
})

def test_post_model_definition_with_records(self):
model = MODEL_DEFINITION.copy()
model['records'] = [MODEL_RECORD, MODEL_RECORD]
Expand Down
3 changes: 3 additions & 0 deletions daybed/views/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@
cors_origins=('*',))


@definition.get(permission='get_definition',
accept='application/schema+json',
renderer='jsonschema')
@definition.get(permission='get_definition')
def get_definition(request):
"""Retrieves a model definition."""
Expand Down