Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse files

Sphinx docs, examples, lots of refactoring

  • Loading branch information...
commit 4100242fa2395bef8db0c5ffbab6f5d0cf95301d 1 parent 9979903
tom christie tom@tomchristie.com authored
Showing with 2,240 additions and 2 deletions.
  1. +1 −1  .hgignore
  2. +4 −1 docs/conf.py
  3. +5 −0 docs/emitters.rst
  4. +14 −0 docs/index.rst
  5. +5 −0 docs/modelresource.rst
  6. +5 −0 docs/parsers.rst
  7. +125 −0 docs/resource.rst
  8. +5 −0 docs/response.rst
  9. 0  examples/__init__.py
  10. 0  examples/blogpost/__init__.py
  11. +68 −0 examples/blogpost/models.py
  12. +163 −0 examples/blogpost/tests.py
  13. +11 −0 examples/blogpost/urls.py
  14. +63 −0 examples/blogpost/views.py
  15. +20 −0 examples/initial_data.json
  16. +11 −0 examples/manage.py
  17. 0  examples/objectstore/__init__.py
  18. +3 −0  examples/objectstore/models.py
  19. +23 −0 examples/objectstore/tests.py
  20. +6 −0 examples/objectstore/urls.py
  21. +54 −0 examples/objectstore/views.py
  22. +96 −0 examples/settings.py
  23. +11 −0 examples/urls.py
  24. 0  flywheel/__init__.py
  25. +118 −0 flywheel/emitters.py
  26. +393 −0 flywheel/modelresource.py
  27. +80 −0 flywheel/parsers.py
  28. +436 −0 flywheel/resource.py
  29. +126 −0 flywheel/response.py
  30. +96 −0 flywheel/templates/emitter.html
  31. +8 −0 flywheel/templates/emitter.txt
  32. +3 −0  flywheel/templates/emitter.xhtml
  33. 0  flywheel/templatetags/__init__.py
  34. +17 −0 flywheel/templatetags/add_query_param.py
  35. +100 −0 flywheel/templatetags/urlize_quoted_links.py
  36. +170 −0 flywheel/utils.py
