Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Split request schemas for each method/endpoint type #1047

Merged
merged 15 commits into from
Feb 1, 2017
Merged
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
3 changes: 2 additions & 1 deletion CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ This document describes changes between each past release.
in a separate view (#1031).
- Upgraded to Kinto-Admin 1.8.1
- Configure the Kinto Admin auth methods from the server configuration (#1042)

- Request schemas (including validation and deserialization) are now isolated by method
and endpoint type (#1047).

5.3.0 (2017-01-20)
------------------
Expand Down
133 changes: 116 additions & 17 deletions kinto/core/resource/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
from kinto.core.utils import strip_whitespace, msec_time, decode_header, native_value


# Resource related schemas


class ResourceSchema(colander.MappingSchema):
"""Base resource schema, with *Cliquet* specific built-in options."""

Expand Down Expand Up @@ -109,6 +112,9 @@ def _get_node_principals(self, perm):
missing=colander.drop)


# Generic schema nodes


class TimeStamp(colander.SchemaNode):
"""Basic integer schema field that can be set to current server timestamp
in milliseconds if no value is provided.
Expand Down Expand Up @@ -215,36 +221,41 @@ def deserialize(self, cstruct=colander.null):
return int(param[1:-1])


# Header schemas


class HeaderSchema(colander.MappingSchema):
"""Schema used for validating and deserializing request headers. """
"""Base schema used for validating and deserializing request headers. """

def response_behavior_validator():
return colander.OneOf(['full', 'light', 'diff'])
missing = colander.drop

if_match = HeaderQuotedInteger(name='If-Match')
if_none_match = HeaderQuotedInteger(name='If-None-Match')
response_behaviour = HeaderField(colander.String(), name='Response-Behavior',
validator=response_behavior_validator())

@staticmethod
def schema_type():
return colander.Mapping(unknown='preserve')


class PatchHeaderSchema(HeaderSchema):
"""Header schema used with PATCH requests."""

def response_behavior_validator():
return colander.OneOf(['full', 'light', 'diff'])

response_behaviour = HeaderField(colander.String(), name='Response-Behavior',
validator=response_behavior_validator())


# Querystring schemas


class QuerySchema(colander.MappingSchema):
"""
Schema used for validating and deserializing querystrings. It will include
and try to guess the type of unknown fields (field filters) on deserialization.
"""

_limit = QueryField(colander.Integer())
_fields = FieldList()
_sort = FieldList()
_token = QueryField(colander.String())
_since = QueryField(colander.Integer())
_to = QueryField(colander.Integer())
_before = QueryField(colander.Integer())
last_modified = QueryField(colander.Integer())
missing = colander.drop

@staticmethod
def schema_type():
Expand Down Expand Up @@ -278,6 +289,64 @@ def deserialize(self, cstruct=colander.null):
return values


class CollectionQuerySchema(QuerySchema):
"""Querystring schema used with collections."""

_limit = QueryField(colander.Integer())
_sort = FieldList()
_token = QueryField(colander.String())
_since = QueryField(colander.Integer())
_to = QueryField(colander.Integer())
_before = QueryField(colander.Integer())
id = QueryField(colander.String())
last_modified = QueryField(colander.Integer())


class RecordGetQuerySchema(QuerySchema):
"""Querystring schema for GET record requests."""

_fields = FieldList()


class CollectionGetQuerySchema(CollectionQuerySchema):
"""Querystring schema for GET collection requests."""

_fields = FieldList()


# Body Schemas


class RecordSchema(colander.MappingSchema):

@colander.deferred
def data(node, kwargs):
data = kwargs.get('data')
if data:
# Check if empty record is allowed.
# (e.g every schema fields have defaults)
try:
data.deserialize({})
except colander.Invalid:
pass
else:
data.default = {}
data.missing = colander.drop
return data

@colander.deferred
def permissions(node, kwargs):
def get_perms(node, kwargs):
return kwargs.get('permissions')
# Set if node is provided, else keep deferred. This allows binding the body
# on Resource first and bind permissions later if using SharableResource.
return get_perms(node, kwargs) or colander.deferred(get_perms)

@staticmethod
def schema_type():
return colander.Mapping(unknown='raise')


class JsonPatchOperationSchema(colander.MappingSchema):
"""Single JSON Patch Operation."""

Expand Down Expand Up @@ -305,12 +374,42 @@ class JsonPatchBodySchema(colander.SequenceSchema):
operations = JsonPatchOperationSchema(missing=colander.drop)


# Request schemas


class RequestSchema(colander.MappingSchema):
"""Baseline schema for kinto requests."""
"""Base schema for kinto requests."""

@colander.deferred
def header(node, kwargs):
return kwargs.get('header')

@colander.deferred
def querystring(node, kwargs):
return kwargs.get('querystring')

header = HeaderSchema(missing=colander.drop)
querystring = QuerySchema(missing=colander.drop)
def after_bind(self, node, kw):
# Set default bindings
if not self.get('header'):
self['header'] = HeaderSchema()
if not self.get('querystring'):
self['querystring'] = QuerySchema()


class PayloadRequestSchema(RequestSchema):
"""Base schema for methods that use a JSON request body."""

@colander.deferred
def body(node, kwargs):
def get_body(node, kwargs):
return kwargs.get('body')
# Set if node is provided, else keep deferred (and allow bindind later)
return get_body(node, kwargs) or colander.deferred(get_body)


class JsonPatchRequestSchema(RequestSchema):
"""JSON Patch (application/json-patch+json) request schema."""

body = JsonPatchBodySchema()
querystring = QuerySchema()
header = PatchHeaderSchema()
71 changes: 30 additions & 41 deletions kinto/core/resource/viewset.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@
from pyramid.settings import asbool

from kinto.core import authorization
from kinto.core.resource.schema import PermissionsSchema, RequestSchema
from kinto.core.resource.schema import (PermissionsSchema, RequestSchema, PayloadRequestSchema,
PatchHeaderSchema, CollectionQuerySchema,
CollectionGetQuerySchema, RecordGetQuerySchema,
RecordSchema)


CONTENT_TYPES = ["application/json"]
Expand Down Expand Up @@ -47,7 +50,6 @@ class ViewSet(object):

collection_methods = ('GET', 'POST', 'DELETE')
record_methods = ('GET', 'PUT', 'PATCH', 'DELETE')
validate_schema_for = ('POST', 'PUT', 'PATCH')

readonly_methods = ('GET', 'OPTIONS', 'HEAD')

Expand All @@ -60,28 +62,38 @@ class ViewSet(object):
default_arguments = {
'permission': authorization.PRIVATE,
'accept': CONTENT_TYPES,
'schema': RequestSchema(),
}

default_post_arguments = {
"content_type": CONTENT_TYPES,
'schema': PayloadRequestSchema(),
}

default_put_arguments = {
"content_type": CONTENT_TYPES,
'schema': PayloadRequestSchema(),
}

default_patch_arguments = {
"content_type": CONTENT_TYPES + PATCH_CONTENT_TYPES
"content_type": CONTENT_TYPES + PATCH_CONTENT_TYPES,
'schema': PayloadRequestSchema().bind(header=PatchHeaderSchema()),
}

default_collection_arguments = {}
default_collection_arguments = {
'schema': RequestSchema().bind(querystring=CollectionQuerySchema()),
}
collection_get_arguments = {
'schema': RequestSchema().bind(querystring=CollectionGetQuerySchema()),
'cors_headers': ('Next-Page', 'Total-Records', 'Last-Modified', 'ETag',
'Cache-Control', 'Expires', 'Pragma')
}

collection_post_arguments = {
'schema': PayloadRequestSchema(),
}
default_record_arguments = {}
record_get_arguments = {
'schema': RequestSchema().bind(querystring=RecordGetQuerySchema()),
'cors_headers': ('Last-Modified', 'ETag',
'Cache-Control', 'Expires', 'Pragma')
}
Expand Down Expand Up @@ -118,14 +130,11 @@ def get_view_arguments(self, endpoint_type, resource_cls, method):
endpoint_args = getattr(self, by_method, {})
args.update(**endpoint_args)

if method.lower() in map(str.lower, self.validate_schema_for):
schema = RequestSchema()
record_schema = self.get_record_schema(resource_cls, method)
record_schema.name = 'body'
schema.add(record_schema)
args['schema'] = schema
else:
args['schema'] = RequestSchema()
request_schema = args.get('schema', RequestSchema())
record_schema = self.get_record_schema(resource_cls, method)
request_schema = request_schema.bind(body=record_schema)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍


args['schema'] = request_schema

validators = args.get('validators', [])
validators.append(colander_validator)
Expand All @@ -145,9 +154,9 @@ def get_record_schema(self, resource_cls, method):
warnings.warn(message, DeprecationWarning)
resource_schema = resource_cls.mapping.__class__

payload_schema = StrictSchema()
payload_schema.add(resource_schema(name='data'))
return payload_schema
record_schema = RecordSchema().bind(data=resource_schema())

return record_schema

def get_view(self, endpoint_type, method):
"""Return the view method name located on the resource object, for the
Expand Down Expand Up @@ -214,32 +223,12 @@ class ShareableViewSet(ViewSet):
def get_record_schema(self, resource_cls, method):
"""Return the Cornice schema for the given method.
"""
if method.lower() == 'patch':
resource_schema = SimpleSchema
else:
resource_schema = resource_cls.schema
if hasattr(resource_cls, 'mapping'):
message = "Resource `mapping` is deprecated, use `schema`"
warnings.warn(message, DeprecationWarning)
resource_schema = resource_cls.mapping.__class__

try:
# Check if empty record is allowed.
# (e.g every schema fields have defaults)
resource_schema().deserialize({})
except colander.Invalid:
schema_kw = dict(missing=colander.required)
else:
schema_kw = dict(default={}, missing=colander.drop)

record_schema = super(ShareableViewSet, self).get_record_schema(resource_cls, method)
allowed_permissions = resource_cls.permissions

payload_schema = StrictSchema()
payload_schema.add(resource_schema(name='data', **schema_kw))
payload_schema.add(PermissionsSchema(name='permissions',
missing=colander.drop,
permissions=allowed_permissions))
return payload_schema
permissions = PermissionsSchema(name='permissions', missing=colander.drop,
permissions=allowed_permissions)
record_schema = record_schema.bind(permissions=permissions)
return record_schema

def get_view_arguments(self, endpoint_type, resource_cls, method):
args = super(ShareableViewSet, self).get_view_arguments(endpoint_type,
Expand Down
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
appdirs==1.4.0
colander==1.3.1
colander==1.3.2
colorama==0.3.7
contextlib2==0.5.4
cornice==2.4.0
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ def read_file(filename):
installed_with_pypy = platform.python_implementation() == 'PyPy'

REQUIREMENTS = [
'colander',
'colander >= 1.3.2',
'colorama',
'cornice >= 2.4',
'jsonschema',
Expand Down
Loading