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

Commit

Permalink
Organize resource code into a resource module
Browse files Browse the repository at this point in the history
  • Loading branch information
leplatrem committed Oct 22, 2015
1 parent 495c074 commit ff4f576
Show file tree
Hide file tree
Showing 14 changed files with 243 additions and 265 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ This document describes changes between each past release.
- Renamed internal backend classes for better consistency. Settings remain
unchanged (#).

- ``cliquet.schema`` is now deprecated, and was moved to a ``cliquet.resource``
module.

**Internal changes**

- Rework PostgreSQL backends to use composition instead of inheritance for the
Expand Down
25 changes: 13 additions & 12 deletions cliquet/resource.py → cliquet/resource/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,17 @@

from cliquet import logger
from cliquet import Service
from cliquet.collection import Collection, ProtectedCollection
from cliquet.viewset import ViewSet, ProtectedViewSet
from cliquet.errors import http_error, raise_invalid, send_alert, ERRORS
from cliquet.schema import ResourceSchema
from cliquet.storage import exceptions as storage_exceptions, Filter, Sort
from cliquet.utils import (
COMPARISON, classname, native_value, decode64, encode64, json,
current_service, encode_header, decode_header
)

from .model import Model, ProtectedModel
from .schema import ResourceSchema
from .viewset import ViewSet, ProtectedViewSet


def register(depth=1, **kwargs):
"""Ressource class decorator.
Expand Down Expand Up @@ -117,22 +118,22 @@ class BaseResource(object):
"""Default :class:`cliquet.viewset.ViewSet` class to use when the resource
is registered."""

default_collection = Collection
"""Default :class:`cliquet.collection.Collection` class to use for
default_model = Model
"""Default :class:`cliquet.collection.Model` class to use for
interacting the :module:`cliquet.storage` and :module:`cliquet.permission`
backends."""

mapping = ResourceSchema()
"""Schema to validate records."""

def __init__(self, request, context=None):
# Collections are isolated by user.
# Models are isolated by user.
parent_id = self.get_parent_id(request)

# Authentication to storage is transmitted as is (cf. cloud_storage).
auth = request.headers.get('Authorization')

self.collection = self.default_collection(
self.collection = self.default_model(
storage=request.registry.storage,
id_generator=request.registry.id_generator,
collection_id=classname(self),
Expand Down Expand Up @@ -179,7 +180,7 @@ def is_known_field(self, field):
#

def collection_get(self):
"""Collection ``GET`` endpoint: retrieve multiple records.
"""Model ``GET`` endpoint: retrieve multiple records.
:raises: :exc:`~pyramid:pyramid.httpexceptions.HTTPNotModified` if
``If-None-Match`` header is provided and collection not
Expand Down Expand Up @@ -226,7 +227,7 @@ def collection_get(self):
return self.postprocess(records)

def collection_post(self):
"""Collection ``POST`` endpoint: create a record.
"""Model ``POST`` endpoint: create a record.
If the new record conflicts against a unique field constraint, the
posted record is ignored, and the existing record is returned, with
Expand Down Expand Up @@ -267,7 +268,7 @@ def collection_post(self):
return self.postprocess(record)

def collection_delete(self):
"""Collection ``DELETE`` endpoint: delete multiple records.
"""Model ``DELETE`` endpoint: delete multiple records.
:raises:
:exc:`~pyramid:pyramid.httpexceptions.HTTPPreconditionFailed` if
Expand Down Expand Up @@ -907,7 +908,7 @@ class ProtectedResource(BaseResource):
"""Protected resources allow to set permissions on records, in order to
share their access or protect their modification.
"""
default_collection = ProtectedCollection
default_model = ProtectedModel
default_viewset = ProtectedViewSet
permissions = ('read', 'write')
"""List of allowed permissions names."""
Expand All @@ -919,7 +920,7 @@ def __init__(self, *args, **kwargs):
# the ``write`` ACE.
self.force_patch_update = True

# Required by the ProtectedCollection class.
# Required by the ProtectedModel class.
self.collection.permission = self.request.registry.permission
self.collection.current_principal = self.request.prefixed_userid
if self.context:
Expand Down
30 changes: 15 additions & 15 deletions cliquet/collection.py → cliquet/resource/model.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
class Collection(object):
class Model(object):
"""A collection stores and manipulate records in its attached storage.
It is not aware of HTTP environment nor protocol.
Expand Down Expand Up @@ -27,7 +27,7 @@ def __init__(self, storage, id_generator=None, collection_id='',
:param id_generator: an instance of id generator, used by storage
on record creation.
:param str name: the collection name
:param str collection_id: the collection id
:param str parent_id: the default parent id
"""
self.storage = storage
Expand Down Expand Up @@ -149,7 +149,7 @@ def create_record(self, record, parent_id=None, unique_fields=None):
.. code-block:: python
def create_record(self, record):
record = super(MyCollection, self).create_record(record)
record = super(MyModel, self).create_record(record)
idx = index.store(record)
record['index'] = idx
return record
Expand Down Expand Up @@ -180,9 +180,9 @@ def update_record(self, record, parent_id=None, unique_fields=None):
.. code-block:: python
def update_record(self, record, parent_id=None,unique_fields=None):
record = super(MyCollection, self).update_record(record,
parent_id,
unique_fields)
record = super(MyModel, self).update_record(record,
parent_id,
unique_fields)
subject = 'Record {} was changed'.format(record[self.id_field])
send_email(subject)
return record
Expand Down Expand Up @@ -213,7 +213,7 @@ def delete_record(self, record, parent_id=None):
.. code-block:: python
def delete_record(self, record):
deleted = super(MyCollection, self).delete_record(record)
deleted = super(MyModel, self).delete_record(record)
erase_media(record)
deleted['media'] = 0
return deleted
Expand All @@ -235,13 +235,13 @@ def delete_record(self, record):
auth=self.auth)


class ProtectedCollection(Collection):
class ProtectedModel(Model):
"""A protected collection interacts with the permission backend.
"""
permissions_field = '__permissions__'

def __init__(self, *args, **kwargs):
super(ProtectedCollection, self).__init__(*args, **kwargs)
super(ProtectedModel, self).__init__(*args, **kwargs)
# Permission backend.
self.permission = None
# Object permission id.
Expand All @@ -259,8 +259,8 @@ def _allow_write(self, perm_object_id):
def delete_records(self, filters=None, parent_id=None):
"""Delete permissions when collection records are deleted in bulk.
"""
deleted = super(ProtectedCollection, self).delete_records(filters,
parent_id)
deleted = super(ProtectedModel, self).delete_records(filters,
parent_id)
perm_ids = [self.get_permission_object_id(record_id=r[self.id_field])
for r in deleted]
self.permission.delete_object_permissions(*perm_ids)
Expand All @@ -269,7 +269,7 @@ def delete_records(self, filters=None, parent_id=None):
def get_record(self, record_id, parent_id=None):
"""Fetch current permissions and add them to returned record.
"""
record = super(ProtectedCollection, self).get_record(
record = super(ProtectedModel, self).get_record(
record_id, parent_id)
perm_object_id = self.get_permission_object_id(record_id)
permissions = self.permission.object_permissions(perm_object_id)
Expand All @@ -282,7 +282,7 @@ def create_record(self, record, parent_id=None, unique_fields=None):
The current principal is added to the owner (``write`` permission).
"""
permissions = record.pop(self.permissions_field, {})
record = super(ProtectedCollection, self).create_record(
record = super(ProtectedModel, self).create_record(
record, parent_id, unique_fields)

record_id = record[self.id_field]
Expand All @@ -303,7 +303,7 @@ def update_record(self, record, parent_id=None, unique_fields=None):
The current principal is added to the owner (``write`` permission).
"""
permissions = record.pop(self.permissions_field, {})
record = super(ProtectedCollection, self).update_record(
record = super(ProtectedModel, self).update_record(
record, parent_id, unique_fields)

record_id = record[self.id_field]
Expand All @@ -318,7 +318,7 @@ def update_record(self, record, parent_id=None, unique_fields=None):
def delete_record(self, record_id, parent_id=None):
"""Delete record and its associated permissions.
"""
record = super(ProtectedCollection, self).delete_record(
record = super(ProtectedModel, self).delete_record(
record_id, parent_id)

perm_object_id = self.get_permission_object_id(record_id)
Expand Down
159 changes: 159 additions & 0 deletions cliquet/resource/schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import colander
from colander import SchemaNode, String

from cliquet.utils import strip_whitespace, msec_time


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

class Options:
"""
Resource schema options.
This is meant to be overriden for changing values:
.. code-block:: python
class Product(ResourceSchema):
reference = colander.SchemaNode(colander.String())
class Options:
unique_fields = ('reference',)
"""
unique_fields = tuple()
"""Fields that must have unique values for the user collection.
During records creation and modification, a conflict error will be
raised if unicity is about to be violated.
"""

readonly_fields = tuple()
"""Fields that cannot be updated. Values for fields will have to be
provided either during record creation, through default values using
``missing`` attribute or implementing a custom logic in
:meth:`cliquet.resource.BaseResource.process_record`.
"""

preserve_unknown = False
"""Define if unknown fields should be preserved or not.
For example, in order to define a schema-less resource, in other words
a resource that will accept any form of record, the following schema
definition is enough:
.. code-block:: python
class SchemaLess(ResourceSchema):
class Options:
preserve_unknown = True
"""
def get_option(self, attr):
default_value = getattr(ResourceSchema.Options, attr)
return getattr(self.Options, attr, default_value)

def is_readonly(self, field):
"""Return True if specified field name is read-only.
:param str field: the field name in the schema
:returns: ``True`` if the specified field is read-only,
``False`` otherwise.
:rtype: bool
"""
return field in self.get_option("readonly_fields")

def schema_type(self, **kw):
if self.get_option("preserve_unknown") is True:
unknown = 'preserve'
else:
unknown = 'ignore'
return colander.Mapping(unknown=unknown)


class PermissionsSchema(colander.SchemaNode):
"""A permission mapping defines ACEs.
It has permission names as keys and principals as values.
::
{
"write": ["fxa:af3e077eb9f5444a949ad65aa86e82ff"],
"groups:create": ["fxa:70a9335eecfe440fa445ba752a750f3d"]
}
"""
def __init__(self, *args, **kwargs):
self.known_perms = kwargs.pop('permissions', tuple())
super(PermissionsSchema, self).__init__(*args, **kwargs)

def schema_type(self, **kw):
return colander.Mapping(unknown='preserve')

def deserialize(self, cstruct=colander.null):
# Start by deserializing a simple mapping.
permissions = super(PermissionsSchema, self).deserialize(cstruct)

# In case it is optional in parent schema.
if permissions in (colander.null, colander.drop):
return permissions

# Remove potential extra children from previous deserialization.
self.children = []
for perm in permissions.keys():
# If know permissions is limited, then validate inline.
if self.known_perms:
colander.OneOf(choices=self.known_perms)(self, perm)

# Add a String list child node with the name of ``perm``.
self.add(self._get_node_principals(perm))

# End up by deserializing a mapping whose keys are now known.
return super(PermissionsSchema, self).deserialize(permissions)

def _get_node_principals(self, perm):
principal = colander.SchemaNode(colander.String())
return colander.SchemaNode(colander.Sequence(), principal, name=perm)


class TimeStamp(colander.SchemaNode):
"""Basic integer schema field that can be set to current server timestamp
in milliseconds if no value is provided.
.. code-block:: python
class Book(ResourceSchema):
added_on = TimeStamp()
read_on = TimeStamp(auto_now=False, missing=-1)
"""
schema_type = colander.Integer

title = 'Epoch timestamp'
"""Default field title."""

auto_now = True
"""Set to current server timestamp (*milliseconds*) if not provided."""

missing = None
"""Default field value if not provided in record."""

def deserialize(self, cstruct=colander.null):
if cstruct is colander.null and self.auto_now:
cstruct = msec_time()
return super(TimeStamp, self).deserialize(cstruct)


class URL(SchemaNode):
"""String field representing a URL, with max length of 2048.
This is basically a shortcut for string field with
`~colander:colander.url`.
.. code-block:: python
class BookmarkSchema(ResourceSchema):
url = URL()
"""
schema_type = String
validator = colander.All(colander.url, colander.Length(min=1, max=2048))

def preparer(self, appstruct):
return strip_whitespace(appstruct)
2 changes: 1 addition & 1 deletion cliquet/viewset.py → cliquet/resource/viewset.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import colander

from cliquet import authorization
from cliquet.schema import PermissionsSchema
from cliquet.resource.schema import PermissionsSchema


class ViewSet(object):
Expand Down
Loading

0 comments on commit ff4f576

Please sign in to comment.