2  .hgignore
View
@@ -3,7 +3,7 @@ syntax: glob
*.pyc
*.db
env
-cache
+docs-build
html
.project
.pydevproject
5 docs/conf.py
View
@@ -13,7 +13,9 @@
import sys, os
-sys.path.append(os.path.join(os.path.dirname(os.path.dirname(__file__)), 'src'))
+sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
+sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(__file__)), 'flywheel'))
+sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(__file__)), 'examples'))
import settings
from django.core.management import setup_environ
setup_environ(settings)
@@ -57,6 +59,7 @@
# The full version, including alpha/beta/rc tags.
release = '0.1'
+autodoc_member_order='bysource'
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
#language = None
5 docs/emitters.rst
View
@@ -0,0 +1,5 @@
+Emitters
+========
+
+.. automodule:: emitters
+ :members:
14 docs/index.rst
View
@@ -3,6 +3,20 @@ FlyWheel Documentation
This is the online documentation for FlyWheel - A REST framework for Django.
+* Clean, simple, class-based views for Resources.
+* Easy input validation using Forms and ModelForms.
+* Self describing APIs, with HTML and Plain Text outputs.
+
+.. toctree::
+ :maxdepth: 1
+
+ resource
+ modelresource
+ parsers
+ emitters
+ response
+
+
Indices and tables
------------------
5 docs/modelresource.rst
View
@@ -0,0 +1,5 @@
+ModelResource
+=============
+
+.. automodule:: modelresource
+ :members:
5 docs/parsers.rst
View
@@ -0,0 +1,5 @@
+Parsers
+=======
+
+.. automodule:: parsers
+ :members:
125 docs/resource.rst
View
@@ -0,0 +1,125 @@
+:mod:`resource`
+===============
+
+The :mod:`resource` module is the core of FlyWheel. It provides the :class:`Resource` base class which handles incoming HTTP requests and maps them to method calls, performing authentication, input deserialization, input validation, output serialization.
+
+Resources are created by sublassing :class:`Resource`, setting a number of class attributes, and overriding one or more methods.
+
+:class:`Resource` class attributes
+----------------------------------
+
+The following class attributes determine the behavior of the Resource and are intended to be overridden.
+
+.. attribute:: Resource.allowed_methods
+
+ A list of the HTTP methods that the Resource supports.
+ HTTP requests to the resource that do not map to an allowed operation will result in a 405 Method Not Allowed response.
+
+ Default: ``('GET',)``
+
+.. attribute:: Resource.anon_allowed_methods
+
+ A list of the HTTP methods that the Resource supports for unauthenticated users.
+ Unauthenticated HTTP requests to the resource that do not map to an allowed operation will result in a 405 Method Not Allowed response.
+
+ Default: ``()``
+
+.. attribute:: Resource.emitters
+
+ Lists the set of emitters that the Resource supports. This determines which media types the resource can serialize it's output to. Clients can specify which media types they accept using standard HTTP content negotiation via the Accept header. (See `RFC 2616 - Sec 14.1 <http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html>`_) Clients can also override this standard content negotiation by specifying a `_format` ...
+
+ The :mod:`emitters` module provides the :class:`BaseEmitter` class and a set of default emitters, including emitters for JSON and XML, as well as emitters for HTML and Plain Text which provide for a self documenting API.
+
+ The ordering of the Emitters is important as it determines an order of preference.
+
+ Default: ``(emitters.JSONEmitter, emitters.DocumentingHTMLEmitter, emitters.DocumentingXHTMLEmitter, emitters.DocumentingPlainTextEmitter, emitters.XMLEmitter)``
+
+.. attribute:: Resource.parsers
+
+ Lists the set of parsers that the Resource supports. This determines which media types the resource can accept as input for incoming HTTP requests. (Typically PUT and POST requests).
+
+ The ordering of the Parsers may be considered informative of preference but is not used ...
+
+ Default: ``(parsers.JSONParser, parsers.XMLParser, parsers.FormParser)``
+
+.. attribute:: Resource.form
+
+ If not None, this attribute should be a Django form which will be used to validate any request data.
+ This attribute is typically only used for POST or PUT requests to the resource.
+
+ Deafult: ``None``
+
+.. attribute:: Resource.callmap
+
+ Maps HTTP methods to function calls on the :class:`Resource`. It may be overridden in order to add support for other HTTP methods such as HEAD, OPTIONS and PATCH, or in order to map methods to different function names, for example to use a more `CRUD <http://en.wikipedia.org/wiki/Create,_read,_update_and_delete>`_ like style.
+
+ Default: ``{ 'GET': 'get', 'POST': 'post', 'PUT': 'put', 'DELETE': 'delete' }``
+
+
+:class:`Resource` methods
+-------------------------
+
+.. method:: Resource.get
+.. method:: Resource.post
+.. method:: Resource.put
+.. method:: Resource.delete
+.. method:: Resource.authenticate
+.. method:: Resource.reverse
+
+:class:`Resource` properties
+----------------------------
+
+.. method:: Resource.name
+.. method:: Resource.description
+.. method:: Resource.default_emitter
+.. method:: Resource.default_parser
+.. method:: Resource.emitted_media_types
+.. method:: Resource.parsed_media_types
+
+:class:`Resource` reserved parameters
+-------------------------------------
+
+.. attribute:: Resource.ACCEPT_QUERY_PARAM
+
+ If set, allows the default `Accept:` header content negotiation to be bypassed by setting the requested media type in a query parameter on the URL. This can be useful if it is necessary to be able to hyperlink to a given format on the Resource using standard HTML.
+
+ Set to None to disable, or to another string value to use another name for the reserved URL query parameter.
+
+ Default: ``_accept``
+
+.. attribute:: Resource.METHOD_PARAM
+
+ If set, allows for PUT and DELETE requests to be tunneled on form POST operations, by setting a (typically hidden) form field with the method name. This allows standard HTML forms to perform method requests which would otherwise `not be supported <http://dev.w3.org/html5/spec/Overview.html#attr-fs-method>`_
+
+ Set to None to disable, or to another string value to use another name for the reserved form field.
+
+ Default: ``_method``
+
+.. attribute:: Resource.CONTENTTYPE_PARAM
+
+ Used together with :attr:`CONTENT_PARAM`.
+
+ If set, allows for arbitrary content types to be tunneled on form POST operations, by setting a form field with the content type. This allows standard HTML forms to perform requests with content types other those `supported by default <http://dev.w3.org/html5/spec/Overview.html#attr-fs-enctype>`_ (ie. `application/x-www-form-urlencoded`, `multipart/form-data`, and `text-plain`)
+
+ Set to None to disable, or to another string value to use another name for the reserved form field.
+
+ Default: ``_contenttype``
+
+.. attribute:: Resource.CONTENT_PARAM
+
+ Used together with :attr:`CONTENTTYPE_PARAM`.
+
+ Set to None to disable, or to another string value to use another name for the reserved form field.
+
+ Default: ``_content``
+
+.. attribute:: Resource.CSRF_PARAM
+
+ The name used in Django's (typically hidden) form field for `CSRF Protection <http://docs.djangoproject.com/en/dev/ref/contrib/csrf/>`_.
+
+ Setting to None does not disable Django's CSRF middleware, but it does mean that the field name will not be treated as reserved by FlyWheel, so for example the default :class:`FormParser` will return fields with this as part of the request content, rather than ignoring them.
+
+ Default:: ``csrfmiddlewaretoken``
+
+reserved params
+internal methods
5 docs/response.rst
View
@@ -0,0 +1,5 @@
+Response
+========
+
+.. automodule:: response
+ :members:
0  examples/__init__.py
View
No changes.
0  examples/blogpost/__init__.py
View
No changes.
68 examples/blogpost/models.py
View
@@ -0,0 +1,68 @@
+from django.db import models
+from django.template.defaultfilters import slugify
+import uuid
+
+def uuid_str():
+ return str(uuid.uuid1())
+
+
+RATING_CHOICES = ((0, 'Awful'),
+ (1, 'Poor'),
+ (2, 'OK'),
+ (3, 'Good'),
+ (4, 'Excellent'))
+
+class BlogPost(models.Model):
+ key = models.CharField(primary_key=True, max_length=64, default=uuid_str, editable=False)
+ title = models.CharField(max_length=128)
+ content = models.TextField()
+ created = models.DateTimeField(auto_now_add=True)
+ slug = models.SlugField(editable=False, default='')
+
+ class Meta:
+ ordering = ('created',)
+
+ @models.permalink
+ def get_absolute_url(self):
+ return ('blogpost.views.BlogPostInstance', (), {'key': self.key})
+
+ @property
+ @models.permalink
+ def comments_url(self):
+ """Link to a resource which lists all comments for this blog post."""
+ return ('blogpost.views.CommentList', (), {'blogpost_id': self.key})
+
+ @property
+ @models.permalink
+ def comment_url(self):
+ """Link to a resource which can create a comment for this blog post."""
+ return ('blogpost.views.CommentCreator', (), {'blogpost_id': self.key})
+
+ def __unicode__(self):
+ return self.title
+
+ def save(self, *args, **kwargs):
+ self.slug = slugify(self.title)
+ super(self.__class__, self).save(*args, **kwargs)
+
+
+class Comment(models.Model):
+ blogpost = models.ForeignKey(BlogPost, editable=False, related_name='comments')
+ username = models.CharField(max_length=128)
+ comment = models.TextField()
+ rating = models.IntegerField(blank=True, null=True, choices=RATING_CHOICES, help_text='How did you rate this post?')
+ created = models.DateTimeField(auto_now_add=True)
+
+ class Meta:
+ ordering = ('created',)
+
+ @models.permalink
+ def get_absolute_url(self):
+ return ('blogpost.views.CommentInstance', (), {'blogpost': self.blogpost.key, 'id': self.id})
+
+ @property
+ @models.permalink
+ def blogpost_url(self):
+ """Link to the blog post resource which this comment corresponds to."""
+ return ('blogpost.views.BlogPostInstance', (), {'key': self.blogpost.key})
+
163 examples/blogpost/tests.py
View
@@ -0,0 +1,163 @@
+"""Test a range of REST API usage of the example application.
+"""
+
+from django.test import TestCase
+from django.core.urlresolvers import reverse
+from blogpost import views
+#import json
+#from rest.utils import xml2dict, dict2xml
+
+
+class AcceptHeaderTests(TestCase):
+ """Test correct behaviour of the Accept header as specified by RFC 2616:
+
+ http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.1"""
+
+ def assert_accept_mimetype(self, mimetype, expect=None):
+ """Assert that a request with given mimetype in the accept header,
+ gives a response with the appropriate content-type."""
+ if expect is None:
+ expect = mimetype
+
+ resp = self.client.get(reverse(views.RootResource), HTTP_ACCEPT=mimetype)
+
+ self.assertEquals(resp['content-type'], expect)
+
+
+ def test_accept_json(self):
+ """Ensure server responds with Content-Type of JSON when requested."""
+ self.assert_accept_mimetype('application/json')
+
+ def test_accept_xml(self):
+ """Ensure server responds with Content-Type of XML when requested."""
+ self.assert_accept_mimetype('application/xml')
+
+ def test_accept_json_when_prefered_to_xml(self):
+ """Ensure server responds with Content-Type of JSON when it is the client's prefered choice."""
+ self.assert_accept_mimetype('application/json;q=0.9, application/xml;q=0.1', expect='application/json')
+
+ def test_accept_xml_when_prefered_to_json(self):
+ """Ensure server responds with Content-Type of XML when it is the client's prefered choice."""
+ self.assert_accept_mimetype('application/json;q=0.1, application/xml;q=0.9', expect='application/xml')
+
+ def test_default_json_prefered(self):
+ """Ensure server responds with JSON in preference to XML."""
+ self.assert_accept_mimetype('application/json,application/xml', expect='application/json')
+
+ def test_accept_generic_subtype_format(self):
+ """Ensure server responds with an appropriate type, when the subtype is left generic."""
+ self.assert_accept_mimetype('text/*', expect='text/html')
+
+ def test_accept_generic_type_format(self):
+ """Ensure server responds with an appropriate type, when the type and subtype are left generic."""
+ self.assert_accept_mimetype('*/*', expect='application/json')
+
+ def test_invalid_accept_header_returns_406(self):
+ """Ensure server returns a 406 (not acceptable) response if we set the Accept header to junk."""
+ resp = self.client.get(reverse(views.RootResource), HTTP_ACCEPT='invalid/invalid')
+ self.assertNotEquals(resp['content-type'], 'invalid/invalid')
+ self.assertEquals(resp.status_code, 406)
+
+ def test_prefer_specific_over_generic(self): # This test is broken right now
+ """More specific accept types have precedence over less specific types."""
+ self.assert_accept_mimetype('application/xml, */*', expect='application/xml')
+ self.assert_accept_mimetype('*/*, application/xml', expect='application/xml')
+
+
+class AllowedMethodsTests(TestCase):
+ """Basic tests to check that only allowed operations may be performed on a Resource"""
+
+ def test_reading_a_read_only_resource_is_allowed(self):
+ """GET requests on a read only resource should default to a 200 (OK) response"""
+ resp = self.client.get(reverse(views.RootResource))
+ self.assertEquals(resp.status_code, 200)
+
+ def test_writing_to_read_only_resource_is_not_allowed(self):
+ """PUT requests on a read only resource should default to a 405 (method not allowed) response"""
+ resp = self.client.put(reverse(views.RootResource), {})
+ self.assertEquals(resp.status_code, 405)
+#
+# def test_reading_write_only_not_allowed(self):
+# resp = self.client.get(reverse(views.WriteOnlyResource))
+# self.assertEquals(resp.status_code, 405)
+#
+# def test_writing_write_only_allowed(self):
+# resp = self.client.put(reverse(views.WriteOnlyResource), {})
+# self.assertEquals(resp.status_code, 200)
+#
+#
+#class EncodeDecodeTests(TestCase):
+# def setUp(self):
+# super(self.__class__, self).setUp()
+# self.input = {'a': 1, 'b': 'example'}
+#
+# def test_encode_form_decode_json(self):
+# content = self.input
+# resp = self.client.put(reverse(views.WriteOnlyResource), content)
+# output = json.loads(resp.content)
+# self.assertEquals(self.input, output)
+#
+# def test_encode_json_decode_json(self):
+# content = json.dumps(self.input)
+# resp = self.client.put(reverse(views.WriteOnlyResource), content, 'application/json')
+# output = json.loads(resp.content)
+# self.assertEquals(self.input, output)
+#
+# #def test_encode_xml_decode_json(self):
+# # content = dict2xml(self.input)
+# # resp = self.client.put(reverse(views.WriteOnlyResource), content, 'application/json', HTTP_ACCEPT='application/json')
+# # output = json.loads(resp.content)
+# # self.assertEquals(self.input, output)
+#
+# #def test_encode_form_decode_xml(self):
+# # content = self.input
+# # resp = self.client.put(reverse(views.WriteOnlyResource), content, HTTP_ACCEPT='application/xml')
+# # output = xml2dict(resp.content)
+# # self.assertEquals(self.input, output)
+#
+# #def test_encode_json_decode_xml(self):
+# # content = json.dumps(self.input)
+# # resp = self.client.put(reverse(views.WriteOnlyResource), content, 'application/json', HTTP_ACCEPT='application/xml')
+# # output = xml2dict(resp.content)
+# # self.assertEquals(self.input, output)
+#
+# #def test_encode_xml_decode_xml(self):
+# # content = dict2xml(self.input)
+# # resp = self.client.put(reverse(views.WriteOnlyResource), content, 'application/json', HTTP_ACCEPT='application/xml')
+# # output = xml2dict(resp.content)
+# # self.assertEquals(self.input, output)
+#
+#class ModelTests(TestCase):
+# def test_create_container(self):
+# content = json.dumps({'name': 'example'})
+# resp = self.client.post(reverse(views.ContainerFactory), content, 'application/json')
+# output = json.loads(resp.content)
+# self.assertEquals(resp.status_code, 201)
+# self.assertEquals(output['name'], 'example')
+# self.assertEquals(set(output.keys()), set(('absolute_uri', 'name', 'key')))
+#
+#class CreatedModelTests(TestCase):
+# def setUp(self):
+# content = json.dumps({'name': 'example'})
+# resp = self.client.post(reverse(views.ContainerFactory), content, 'application/json', HTTP_ACCEPT='application/json')
+# self.container = json.loads(resp.content)
+#
+# def test_read_container(self):
+# resp = self.client.get(self.container["absolute_uri"])
+# self.assertEquals(resp.status_code, 200)
+# container = json.loads(resp.content)
+# self.assertEquals(container, self.container)
+#
+# def test_delete_container(self):
+# resp = self.client.delete(self.container["absolute_uri"])
+# self.assertEquals(resp.status_code, 204)
+# self.assertEquals(resp.content, '')
+#
+# def test_update_container(self):
+# self.container['name'] = 'new'
+# content = json.dumps(self.container)
+# resp = self.client.put(self.container["absolute_uri"], content, 'application/json')
+# self.assertEquals(resp.status_code, 200)
+# container = json.loads(resp.content)
+# self.assertEquals(container, self.container)
+
11 examples/blogpost/urls.py
View
@@ -0,0 +1,11 @@
+from django.conf.urls.defaults import patterns
+
+urlpatterns = patterns('blogpost.views',
+ (r'^$', 'RootResource'),
+ (r'^blog-posts/$', 'BlogPostList'),
+ (r'^blog-post/$', 'BlogPostCreator'),
+ (r'^blog-post/(?P<key>[^/]+)/$', 'BlogPostInstance'),
+ (r'^blog-post/(?P<blogpost_id>[^/]+)/comments/$', 'CommentList'),
+ (r'^blog-post/(?P<blogpost_id>[^/]+)/comment/$', 'CommentCreator'),
+ (r'^blog-post/(?P<blogpost>[^/]+)/comments/(?P<id>[^/]+)/$', 'CommentInstance'),
+)
63 examples/blogpost/views.py
View
@@ -0,0 +1,63 @@
+from flywheel.response import Response, status
+from flywheel.resource import Resource
+from flywheel.modelresource import ModelResource, QueryModelResource
+from blogpost.models import BlogPost, Comment
+
+##### Root Resource #####
+
+class RootResource(Resource):
+ """This is the top level resource for the API.
+ All the sub-resources are discoverable from here."""
+ allowed_methods = ('GET',)
+
+ def get(self, request, *args, **kwargs):
+ return Response(status.HTTP_200_OK,
+ {'blog-posts': self.reverse(BlogPostList),
+ 'blog-post': self.reverse(BlogPostCreator)})
+
+
+##### Blog Post Resources #####
+
+BLOG_POST_FIELDS = ('created', 'title', 'slug', 'content', 'absolute_url', 'comment_url', 'comments_url')
+
+class BlogPostList(QueryModelResource):
+ """A resource which lists all existing blog posts."""
+ allowed_methods = ('GET', )
+ model = BlogPost
+ fields = BLOG_POST_FIELDS
+
+class BlogPostCreator(ModelResource):
+ """A resource with which blog posts may be created."""
+ allowed_methods = ('POST',)
+ model = BlogPost
+ fields = BLOG_POST_FIELDS
+
+class BlogPostInstance(ModelResource):
+ """A resource which represents a single blog post."""
+ allowed_methods = ('GET', 'PUT', 'DELETE')
+ model = BlogPost
+ fields = BLOG_POST_FIELDS
+
+
+##### Comment Resources #####
+
+COMMENT_FIELDS = ('username', 'comment', 'created', 'rating', 'absolute_url', 'blogpost_url')
+
+class CommentList(QueryModelResource):
+ """A resource which lists all existing comments for a given blog post."""
+ allowed_methods = ('GET', )
+ model = Comment
+ fields = COMMENT_FIELDS
+
+class CommentCreator(ModelResource):
+ """A resource with which blog comments may be created for a given blog post."""
+ allowed_methods = ('POST',)
+ model = Comment
+ fields = COMMENT_FIELDS
+
+class CommentInstance(ModelResource):
+ """A resource which represents a single comment."""
+ allowed_methods = ('GET', 'PUT', 'DELETE')
+ model = Comment
+ fields = COMMENT_FIELDS
+
20 examples/initial_data.json
View
@@ -0,0 +1,20 @@
+[
+ {
+ "pk": 1,
+ "model": "auth.user",
+ "fields": {
+ "username": "admin",
+ "first_name": "",
+ "last_name": "",
+ "is_active": true,
+ "is_superuser": true,
+ "is_staff": true,
+ "last_login": "2010-01-01 00:00:00",
+ "groups": [],
+ "user_permissions": [],
+ "password": "sha1$6cbce$e4e808893d586a3301ac3c14da6c84855999f1d8",
+ "email": "test@example.com",
+ "date_joined": "2010-01-01 00:00:00"
+ }
+ }
+]
11 examples/manage.py
View
@@ -0,0 +1,11 @@
+#!/usr/bin/env python
+from django.core.management import execute_manager
+try:
+ import settings # Assumed to be in the same directory.
+except ImportError:
+ import sys
+ sys.stderr.write("Error: Can't find the file 'settings.py' in the directory containing %r. It appears you've customized things.\nYou'll have to run django-admin.py, passing it your settings module.\n(If the file settings.py does indeed exist, it's causing an ImportError somehow.)\n" % __file__)
+ sys.exit(1)
+
+if __name__ == "__main__":
+ execute_manager(settings)
0  examples/objectstore/__init__.py
View
No changes.
3  examples/objectstore/models.py
View
@@ -0,0 +1,3 @@
+from django.db import models
+
+# Create your models here.
23 examples/objectstore/tests.py
View
@@ -0,0 +1,23 @@
+"""
+This file demonstrates two different styles of tests (one doctest and one
+unittest). These will both pass when you run "manage.py test".
+
+Replace these with more appropriate tests for your application.
+"""
+
+from django.test import TestCase
+
+class SimpleTest(TestCase):
+ def test_basic_addition(self):
+ """
+ Tests that 1 + 1 always equals 2.
+ """
+ self.failUnlessEqual(1 + 1, 2)
+
+__test__ = {"doctest": """
+Another way to test that 1 + 1 is equal to 2.
+
+>>> 1 + 1 == 2
+True
+"""}
+
6 examples/objectstore/urls.py
View
@@ -0,0 +1,6 @@
+from django.conf.urls.defaults import patterns
+
+urlpatterns = patterns('objectstore.views',
+ (r'^$', 'ObjectStoreRoot'),
+ (r'^(?P<key>[A-Za-z0-9_-]{1,64})/$', 'StoredObject'),
+)
54 examples/objectstore/views.py
View
@@ -0,0 +1,54 @@
+from django.conf import settings
+
+from flywheel.resource import Resource
+from flywheel.response import Response, status
+
+import pickle
+import os
+import uuid
+
+OBJECT_STORE_DIR = os.path.join(settings.MEDIA_ROOT, 'objectstore')
+
+
+class ObjectStoreRoot(Resource):
+ """Root of the Object Store API.
+ Allows the client to get a complete list of all the stored objects, or to create a new stored object."""
+ allowed_methods = ('GET', 'POST')
+
+ def get(self, request):
+ """Return a list of all the stored object URLs."""
+ keys = sorted(os.listdir(OBJECT_STORE_DIR))
+ return [self.reverse(StoredObject, key=key) for key in keys]
+
+ def post(self, request, content):
+ """Create a new stored object, with a unique key."""
+ key = str(uuid.uuid1())
+ pathname = os.path.join(OBJECT_STORE_DIR, key)
+ pickle.dump(content, open(pathname, 'wb'))
+ return Response(status.HTTP_201_CREATED, content, {'Location': self.reverse(StoredObject, key=key)})
+
+
+class StoredObject(Resource):
+ """Represents a stored object.
+ The object may be any picklable content."""
+ allowed_methods = ('GET', 'PUT', 'DELETE')
+
+ def get(self, request, key):
+ """Return a stored object, by unpickling the contents of a locally stored file."""
+ pathname = os.path.join(OBJECT_STORE_DIR, key)
+ if not os.path.exists(pathname):
+ return Response(status.HTTP_404_NOT_FOUND)
+ return pickle.load(open(pathname, 'rb'))
+
+ def put(self, request, content, key):
+ """Update/create a stored object, by pickling the request content to a locally stored file."""
+ pathname = os.path.join(OBJECT_STORE_DIR, key)
+ pickle.dump(content, open(pathname, 'wb'))
+ return content
+
+ def delete(self, request, key):
+ """Delete a stored object, by removing it's pickled file."""
+ pathname = os.path.join(OBJECT_STORE_DIR, key)
+ if not os.path.exists(pathname):
+ return Response(status.HTTP_404_NOT_FOUND)
+ os.remove(pathname)
96 examples/settings.py
View
@@ -0,0 +1,96 @@
+# Django settings for src project.
+
+DEBUG = True
+TEMPLATE_DEBUG = DEBUG
+
+ADMINS = (
+ # ('Your Name', 'your_email@domain.com'),
+)
+
+MANAGERS = ADMINS
+
+DATABASES = {
+ 'default': {
+ 'ENGINE': 'django.db.backends.sqlite3', # Add 'postgresql_psycopg2', 'postgresql', 'mysql', 'sqlite3' or 'oracle'.
+ 'NAME': 'sqlite3.db', # Or path to database file if using sqlite3.
+ 'USER': '', # Not used with sqlite3.
+ 'PASSWORD': '', # Not used with sqlite3.
+ 'HOST': '', # Set to empty string for localhost. Not used with sqlite3.
+ 'PORT': '', # Set to empty string for default. Not used with sqlite3.
+ }
+}
+
+# Local time zone for this installation. Choices can be found here:
+# http://en.wikipedia.org/wiki/List_of_tz_zones_by_name
+# although not all choices may be available on all operating systems.
+# On Unix systems, a value of None will cause Django to use the same
+# timezone as the operating system.
+# If running in a Windows environment this must be set to the same as your
+# system time zone.
+TIME_ZONE = 'Europe/London'
+
+# Language code for this installation. All choices can be found here:
+# http://www.i18nguy.com/unicode/language-identifiers.html
+LANGUAGE_CODE = 'en-uk'
+
+SITE_ID = 1
+
+# If you set this to False, Django will make some optimizations so as not
+# to load the internationalization machinery.
+USE_I18N = True
+
+# If you set this to False, Django will not format dates, numbers and
+# calendars according to the current locale
+USE_L10N = True
+
+# Absolute filesystem path to the directory that will hold user-uploaded files.
+# Example: "/home/media/media.lawrence.com/"
+MEDIA_ROOT = '/Users/tomchristie/'
+
+# URL that handles the media served from MEDIA_ROOT. Make sure to use a
+# trailing slash if there is a path component (optional in other cases).
+# Examples: "http://media.lawrence.com", "http://example.com/media/"
+MEDIA_URL = ''
+
+# URL prefix for admin media -- CSS, JavaScript and images. Make sure to use a
+# trailing slash.
+# Examples: "http://foo.com/media/", "/media/".
+ADMIN_MEDIA_PREFIX = '/media/'
+
+# Make this unique, and don't share it with anybody.
+SECRET_KEY = 't&9mru2_k$t8e2-9uq-wu2a1)9v*us&j3i#lsqkt(lbx*vh1cu'
+
+# List of callables that know how to import templates from various sources.
+TEMPLATE_LOADERS = (
+ 'django.template.loaders.filesystem.Loader',
+ 'django.template.loaders.app_directories.Loader',
+# 'django.template.loaders.eggs.Loader',
+)
+
+MIDDLEWARE_CLASSES = (
+ 'django.middleware.common.CommonMiddleware',
+ 'django.contrib.sessions.middleware.SessionMiddleware',
+ 'django.middleware.csrf.CsrfViewMiddleware',
+ 'django.contrib.auth.middleware.AuthenticationMiddleware',
+ 'django.contrib.messages.middleware.MessageMiddleware',
+)
+
+ROOT_URLCONF = 'urls'
+
+TEMPLATE_DIRS = (
+ # Put strings here, like "/home/html/django_templates" or "C:/www/django/templates".
+ # Always use forward slashes, even on Windows.
+ # Don't forget to use absolute paths, not relative paths.
+)
+
+INSTALLED_APPS = (
+ 'django.contrib.auth',
+ 'django.contrib.contenttypes',
+ 'django.contrib.sessions',
+ 'django.contrib.sites',
+ 'django.contrib.messages',
+ 'django.contrib.admin',
+ 'flywheel',
+ 'blogpost',
+ 'objectstore'
+)
11 examples/urls.py
View
@@ -0,0 +1,11 @@
+from django.conf.urls.defaults import patterns, include
+from django.contrib import admin
+
+admin.autodiscover()
+
+urlpatterns = patterns('',
+ (r'^blog-post-example/', include('blogpost.urls')),
+ (r'^object-store-example/', include('objectstore.urls')),
+ (r'^admin/doc/', include('django.contrib.admindocs.urls')),
+ (r'^admin/', include(admin.site.urls)),
+)
0  flywheel/__init__.py
View
No changes.
118 flywheel/emitters.py
View
@@ -0,0 +1,118 @@
+from django.template import RequestContext, loader
+
+from flywheel.response import NoContent
+
+from utils import dict2xml
+try:
+ import json
+except ImportError:
+ import simplejson as json
+
+
+
+class BaseEmitter(object):
+ media_type = None
+
+ def __init__(self, resource):
+ self.resource = resource
+
+ def emit(self, output=NoContent, verbose=False):
+ raise Exception('emit() function on a subclass of BaseEmitter must be implemented')
+
+
+from django import forms
+class JSONForm(forms.Form):
+ _contenttype = forms.CharField(max_length=256, initial='application/json', label='Content Type')
+ _content = forms.CharField(label='Content', widget=forms.Textarea)
+
+class DocumentingTemplateEmitter(BaseEmitter):
+ """Emitter used to self-document the API"""
+ template = None
+
+ def emit(self, output=NoContent):
+ resource = self.resource
+
+ # Find the first valid emitter and emit the content. (Don't another documenting emitter.)
+ emitters = [emitter for emitter in resource.emitters if not isinstance(emitter, DocumentingTemplateEmitter)]
+ if not emitters:
+ content = 'No emitters were found'
+ else:
+ content = emitters[0](resource).emit(output, verbose=True)
+
+ # Get the form instance if we have one bound to the input
+ form_instance = resource.form_instance
+
+ # Otherwise if this isn't an error response
+ # then attempt to get a form bound to the response object
+ if not form_instance and not resource.response.is_error and resource.response.has_content_body:
+ try:
+ form_instance = resource.get_form(resource.response.raw_content)
+ except:
+ pass
+
+ # If we still don't have a form instance then try to get an unbound form
+ if not form_instance:
+ try:
+ form_instance = self.resource.get_form()
+ except:
+ pass
+
+ if not form_instance:
+ form_instance = JSONForm()
+
+ template = loader.get_template(self.template)
+ context = RequestContext(self.resource.request, {
+ 'content': content,
+ 'resource': self.resource,
+ 'request': self.resource.request,
+ 'response': self.resource.response,
+ 'form': form_instance
+ })
+
+ ret = template.render(context)
+
+ # Munge DELETE Response code to allow us to return content
+ # (Do this *after* we've rendered the template so that we include the normal deletion response code in the output)
+ if self.resource.response.status == 204:
+ self.resource.response.status = 200
+
+ return ret
+
+
+class JSONEmitter(BaseEmitter):
+ media_type = 'application/json'
+
+ def emit(self, output=NoContent, verbose=False):
+ if output is NoContent:
+ return ''
+ if verbose:
+ return json.dumps(output, indent=4, sort_keys=True)
+ return json.dumps(output)
+
+
+class XMLEmitter(BaseEmitter):
+ media_type = 'application/xml'
+
+ def emit(self, output=NoContent, verbose=False):
+ if output is NoContent:
+ return ''
+ return dict2xml(output)
+
+
+class DocumentingHTMLEmitter(DocumentingTemplateEmitter):
+ media_type = 'text/html'
+ uses_forms = True
+ template = 'emitter.html'
+
+
+class DocumentingXHTMLEmitter(DocumentingTemplateEmitter):
+ media_type = 'application/xhtml+xml'
+ uses_forms = True
+ template = 'emitter.html'
+
+
+class DocumentingPlainTextEmitter(DocumentingTemplateEmitter):
+ media_type = 'text/plain'
+ template = 'emitter.txt'
+
+
393 flywheel/modelresource.py
View
@@ -0,0 +1,393 @@
+"""TODO: docs
+"""
+from django.forms import ModelForm
+from django.db.models.query import QuerySet
+from django.db.models import Model
+
+from flywheel.response import status, Response, ResponseException
+from flywheel.resource import Resource
+
+import decimal
+import inspect
+import re
+
+
+class ModelResource(Resource):
+ """A specialized type of Resource, for resources that map directly to a Django Model.
+ Useful things this provides:
+
+ 0. Default input validation based on ModelForms.
+ 1. Nice serialization of returned Models and QuerySets.
+ 2. A default set of create/read/update/delete operations."""
+
+ # The model attribute refers to the Django Model which this Resource maps to.
+ # (The Model's class, rather than an instance of the Model)
+ model = None
+
+ # By default the set of returned fields will be the set of:
+ #
+ # 0. All the fields on the model, excluding 'id'.
+ # 1. All the properties on the model.
+ # 2. The absolute_url of the model, if a get_absolute_url method exists for the model.
+ #
+ # If you wish to override this behaviour,
+ # you should explicitly set the fields attribute on your class.
+ fields = None
+
+ # By default the form used with be a ModelForm for self.model
+ # If you wish to override this behaviour or provide a sub-classed ModelForm
+ # you should explicitly set the form attribute on your class.
+ form = None
+
+ # By default the set of input fields will be the same as the set of output fields
+ # If you wish to override this behaviour you should explicitly set the
+ # form_fields attribute on your class.
+ form_fields = None
+
+
+ def get_form(self, content=None):
+ """Return a form that may be used in validation and/or rendering an html emitter"""
+ if self.form:
+ return super(self.__class__, self).get_form(content)
+
+ elif self.model:
+
+ class NewModelForm(ModelForm):
+ class Meta:
+ model = self.model
+ fields = self.form_fields if self.form_fields else None
+
+ if content and isinstance(content, Model):
+ return NewModelForm(instance=content)
+ elif content:
+ return NewModelForm(content)
+
+ return NewModelForm()
+
+ return None
+
+
+ def cleanup_request(self, data, form_instance):
+ """Override cleanup_request to drop read-only fields from the input prior to validation.
+ This ensures that we don't error out with 'non-existent field' when these fields are supplied,
+ and allows for a pragmatic approach to resources which include read-only elements.
+
+ I would actually like to be strict and verify the value of correctness of the values in these fields,
+ although that gets tricky as it involves validating at the point that we get the model instance.
+
+ See here for another example of this approach:
+ http://fedoraproject.org/wiki/Cloud_APIs_REST_Style_Guide
+ https://www.redhat.com/archives/rest-practices/2010-April/thread.html#00041"""
+ read_only_fields = set(self.fields) - set(self.form_instance.fields)
+ input_fields = set(data.keys())
+
+ clean_data = {}
+ for key in input_fields - read_only_fields:
+ clean_data[key] = data[key]
+
+ return super(ModelResource, self).cleanup_request(clean_data, form_instance)
+
+
+ def cleanup_response(self, data):
+ """A munging of Piston's pre-serialization. Returns a dict"""
+
+ def _any(thing, fields=()):
+ """
+ Dispatch, all types are routed through here.
+ """
+ ret = None
+
+ if isinstance(thing, QuerySet):
+ ret = _qs(thing, fields=fields)
+ elif isinstance(thing, (tuple, list)):
+ ret = _list(thing)
+ elif isinstance(thing, dict):
+ ret = _dict(thing)
+ elif isinstance(thing, int):
+ ret = thing
+ elif isinstance(thing, bool):
+ ret = thing
+ elif isinstance(thing, type(None)):
+ ret = thing
+ elif isinstance(thing, decimal.Decimal):
+ ret = str(thing)
+ elif isinstance(thing, Model):
+ ret = _model(thing, fields=fields)
+ #elif isinstance(thing, HttpResponse): TRC
+ # raise HttpStatusCode(thing)
+ elif inspect.isfunction(thing):
+ if not inspect.getargspec(thing)[0]:
+ ret = _any(thing())
+ elif hasattr(thing, '__emittable__'):
+ f = thing.__emittable__
+ if inspect.ismethod(f) and len(inspect.getargspec(f)[0]) == 1:
+ ret = _any(f())
+ else:
+ ret = str(thing) # TRC TODO: Change this back!
+
+ return ret
+
+ def _fk(data, field):
+ """
+ Foreign keys.
+ """
+ return _any(getattr(data, field.name))
+
+ def _related(data, fields=()):
+ """
+ Foreign keys.
+ """
+ return [ _model(m, fields) for m in data.iterator() ]
+
+ def _m2m(data, field, fields=()):
+ """
+ Many to many (re-route to `_model`.)
+ """
+ return [ _model(m, fields) for m in getattr(data, field.name).iterator() ]
+
+
+ def _method_fields(data, fields):
+ if not data:
+ return { }
+
+ has = dir(data)
+ ret = dict()
+
+ for field in fields:
+ if field in has:
+ ret[field] = getattr(data, field)
+
+ return ret
+
+ def _model(data, fields=()):
+ """
+ Models. Will respect the `fields` and/or
+ `exclude` on the handler (see `typemapper`.)
+ """
+ ret = { }
+ #handler = self.in_typemapper(type(data), self.anonymous) # TRC
+ handler = None # TRC
+ get_absolute_url = False
+
+ if handler or fields:
+ v = lambda f: getattr(data, f.attname)
+
+ if not fields:
+ """
+ Fields was not specified, try to find teh correct
+ version in the typemapper we were sent.
+ """
+ mapped = self.in_typemapper(type(data), self.anonymous)
+ get_fields = set(mapped.fields)
+ exclude_fields = set(mapped.exclude).difference(get_fields)
+
+ if not get_fields:
+ get_fields = set([ f.attname.replace("_id", "", 1)
+ for f in data._meta.fields ])
+
+ # sets can be negated.
+ for exclude in exclude_fields:
+ if isinstance(exclude, basestring):
+ get_fields.discard(exclude)
+
+ elif isinstance(exclude, re._pattern_type):
+ for field in get_fields.copy():
+ if exclude.match(field):
+ get_fields.discard(field)
+
+ get_absolute_url = True
+
+ else:
+ get_fields = set(fields)
+ if 'absolute_url' in get_fields: # MOVED (TRC)
+ get_absolute_url = True
+
+ met_fields = _method_fields(handler, get_fields) # TRC
+
+ for f in data._meta.local_fields:
+ if f.serialize and not any([ p in met_fields for p in [ f.attname, f.name ]]):
+ if not f.rel:
+ if f.attname in get_fields:
+ ret[f.attname] = _any(v(f))
+ get_fields.remove(f.attname)
+ else:
+ if f.attname[:-3] in get_fields:
+ ret[f.name] = _fk(data, f)
+ get_fields.remove(f.name)
+
+ for mf in data._meta.many_to_many:
+ if mf.serialize and mf.attname not in met_fields:
+ if mf.attname in get_fields:
+ ret[mf.name] = _m2m(data, mf)
+ get_fields.remove(mf.name)
+
+ # try to get the remainder of fields
+ for maybe_field in get_fields:
+
+ if isinstance(maybe_field, (list, tuple)):
+ model, fields = maybe_field
+ inst = getattr(data, model, None)
+
+ if inst:
+ if hasattr(inst, 'all'):
+ ret[model] = _related(inst, fields)
+ elif callable(inst):
+ if len(inspect.getargspec(inst)[0]) == 1:
+ ret[model] = _any(inst(), fields)
+ else:
+ ret[model] = _model(inst, fields)
+
+ elif maybe_field in met_fields:
+ # Overriding normal field which has a "resource method"
+ # so you can alter the contents of certain fields without
+ # using different names.
+ ret[maybe_field] = _any(met_fields[maybe_field](data))
+
+ else:
+ maybe = getattr(data, maybe_field, None)
+ if maybe:
+ if callable(maybe):
+ if len(inspect.getargspec(maybe)[0]) == 1:
+ ret[maybe_field] = _any(maybe())
+ else:
+ ret[maybe_field] = _any(maybe)
+ else:
+ pass # TRC
+ #handler_f = getattr(handler or self.handler, maybe_field, None)
+ #
+ #if handler_f:
+ # ret[maybe_field] = _any(handler_f(data))
+
+ else:
+ # Add absolute_url if it exists
+ get_absolute_url = True
+
+ # Add all the fields
+ for f in data._meta.fields:
+ if f.attname != 'id':
+ ret[f.attname] = _any(getattr(data, f.attname))
+
+ # Add all the propertiess
+ klass = data.__class__
+ for attr in dir(klass):
+ if not attr.startswith('_') and not attr in ('pk','id') and isinstance(getattr(klass, attr, None), property):
+ #if attr.endswith('_url') or attr.endswith('_uri'):
+ # ret[attr] = self.make_absolute(_any(getattr(data, attr)))
+ #else:
+ ret[attr] = _any(getattr(data, attr))
+ #fields = dir(data.__class__) + ret.keys()
+ #add_ons = [k for k in dir(data) if k not in fields and not k.startswith('_')]
+ #print add_ons
+ ###print dir(data.__class__)
+ #from django.db.models import Model
+ #model_fields = dir(Model)
+
+ #for attr in dir(data):
+ ## #if attr.startswith('_'):
+ ## # continue
+ # if (attr in fields) and not (attr in model_fields) and not attr.startswith('_'):
+ # print attr, type(getattr(data, attr, None)), attr in fields, attr in model_fields
+
+ #for k in add_ons:
+ # ret[k] = _any(getattr(data, k))
+
+ # TRC
+ # resouce uri
+ #if self.in_typemapper(type(data), self.anonymous):
+ # handler = self.in_typemapper(type(data), self.anonymous)
+ # if hasattr(handler, 'resource_uri'):
+ # url_id, fields = handler.resource_uri()
+ # ret['resource_uri'] = permalink( lambda: (url_id,
+ # (getattr(data, f) for f in fields) ) )()
+
+ # TRC
+ #if hasattr(data, 'get_api_url') and 'resource_uri' not in ret:
+ # try: ret['resource_uri'] = data.get_api_url()
+ # except: pass
+
+ # absolute uri
+ if hasattr(data, 'get_absolute_url') and get_absolute_url:
+ try: ret['absolute_url'] = data.get_absolute_url()
+ except: pass
+
+ for key, val in ret.items():
+ if key.endswith('_url') or key.endswith('_uri'):
+ ret[key] = self.add_domain(val)
+
+ return ret
+
+ def _qs(data, fields=()):
+ """
+ Querysets.
+ """
+ return [ _any(v, fields) for v in data ]
+
+ def _list(data):
+ """
+ Lists.
+ """
+ return [ _any(v) for v in data ]
+
+ def _dict(data):
+ """
+ Dictionaries.
+ """
+ return dict([ (k, _any(v)) for k, v in data.iteritems() ])
+
+ # Kickstart the seralizin'.
+ return _any(data, self.fields)
+
+
+ def post(self, request, content, *args, **kwargs):
+ # TODO: test creation on a non-existing resource url
+ all_kw_args = dict(content.items() + kwargs.items())
+ instance = self.model(**all_kw_args)
+ instance.save()
+ headers = {}
+ if hasattr(instance, 'get_absolute_url'):
+ headers['Location'] = self.add_domain(instance.get_absolute_url())
+ return Response(status.HTTP_201_CREATED, instance, headers)
+
+ def get(self, request, *args, **kwargs):
+ try:
+ instance = self.model.objects.get(**kwargs)
+ except self.model.DoesNotExist:
+ raise ResponseException(status.HTTP_404_NOT_FOUND)
+
+ return instance
+
+ def put(self, request, content, *args, **kwargs):
+ # TODO: update on the url of a non-existing resource url doesn't work correctly at the moment - will end up with a new url
+ try:
+ instance = self.model.objects.get(**kwargs)
+ for (key, val) in content.items():
+ setattr(instance, key, val)
+ except self.model.DoesNotExist:
+ instance = self.model(**content)
+ instance.save()
+
+ instance.save()
+ return instance
+
+ def delete(self, request, *args, **kwargs):
+ try:
+ instance = self.model.objects.get(**kwargs)
+ except self.model.DoesNotExist:
+ raise ResponseException(status.HTTP_404_NOT_FOUND, None, {})
+
+ instance.delete()
+ return
+
+
+
+class QueryModelResource(ModelResource):
+ allowed_methods = ('read',)
+ queryset = None
+
+ def get_form(self, data=None):
+ return None
+
+ def get(self, request, *args, **kwargs):
+ queryset = self.queryset if self.queryset else self.model.objects.all()
+ return queryset
+
80 flywheel/parsers.py
View
@@ -0,0 +1,80 @@
+from flywheel.response import status, ResponseException
+
+try:
+ import json
+except ImportError:
+ import simplejson as json
+
+# TODO: Make all parsers only list a single media_type, rather than a list
+
+class BaseParser(object):
+ media_types = ()
+
+ def __init__(self, resource):
+ self.resource = resource
+
+ def parse(self, input):
+ return {}
+
+
+class JSONParser(BaseParser):
+ media_types = ('application/xml',)
+
+ def parse(self, input):
+ try:
+ return json.loads(input)
+ except ValueError, exc:
+ raise ResponseException(status.HTTP_400_BAD_REQUEST, {'detail': 'JSON parse error - %s' % str(exc)})
+
+class XMLParser(BaseParser):
+ media_types = ('application/xml',)
+
+
+class FormParser(BaseParser):
+ """The default parser for form data.
+ Return a dict containing a single value for each non-reserved parameter.
+ """
+
+ media_types = ('application/x-www-form-urlencoded',)
+
+ def parse(self, input):
+ # The FormParser doesn't parse the input as other parsers would, since Django's already done the
+ # form parsing for us. We build the content object from the request directly.
+ request = self.resource.request
+
+ if request.method == 'PUT':
+ # Fix from piston to force Django to give PUT requests the same
+ # form processing that POST requests get...
+ #
+ # Bug fix: if _load_post_and_files has already been called, for
+ # example by middleware accessing request.POST, the below code to
+ # pretend the request is a POST instead of a PUT will be too late
+ # to make a difference. Also calling _load_post_and_files will result
+ # in the following exception:
+ # AttributeError: You cannot set the upload handlers after the upload has been processed.
+ # The fix is to check for the presence of the _post field which is set
+ # the first time _load_post_and_files is called (both by wsgi.py and
+ # modpython.py). If it's set, the request has to be 'reset' to redo
+ # the query value parsing in POST mode.
+ if hasattr(request, '_post'):
+ del request._post
+ del request._files
+
+ try:
+ request.method = "POST"
+ request._load_post_and_files()
+ request.method = "PUT"
+ except AttributeError:
+ request.META['REQUEST_METHOD'] = 'POST'
+ request._load_post_and_files()
+ request.META['REQUEST_METHOD'] = 'PUT'
+
+ # Strip any parameters that we are treating as reserved
+ data = {}
+ for (key, val) in request.POST.items():
+ if key not in self.resource.RESERVED_FORM_PARAMS:
+ data[key] = val
+
+ return data
+
+
436 flywheel/resource.py
View
@@ -0,0 +1,436 @@
+from django.contrib.sites.models import Site
+from django.core.urlresolvers import reverse
+from django.http import HttpResponse
+
+from flywheel import emitters, parsers
+from flywheel.response import status, Response, ResponseException
+
+from decimal import Decimal
+import re
+from itertools import chain
+
+# TODO: Authentication
+# TODO: Display user login in top panel: http://stackoverflow.com/questions/806835/django-redirect-to-previous-page-after-login
+# TODO: Figure how out references and named urls need to work nicely
+# TODO: POST on existing 404 URL, PUT on existing 404 URL
+# TODO: Remove is_error throughout
+#
+# NEXT: Exceptions on func() -> 500, tracebacks emitted if settings.DEBUG
+# NEXT: Generic content form
+# NEXT: Remove self.blah munging (Add a ResponseContext object?)
+# NEXT: Caching cleverness
+# NEXT: Test non-existent fields on ModelResources
+#
+# FUTURE: Erroring on read-only fields
+
+# Documentation, Release
+
+__all__ = ['Resource']
+
+
+
+class Resource(object):
+ """Handles incoming requests and maps them to REST operations,
+ performing authentication, input deserialization, input validation, output serialization."""
+
+ # List of RESTful operations which may be performed on this resource.
+ allowed_methods = ('GET',)
+ anon_allowed_methods = ()
+
+ # List of emitters the resource can serialize the response with, ordered by preference
+ emitters = ( emitters.JSONEmitter,
+ emitters.DocumentingHTMLEmitter,
+ emitters.DocumentingXHTMLEmitter,
+ emitters.DocumentingPlainTextEmitter,
+ emitters.XMLEmitter )
+
+ # List of content-types the resource can read from
+ parsers = ( parsers.JSONParser,
+ parsers.XMLParser,
+ parsers.FormParser )
+
+ # Optional form for input validation and presentation of HTML formatted responses.
+ form = None
+
+ # Map standard HTTP methods to function calls
+ callmap = { 'GET': 'get', 'POST': 'post',
+ 'PUT': 'put', 'DELETE': 'delete' }
+
+ # Some reserved parameters to allow us to use standard HTML forms with our resource
+ # Override any/all of these with None to disable them, or override them with another value to rename them.
+ ACCEPT_QUERY_PARAM = '_accept' # Allow override of Accept header in URL query params
+ METHOD_PARAM = '_method' # Allow POST overloading in form params
+ CONTENTTYPE_PARAM = '_contenttype' # Allow override of Content-Type header in form params (allows sending arbitrary content with standard forms)
+ CONTENT_PARAM = '_content' # Allow override of body content in form params (allows sending arbitrary content with standard forms)
+ CSRF_PARAM = 'csrfmiddlewaretoken' # Django's CSRF token used in form params
+
+
+ def __new__(cls, request, *args, **kwargs):
+ """Make the class callable so it can be used as a Django view."""
+ self = object.__new__(cls)
+ self.__init__(request)
+ try:
+ return self._handle_request(request, *args, **kwargs)
+ except:
+ import traceback
+ traceback.print_exc()
+ raise
+
+
+ def __init__(self, request):
+ """"""
+ # Setup the resource context
+ self.request = request
+ self.auth_context = None
+ self.response = None
+ self.form_instance = None
+
+ # These sets are determined now so that overridding classes can modify the various parameter names,
+ # or set them to None to disable them.
+ self.RESERVED_FORM_PARAMS = set((self.METHOD_PARAM, self.CONTENTTYPE_PARAM, self.CONTENT_PARAM, self.CSRF_PARAM))
+ self.RESERVED_QUERY_PARAMS = set((self.ACCEPT_QUERY_PARAM))
+ self.RESERVED_FORM_PARAMS.discard(None)
+ self.RESERVED_QUERY_PARAMS.discard(None)
+
+
+ @property
+ def name(self):
+ """Provide a name for the resource.
+ By default this is the class name, with 'CamelCaseNames' converted to 'Camel Case Names'."""
+ class_name = self.__class__.__name__
+ return re.sub('(((?<=[a-z])[A-Z])|([A-Z](?![A-Z]|$)))', ' \\1', class_name).strip()
+
+ @property
+ def description(self):
+ """Provide a description for the resource.
+ By default this is the class's docstring with leading line spaces stripped."""
+ return re.sub(re.compile('^ +', re.MULTILINE), '', self.__doc__)
+
+ @property
+ def emitted_media_types(self):
+ """Return an list of all the media types that this resource can emit."""
+ return [emitter.media_type for emitter in self.emitters]
+
+ @property
+ def default_emitter(self):
+ """Return the resource's most prefered emitter.
+ (This emitter is used if the client does not send and Accept: header, or sends Accept: */*)"""
+ return self.emitters[0]
+
+ # TODO:
+
+ #def parsed_media_types(self):
+ # """Return an list of all the media types that this resource can emit."""
+ # return [parser.media_type for parser in self.parsers]
+
+ #def deafult_parser(self):
+ # return self.parsers[0]
+
+
+ def reverse(self, view, *args, **kwargs):
+ """Return a fully qualified URI for a given view or resource.
+ Add the domain using the Sites framework if possible, otherwise fallback to using the current request."""
+ return self.add_domain(reverse(view, args=args, kwargs=kwargs))
+
+
+ def authenticate(self, request):
+ """TODO"""
+ return None
+ # user = ...
+ # if DEBUG and request is from localhost
+ # if anon_user and not anon_allowed_methods raise PermissionDenied
+ # return auth_context
+
+
+ def get(self, request, *args, **kwargs):
+ """Must be subclassed to be implemented."""
+ self.not_implemented('GET')
+
+
+ def post(self, request, content, *args, **kwargs):
+ """Must be subclassed to be implemented."""
+ self.not_implemented('POST')
+
+
+ def put(self, request, content, *args, **kwargs):
+ """Must be subclassed to be implemented."""
+ self.not_implemented('PUT')
+
+
+ def delete(self, request, *args, **kwargs):
+ """Must be subclassed to be implemented."""
+ self.not_implemented('DELETE')
+
+
+ def not_implemented(self, operation):
+ """Return an HTTP 500 server error if an operation is called which has been allowed by
+ allowed_methods, but which has not been implemented."""
+ raise ResponseException(status.HTTP_500_INTERNAL_SERVER_ERROR,
+ {'detail': '%s operation on this resource has not been implemented' % (operation, )})
+
+
+ def add_domain(self, path):
+ """Given a path, return an fully qualified URI.
+ Use the Sites framework if possible, otherwise fallback to using the domain from the current request."""
+
+ # Note that out-of-the-box the Sites framework uses the reserved domain 'example.com'
+ # See RFC 2606 - http://www.faqs.org/rfcs/rfc2606.html
+ try:
+ site = Site.objects.get_current()
+ if site.domain and site.domain != 'example.com':
+ return 'http://%s%s' % (site.domain, path)
+ except:
+ pass
+
+ return self.request.build_absolute_uri(path)
+
+
+ def determine_method(self, request):
+ """Determine the HTTP method that this request should be treated as.
+ Allows PUT and DELETE tunneling via the _method parameter if METHOD_PARAM is set."""
+ method = request.method.upper()
+
+ if method == 'POST' and self.METHOD_PARAM and request.POST.has_key(self.METHOD_PARAM):
+ method = request.POST[self.METHOD_PARAM].upper()
+
+ return method
+
+
+ def check_method_allowed(self, method):
+ """Ensure the request method is acceptable for this resource."""
+ if not method in self.callmap.keys():
+ raise ResponseException(status.HTTP_501_NOT_IMPLEMENTED,
+ {'detail': 'Unknown or unsupported method \'%s\'' % method})
+
+ if not method in self.allowed_methods:
+ raise ResponseException(status.HTTP_405_METHOD_NOT_ALLOWED,
+ {'detail': 'Method \'%s\' not allowed on this resource.' % method})
+
+
+ def get_form(self, data=None):
+ """Optionally return a Django Form instance, which may be used for validation
+ and/or rendered by an HTML/XHTML emitter.
+
+ If data is not None the form will be bound to data. is_response indicates if data should be
+ treated as the input data (bind to client input) or the response data (bind to an existing object)."""
+ if self.form:
+ if data:
+ return self.form(data)
+ else:
+ return self.form()
+ return None
+
+
+ def cleanup_request(self, data, form_instance):
+ """Perform any resource-specific data deserialization and/or validation
+ after the initial HTTP content-type deserialization has taken place.
+
+ Returns a tuple containing the cleaned up data, and optionally a form bound to that data.
+
+ By default this uses form validation to filter the basic input into the required types."""
+ if form_instance is None:
+ return data
+
+ # Default form validation does not check for additional invalid fields
+ non_existent_fields = []
+ for key in set(data.keys()) - set(form_instance.fields.keys()):
+ non_existent_fields.append(key)
+
+ if not form_instance.is_valid() or non_existent_fields:
+ if not form_instance.errors and not non_existent_fields:
+ # If no data was supplied the errors property will be None
+ details = 'No content was supplied'
+
+ else:
+ # Add standard field errors
+ details = dict((key, map(unicode, val)) for (key, val) in form_instance.errors.iteritems())
+
+ # Add any non-field errors
+ if form_instance.non_field_errors():
+ details['errors'] = form_instance.non_field_errors()
+
+ # Add any non-existent field errors
+ for key in non_existent_fields:
+ details[key] = ['This field does not exist']
+
+ # Bail. Note that we will still serialize this response with the appropriate content type
+ raise ResponseException(status.HTTP_400_BAD_REQUEST, {'detail': details})
+
+ return form_instance.cleaned_data
+
+
+ def cleanup_response(self, data):
+ """Perform any resource-specific data filtering prior to the standard HTTP
+ content-type serialization.
+
+ Eg filter complex objects that cannot be serialized by json/xml/etc into basic objects that can."""
+ return data
+
+
+ def determine_parser(self, request):
+ """Return the appropriate parser for the input, given the client's 'Content-Type' header,
+ and the content types that this Resource knows how to parse."""
+ content_type = request.META.get('CONTENT_TYPE', 'application/x-www-form-urlencoded')
+ split = content_type.split(';', 1)
+ if len(split) > 1:
+ content_type = split[0]
+ content_type = content_type.strip()
+
+ # Create a list of list of (media_type, Parser) tuples
+ media_type_parser_tuples = [[(media_type, parser) for media_type in parser.media_types] for parser in self.parsers]
+
+ # Flatten the list and turn it into a media_type -> Parser dict
+ media_type_to_parser = dict(chain.from_iterable(media_type_parser_tuples))
+
+ try:
+ return media_type_to_parser[content_type]
+ except KeyError:
+ raise ResponseException(status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
+ {'detail': 'Unsupported media type \'%s\'' % content_type})
+
+
+ def determine_emitter(self, request):
+ """Return the appropriate emitter for the output, given the client's 'Accept' header,
+ and the content types that this Resource knows how to serve.
+
+ See: RFC 2616, Section 14 - http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html"""
+
+ if self.ACCEPT_QUERY_PARAM and request.GET.get(self.ACCEPT_QUERY_PARAM, None):
+ # Use _accept parameter override
+ accept_list = [request.GET.get(self.ACCEPT_QUERY_PARAM)]
+ elif request.META.has_key('HTTP_ACCEPT'):
+ # Use standard HTTP Accept negotiation
+ accept_list = request.META["HTTP_ACCEPT"].split(',')
+ else:
+ # No accept header specified
+ return self.default_emitter
+
+ # Parse the accept header into a dict of {qvalue: set of media types}
+ # We ignore mietype parameters
+ accept_dict = {}
+ for token in accept_list:
+ components = token.split(';')
+ mimetype = components[0].strip()
+ qvalue = Decimal('1.0')
+
+ if len(components) > 1:
+ # Parse items that have a qvalue eg text/html;q=0.9
+ try:
+ (q, num) = components[-1].split('=')
+ if q == 'q':
+ qvalue = Decimal(num)
+ except:
+ # Skip malformed entries
+ continue
+
+ if accept_dict.has_key(qvalue):
+ accept_dict[qvalue].add(mimetype)
+ else:
+ accept_dict[qvalue] = set((mimetype,))
+
+ # Convert to a list of sets ordered by qvalue (highest first)
+ accept_sets = [accept_dict[qvalue] for qvalue in sorted(accept_dict.keys(), reverse=True)]
+
+ for accept_set in accept_sets:
+ # Return any exact match
+ for emitter in self.emitters:
+ if emitter.media_type in accept_set:
+ return emitter
+
+ # Return any subtype match
+ for emitter in self.emitters:
+ if emitter.media_type.split('/')[0] + '/*' in accept_set:
+ return emitter
+
+ # Return default
+ if '*/*' in accept_set:
+ return self.default_emitter
+
+
+ raise ResponseException(status.HTTP_406_NOT_ACCEPTABLE,
+ {'detail': 'Could not statisfy the client\'s Accept header',
+ 'available_types': self.emitted_media_types})
+
+
+ def _handle_request(self, request, *args, **kwargs):
+ """
+ Broadly this consists of the following procedure:
+
+ 0. ensure the operation is permitted
+ 1. deserialize request content into request data, using standard HTTP content types (PUT/POST only)
+ 2. cleanup and validate request data (PUT/POST only)
+ 3. call the core method to get the response data
+ 4. cleanup the response data
+ 5. serialize response data into response content, using standard HTTP content negotiation
+ """
+ emitter = None
+ method = self.determine_method(request)
+
+ try:
+ # Before we attempt anything else determine what format to emit our response data with.
+ emitter = self.determine_emitter(request)
+
+ # Authenticate the request, and store any context so that the resource operations can
+ # do more fine grained authentication if required.
+ #
+ # Typically the context will be a user, or None if this is an anonymous request,
+ # but it could potentially be more complex (eg the context of a request key which
+ # has been signed against a particular set of permissions)
+ self.auth_context = self.authenticate(request)
+
+ # Ensure the requested operation is permitted on this resource
+ self.check_method_allowed(method)
+
+ # Get the appropriate create/read/update/delete function
+ func = getattr(self, self.callmap.get(method, None))
+
+ # Either generate the response data, deserializing and validating any request data
+ # TODO: Add support for message bodys on other HTTP methods, as it is valid.
+ if method in ('PUT', 'POST'):
+ parser = self.determine_parser(request)
+ data = parser(self).parse(request.raw_post_data)
+ self.form_instance = self.get_form(data)
+ data = self.cleanup_request(data, self.form_instance)
+ response = func(request, data, *args, **kwargs)
+
+ else:
+ response = func(request, *args, **kwargs)
+
+ # Allow return value to be either Response, or an object, or None
+ if isinstance(response, Response):
+ self.response = response
+ elif response is not None:
+ self.response = Response(status.HTTP_200_OK, response)
+ else:
+ self.response = Response(status.HTTP_204_NO_CONTENT)
+
+ # Pre-serialize filtering (eg filter complex objects into natively serializable types)
+ self.response.cleaned_content = self.cleanup_response(self.response.raw_content)
+
+
+ except ResponseException, exc:
+ self.response = exc.response
+
+ # Fall back to the default emitter if we failed to perform content negotiation
+ if emitter is None:
+ emitter = self.default_emitter
+
+
+ # Always add these headers
+ self.response.headers['Allow'] = ', '.join(self.allowed_methods)
+ self.response.headers['Vary'] = 'Authenticate, Allow'
+
+ # Serialize the response content
+ if self.response.has_content_body:
+ content = emitter(self).emit(output=self.response.cleaned_content)
+ else:
+ content = emitter(self).emit()
+
+ # Build the HTTP Response
+ # TODO: Check if emitter.mimetype is underspecified, or if a content-type header has been set
+ resp = HttpResponse(content, mimetype=emitter.media_type, status=self.response.status)
+ for (key, val) in self.response.headers.items():
+ resp[key] = val
+
+ return resp
+
126 flywheel/response.py
View
@@ -0,0 +1,126 @@
+from django.core.handlers.wsgi import STATUS_CODE_TEXT
+
+__all__ =['status', 'NoContent', 'Response', ]
+
+
+class Status(object):
+ """Descriptive HTTP status codes, for code readability.
+ See RFC 2616 - Sec 10: http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html"""
+
+ # Verbose format (I prefer this as it's more explicit)
+ HTTP_100_CONTINUE = 100
+ HTTP_101_SWITCHING_PROTOCOLS = 101
+ HTTP_200_OK = 200
+ HTTP_201_CREATED = 201
+ HTTP_202_ACCEPTED = 202
+ HTTP_203_NON_AUTHORITATIVE_INFORMATION = 203
+ HTTP_204_NO_CONTENT = 204
+ HTTP_205_RESET_CONTENT = 205
+ HTTP_206_PARTIAL_CONTENT = 206
+ HTTP_300_MULTIPLE_CHOICES = 300
+ HTTP_301_MOVED_PERMANENTLY = 301
+ HTTP_302_FOUND = 302
+ HTTP_303_SEE_OTHER = 303
+ HTTP_304_NOT_MODIFIED = 304
+ HTTP_305_USE_PROXY = 305
+ HTTP_306_RESERVED = 306
+ HTTP_307_TEMPORARY_REDIRECT = 307
+ HTTP_400_BAD_REQUEST = 400
+ HTTP_401_UNAUTHORIZED = 401
+ HTTP_402_PAYMENT_REQUIRED = 402
+ HTTP_403_FORBIDDEN = 403
+ HTTP_404_NOT_FOUND = 404
+ HTTP_405_METHOD_NOT_ALLOWED = 405
+ HTTP_406_NOT_ACCEPTABLE = 406
+ HTTP_407_PROXY_AUTHENTICATION_REQUIRED = 407
+ HTTP_408_REQUEST_TIMEOUT = 408
+ HTTP_409_CONFLICT = 409
+ HTTP_410_GONE = 410
+ HTTP_411_LENGTH_REQUIRED = 411
+ HTTP_412_PRECONDITION_FAILED = 412
+ HTTP_413_REQUEST_ENTITY_TOO_LARGE = 413
+ HTTP_414_REQUEST_URI_TOO_LONG = 414
+ HTTP_415_UNSUPPORTED_MEDIA_TYPE = 415
+ HTTP_416_REQUESTED_RANGE_NOT_SATISFIABLE = 416
+ HTTP_417_EXPECTATION_FAILED = 417
+ HTTP_500_INTERNAL_SERVER_ERROR = 500
+ HTTP_501_NOT_IMPLEMENTED = 501
+ HTTP_502_BAD_GATEWAY = 502
+ HTTP_503_SERVICE_UNAVAILABLE = 503
+ HTTP_504_GATEWAY_TIMEOUT = 504
+ HTTP_505_HTTP_VERSION_NOT_SUPPORTED = 505
+
+ # Short format
+ CONTINUE = 100
+ SWITCHING_PROTOCOLS = 101
+ OK = 200
+ CREATED = 201
+ ACCEPTED = 202
+ NON_AUTHORITATIVE_INFORMATION = 203
+ NO_CONTENT = 204
+ RESET_CONTENT = 205
+ PARTIAL_CONTENT = 206
+ MULTIPLE_CHOICES = 300
+ MOVED_PERMANENTLY = 301
+ FOUND = 302
+ SEE_OTHER = 303
+ NOT_MODIFIED = 304
+ USE_PROXY = 305
+ RESERVED = 306
+ TEMPORARY_REDIRECT = 307
+ BAD_REQUEST = 400
+ UNAUTHORIZED = 401
+ PAYMENT_REQUIRED = 402
+ FORBIDDEN = 403
+ NOT_FOUND = 404
+ METHOD_NOT_ALLOWED = 405
+ NOT_ACCEPTABLE = 406
+ PROXY_AUTHENTICATION_REQUIRED = 407
+ REQUEST_TIMEOUT = 408
+ CONFLICT = 409
+ GONE = 410
+ LENGTH_REQUIRED = 411
+ PRECONDITION_FAILED = 412
+ REQUEST_ENTITY_TOO_LARGE = 413
+ REQUEST_URI_TOO_LONG = 414
+ UNSUPPORTED_MEDIA_TYPE = 415
+ REQUESTED_RANGE_NOT_SATISFIABLE = 416
+ EXPECTATION_FAILED = 417
+ INTERNAL_SERVER_ERROR = 500
+ NOT_IMPLEMENTED = 501
+ BAD_GATEWAY = 502
+ SERVICE_UNAVAILABLE = 503
+ GATEWAY_TIMEOUT = 504
+ HTTP_VERSION_NOT_SUPPORTED = 505
+
+
+
+# This is simply stylistic, I think 'status.HTTP_200_OK' reads nicely.
+status = Status()
+
+
+class NoContent(object):
+ """Used to indicate no body in http response.
+ (We cannot just use None, as that is a valid, serializable response object.)"""
+ pass
+
+
+class Response(object):
+ def __init__(self, status, content=NoContent, headers={}, is_error=False):
+ self.status = status
+ self.has_content_body = not content is NoContent
+ self.raw_content = content # content prior to filtering
+ self.cleaned_content = content # content after filtering
+ self.headers = headers
+ self.is_error = is_error
+
+ @property
+ def status_text(self):
+ """Return reason text corrosponding to our HTTP response status code.
+ Provided for convienience."""
+ return STATUS_CODE_TEXT.get(self.status, '')
+
+
+class ResponseException(BaseException):
+ def __init__(self, status, content=NoContent, headers={}):
+ self.response = Response(status, content=content, headers=headers, is_error=True)
96 flywheel/templates/emitter.html
View
@@ -0,0 +1,96 @@
+{% load urlize_quoted_links %}{% load add_query_param %}<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
+ "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml">
+ <head>
+ <style>
+ pre {border: 1px solid black; padding: 1em; background: #ffd}
+ div.action {border: 1px solid black; padding: 0.5em 1em; margin-bottom: 0.5em; background: #ddf}
+ ul.accepttypes {float: right; list-style-type: none; margin: 0; padding: 0}
+ ul.accepttypes li {display: inline;}
+ form div {margin: 0.5em 0}
+ form div * {vertical-align: top}
+ form ul.errorlist {display: inline; margin: 0; padding: 0}
+ form ul.errorlist li {display: inline; color: red;}
+ .clearing {display: block; margin: 0; padding: 0; clear: both;}
+ </style>
+ <title>API - {{ resource.name }}</title>
+ </head>
+ <body>
+ <h1>{{ resource.name }}</h1>
+ <p>{{ resource.description|linebreaksbr }}</p>
+ <pre><b>{{ response.status }} {{ response.status_text }}</b>{% autoescape off %}
+{% for key, val in response.headers.items %}<b>{{ key }}:</b> {{ val|urlize_quoted_links }}
+{% endfor %}
+{{ content|urlize_quoted_links }} </pre>{% endautoescape %}
+
+{% if 'GET' in resource.allowed_methods %}
+ <div class='action'>
+ <a href='{{ request.path }}'>GET</a>
+ <ul class="accepttypes">
+ {% for media_type in resource.emitted_media_types %}
+ {% with resource.ACCEPT_QUERY_PARAM|add:"="|add:media_type as param %}
+ <li>[<a href='{{ request.path|add_query_param:param }}'>{{ media_type }}</a>]</li>
+ {% endwith %}
+ {% endfor %}
+ </ul>
+ <div class="clearing"></div>
+ </div>
+{% endif %}
+
+{% comment %} *** Only display the POST/PUT/DELETE forms if we have a bound form, and if method ***
+ *** tunneling via POST forms is enabled. ***
+ *** (We could display only the POST form if method tunneling is disabled, but I think ***
+ *** the user experience would be confusing, so we simply turn all forms off. *** {% endcomment %}
+
+{% if resource.METHOD_PARAM and form %}
+ {% if 'POST' in resource.allowed_methods %}
+ <div class='action'>
+ <form action="{{ request.path }}" method="post">
+ {% csrf_token %}
+ {% for field in form %}
+ <div>
+ {{ field.label_tag }}:
+ {{ field }}
+ {{ field.help_text }}
+ {{ field.errors }}
+ </div>
+ {% endfor %}
+ <div class="clearing"></div>
+ <input type="submit" value="POST" />
+ </form>
+ </div>
+ {% endif %}
+
+ {% if 'PUT' in resource.allowed_methods %}
+ <div class='action'>
+ <form action="{{ request.path }}" method="post">
+ <input type="hidden" name="{{ resource.METHOD_PARAM }}" value="PUT" />
+ {% csrf_token %}
+ {% for field in form %}
+ <div>
+ {{ field.label_tag }}:
+ {{ field }}
+ {{ field.help_text }}
+ {{ field.errors }}
+ </div>
+ {% endfor %}
+ <div class="clearing"></div>
+ <input type="submit" value="PUT" />
+ </form>
+ </div>
+ {% endif %}
+
+ {% if 'DELETE' in resource.allowed_methods %}
+ <div class='action'>
+ <form action="{{ request.path }}" method="post">
+ {% csrf_token %}
+ <input type="hidden" name="{{ resource.METHOD_PARAM }}" value="DELETE" />
+ <input type="submit" value="DELETE" />
+ </form>
+ </div>
+ {% endif %}
+{% endif %}
+
+ </body>
+</html>
8 flywheel/templates/emitter.txt
View
@@ -0,0 +1,8 @@
+{{ resource.name }}
+
+{{ resource.description }}
+
+{% autoescape off %}HTTP/1.0 {{ response.status }} {{ response.status_text }}
+{% for key, val in response.headers.items %}{{ key }}: {{ val }}
+{% endfor %}
+{{ content }}{% endautoescape %}
3  flywheel/templates/emitter.xhtml
View
@@ -0,0 +1,3 @@
+HTML:
+
+{{ content }}
0  flywheel/templatetags/__init__.py
View
No changes.
17 flywheel/templatetags/add_query_param.py
View
@@ -0,0 +1,17 @@
+from django.template import Library
+from urlparse import urlparse, urlunparse
+from urllib import quote
+register = Library()
+
+def add_query_param(url, param):
+ (key, val) = param.split('=')
+ param = '%s=%s' % (key, quote(val))
+ (scheme, netloc, path, params, query, fragment) = urlparse(url)
+ if query:
+ query += "&" + param
+ else:
+ query = param
+ return urlunparse((scheme, netloc, path, params, query, fragment))
+
+
+register.filter('add_query_param', add_query_param)
100 flywheel/templatetags/urlize_quoted_links.py
View
@@ -0,0 +1,100 @@
+"""Adds the custom filter 'urlize_quoted_links'
+
+This is identical to the built-in filter 'urlize' with the exception that
+single and double quotes are permitted as leading or trailing punctuation.
+"""
+
+# Almost all of this code is copied verbatim from django.utils.html
+# LEADING_PUNCTUATION and TRAILING_PUNCTUATION have been modified
+import re
+import string
+
+from django.utils.safestring import SafeData, mark_safe
+from django.utils.encoding import force_unicode
+from django.utils.http import urlquote
+from django.utils.html import escape
+from django import template
+
+# Configuration for urlize() function.
+LEADING_PUNCTUATION = ['(', '<', '&lt;', '"', "'"]
+TRAILING_PUNCTUATION = ['.', ',', ')', '>', '\n', '&gt;', '"', "'"]
+
+# List of possible strings used for bullets in bulleted lists.
+DOTS = ['&middot;', '*', '\xe2\x80\xa2', '&#149;', '&bull;', '&#8226;']
+
+unencoded_ampersands_re = re.compile(r'&(?!(\w+|#\d+);)')
+word_split_re = re.compile(r'(\s+)')
+punctuation_re = re.compile('^(?P<lead>(?:%s)*)(?P<middle>.*?)(?P<trail>(?:%s)*)$' % \
+ ('|'.join([re.escape(x) for x in LEADING_PUNCTUATION]),
+ '|'.join([re.escape(x) for x in TRAILING_PUNCTUATION])))
+simple_email_re = re.compile(r'^\S+@[a-zA-Z0-9._-]+\.[a-zA-Z0-9._-]+$')
+link_target_attribute_re = re.compile(r'(<a [^>]*?)target=[^\s>]+')
+html_gunk_re = re.compile(r'(?:<br clear="all">|<i><\/i>|<b><\/b>|<em><\/em>|<strong><\/strong>|<\/?smallcaps>|<\/?uppercase>)', re.IGNORECASE)
+hard_coded_bullets_re = re.compile(r'((?:<p>(?:%s).*?[a-zA-Z].*?</p>\s*)+)' % '|'.join([re.escape(x) for x in DOTS]), re.DOTALL)
+trailing_empty_content_re = re.compile(r'(?:<p>(?:&nbsp;|\s|<br \/>)*?</p>\s*)+\Z')
+
+def urlize_quoted_links(text, trim_url_limit=None, nofollow=False, autoescape=True):
+ """
+ Converts any URLs in text into clickable links.
+
+ Works on http://, https://, www. links and links ending in .org, .net or
+ .com. Links can have trailing punctuation (periods, commas, close-parens)
+ and leading punctuation (opening parens) and it'll still do the right
+ thing.
+
+ If trim_url_limit is not None, the URLs in link text longer than this limit
+ will truncated to trim_url_limit-3 characters and appended with an elipsis.
+
+ If nofollow is True, the URLs in link text will get a rel="nofollow"
+ attribute.
+
+ If autoescape is True, the link text and URLs will get autoescaped.
+ """
+ trim_url = lambda x, limit=trim_url_limit: limit is not None and (len(x) > limit and ('%s...' % x[:max(0, limit - 3)])) or x
+ safe_input = isinstance(text, SafeData)
+ words = word_split_re.split(force_unicode(text))
+ nofollow_attr = nofollow and ' rel="nofollow"' or ''
+ for i, word in enumerate(words):
+ match = None
+ if '.' in word or '@' in word or ':' in word:
+ match = punctuation_re.match(word)
+ if match:
+ lead, middle, trail = match.groups()
+ # Make URL we want to point to.
+ url = None
+ if middle.startswith('http://') or middle.startswith('https://'):
+ url = urlquote(middle, safe='/&=:;#?+*')
+ elif middle.startswith('www.') or ('@' not in middle and \
+ middle and middle[0] in string.ascii_letters + string.digits and \
+ (middle.endswith('.org') or middle.endswith('.net') or middle.endswith('.com'))):
+ url = urlquote('http://%s' % middle, safe='/&=:;#?+*')
+ elif '@' in middle and not ':' in middle and simple_email_re.match(middle):
+ url = 'mailto:%s' % middle
+ nofollow_attr = ''
+ # Make link.
+ if url:
+ trimmed = trim_url(middle)
+ if autoescape and not safe_input:
+ lead, trail = escape(lead), escape(trail)
+ url, trimmed = escape(url), escape(trimmed)
+ middle = '<a href="%s"%s>%s</a>' % (url, nofollow_attr, trimmed)
+ words[i] = mark_safe('%s%s%s' % (lead, middle, trail))
+ else:
+ if safe_input:
+ words[i] = mark_safe(word)
+ elif autoescape:
+ words[i] = escape(word)
+ elif safe_input:
+ words[i] = mark_safe(word)
+ elif autoescape:
+ words[i] = escape(word)
+ return u''.join(words)
+
+
+#urlize_quoted_links.needs_autoescape = True
+urlize_quoted_links.is_safe = True
+
+# Register urlize_quoted_links as a custom filter
+# http://docs.djangoproject.com/en/dev/howto/custom-template-tags/
+register = template.Library()
+register.filter(urlize_quoted_links)
170 flywheel/utils.py
View
@@ -0,0 +1,170 @@
+import re
+import xml.etree.ElementTree as ET
+from django.utils.encoding import smart_unicode
+from django.utils.xmlutils import SimplerXMLGenerator
+try:
+ import cStringIO as StringIO
+except ImportError:
+ import StringIO
+
+# From piston
+def coerce_put_post(request):
+ """
+ Django doesn't particularly understand REST.
+ In case we send data over PUT, Django won't
+ actually look at the data and load it. We need
+ to twist its arm here.
+
+ The try/except abominiation here is due to a bug
+ in mod_python. This should fix it.
+ """
+ if request.method != 'PUT':
+ return
+
+ # Bug fix: if _load_post_and_files has already been called, for
+ # example by middleware accessing request.POST, the below code to
+ # pretend the request is a POST instead of a PUT will be too late
+ # to make a difference. Also calling _load_post_and_files will result
+ # in the following exception:
+ # AttributeError: You cannot set the upload handlers after the upload has been processed.
+ # The fix is to check for the presence of the _post field which is set
+ # the first time _load_post_and_files is called (both by wsgi.py and
+ # modpython.py). If it's set, the request has to be 'reset' to redo
+ # the query value parsing in POST mode.
+ if hasattr(request, '_post'):
+ del request._post
+ del request._files
+
+ try:
+ request.method = "POST"
+ request._load_post_and_files()
+ request.method = "PUT"
+ except AttributeError:
+ request.META['REQUEST_METHOD'] = 'POST'
+ request._load_post_and_files()
+ request.META['REQUEST_METHOD'] = 'PUT'
+
+ request.PUT = request.POST
+
+# From http://www.koders.com/python/fidB6E125C586A6F49EAC38992CF3AFDAAE35651975.aspx?s=mdef:xml
+#class object_dict(dict):
+# """object view of dict, you can
+# >>> a = object_dict()
+# >>> a.fish = 'fish'
+# >>> a['fish']
+# 'fish'
+# >>> a['water'] = 'water'
+# >>> a.water
+# 'water'
+# >>> a.test = {'value': 1}
+# >>> a.test2 = object_dict({'name': 'test2', 'value': 2})
+# >>> a.test, a.test2.name, a.test2.value
+# (1, 'test2', 2)
+# """
+# def __init__(self, initd=None):
+# if initd is None:
+# initd = {}
+# dict.__init__(self, initd)
+#
+# def __getattr__(self, item):
+# d = self.__getitem__(item)
+# # if value is the only key in object, you can omit it
+# if isinstance(d, dict) and 'value' in d and len(d) == 1:
+# return d['value']
+# else:
+# return d
+#
+# def __setattr__(self, item, value):
+# self.__setitem__(item, value)
+
+
+# From xml2dict
+class XML2Dict(object):
+
+ def __init__(self):
+ pass
+
+ def _parse_node(self, node):
+ node_tree = {}
+ # Save attrs and text, hope there will not be a child with same name
+ if node.text:
+ node_tree = node.text
+ for (k,v) in node.attrib.items():
+ k,v = self._namespace_split(k, v)
+ node_tree[k] = v
+ #Save childrens
+ for child in node.getchildren():
+ tag, tree = self._namespace_split(child.tag, self._parse_node(child))
+ if tag not in node_tree: # the first time, so store it in dict
+ node_tree[tag] = tree
+ continue
+ old = node_tree[tag]
+ if not isinstance(old, list):
+ node_tree.pop(tag)
+ node_tree[tag] = [old] # multi times, so change old dict to a list
+ node_tree[tag].append(tree) # add the new one
+
+ return node_tree
+
+
+ def _namespace_split(self, tag, value):
+ """
+ Split the tag '{http://cs.sfsu.edu/csc867/myscheduler}patients'
+ ns = http://cs.sfsu.edu/csc867/myscheduler
+ name = patients
+ """
+ result = re.compile("\{(.*)\}(.*)").search(tag)
+ if result:
+ print tag
+ value.namespace, tag = result.groups()
+ return (tag, value)
+
+ def parse(self, file):
+ """parse a xml file to a dict"""
+ f = open(file, 'r')
+ return self.fromstring(f.read())
+
+ def fromstring(self, s):
+ """parse a string"""
+ t = ET.fromstring(s)
+ unused_root_tag, root_tree = self._namespace_split(t.tag, self._parse_node(t))
+ return root_tree
+
+
+def xml2dict(input):
+ return XML2Dict().fromstring(input)
+
+
+# Piston:
+class XMLEmitter():
+ def _to_xml(self, xml, data):
+ if isinstance(data, (list, tuple)):
+ for item in data:
+ xml.startElement("list-item", {})
+ self._to_xml(xml, item)
+ xml.endElement("list-item")
+
+ elif isinstance(data, dict):
+ for key, value in data.iteritems():
+ xml.startElement(key, {})
+ self._to_xml(xml, value)
+ xml.endElement(key)
+
+ else:
+ xml.characters(smart_unicode(data))
+
+ def dict2xml(self, data):
+ stream = StringIO.StringIO()
+
+ xml = SimplerXMLGenerator(stream, "utf-8")
+ xml.startDocument()
+ xml.startElement("root", {})
+
+ self._to_xml(xml, data)
+
+ xml.endElement("root")
+ xml.endDocument()
+ return stream.getvalue()
+
+def dict2xml(input):
+ return XMLEmitter().dict2xml(input)
Please sign in to comment.
Something went wrong with that request. Please try again.