diff options
-rw-r--r-- | .hgignore | 3 | ||||
-rw-r--r-- | README.txt | 4 | ||||
-rw-r--r-- | docs/conf.py | 220 | ||||
-rw-r--r-- | docs/index.rst | 12 | ||||
-rw-r--r-- | src/rest/modelresource.py | 52 | ||||
-rw-r--r-- | src/rest/parsers.py | 6 | ||||
-rw-r--r-- | src/rest/resource.py | 80 | ||||
-rw-r--r-- | src/rest/status.py | 50 | ||||
-rw-r--r-- | src/testapp/tests.py | 2 |
9 files changed, 391 insertions, 38 deletions
@@ -3,5 +3,8 @@ syntax: glob *.pyc *.db env +cache +html .project .pydevproject +.settings @@ -11,3 +11,7 @@ source ./env/bin/activate pip install -r ./requirements.txt python ./src/manage.py test +# To build the documentation... + +sphinx-build -c docs -b html -d cache docs html + diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 00000000..d1822795 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,220 @@ +# -*- coding: utf-8 -*- +# +# Asset Platform documentation build configuration file, created by +# sphinx-quickstart on Fri Nov 19 20:24:09 2010. +# +# This file is execfile()d with the current directory set to its containing dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. + +import sys, os + +sys.path.append(os.path.join(os.path.dirname(os.path.dirname(__file__)), 'src')) +import settings +from django.core.management import setup_environ +setup_environ(settings) + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +#sys.path.insert(0, os.path.abspath('.')) + +# -- General configuration ----------------------------------------------------- + +# If your documentation needs a minimal Sphinx version, state it here. +#needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be extensions +# coming with Sphinx (named 'sphinx.ext.*') or your custom ones. +extensions = ['sphinx.ext.autodoc', 'sphinx.ext.doctest', 'sphinx.ext.todo', 'sphinx.ext.coverage', 'sphinx.ext.viewcode'] + +# Add any paths that contain templates here, relative to this directory. +templates_path = [] + +# The suffix of source filenames. +source_suffix = '.rst' + +# The encoding of source files. +#source_encoding = 'utf-8-sig' + +# The master toctree document. +master_doc = 'index' + +# General information about the project. +project = u'FlyWheel' +copyright = u'2011, Tom Christie' + +# The version info for the project you're documenting, acts as replacement for +# |version| and |release|, also used in various other places throughout the +# built documents. +# +# The short X.Y version. +version = '0.1' +# The full version, including alpha/beta/rc tags. +release = '0.1' + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +#language = None + +# There are two options for replacing |today|: either, you set today to some +# non-false value, then it is used: +#today = '' +# Else, today_fmt is used as the format for a strftime call. +#today_fmt = '%B %d, %Y' + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +exclude_patterns = ['_build'] + +# The reST default role (used for this markup: `text`) to use for all documents. +#default_role = None + +# If true, '()' will be appended to :func: etc. cross-reference text. +#add_function_parentheses = True + +# If true, the current module name will be prepended to all description +# unit titles (such as .. function::). +#add_module_names = True + +# If true, sectionauthor and moduleauthor directives will be shown in the +# output. They are ignored by default. +#show_authors = False + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + +# A list of ignored prefixes for module index sorting. +#modindex_common_prefix = [] + + +# -- Options for HTML output --------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +html_theme = 'default' + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +#html_theme_options = {} + +# Add any paths that contain custom themes here, relative to this directory. +#html_theme_path = [] + +# The name for this set of Sphinx documents. If None, it defaults to +# "<project> v<release> documentation". +#html_title = None + +# A shorter title for the navigation bar. Default is the same as html_title. +#html_short_title = None + +# The name of an image file (relative to this directory) to place at the top +# of the sidebar. +#html_logo = None + +# The name of an image file (within the static path) to use as favicon of the +# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 +# pixels large. +#html_favicon = None + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = [] + +# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, +# using the given strftime format. +#html_last_updated_fmt = '%b %d, %Y' + +# If true, SmartyPants will be used to convert quotes and dashes to +# typographically correct entities. +#html_use_smartypants = True + +# Custom sidebar templates, maps document names to template names. +#html_sidebars = {} + +# Additional templates that should be rendered to pages, maps page names to +# template names. +#html_additional_pages = {} + +# If false, no module index is generated. +#html_domain_indices = True + +# If false, no index is generated. +#html_use_index = True + +# If true, the index is split into individual pages for each letter. +#html_split_index = False + +# If true, links to the reST sources are added to the pages. +#html_show_sourcelink = True + +# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. +#html_show_sphinx = True + +# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. +#html_show_copyright = True + +# If true, an OpenSearch description file will be output, and all pages will +# contain a <link> tag referring to it. The value of this option must be the +# base URL from which the finished HTML is served. +#html_use_opensearch = '' + +# This is the file name suffix for HTML files (e.g. ".xhtml"). +#html_file_suffix = None + +# Output file base name for HTML help builder. +htmlhelp_basename = 'restfulloggingdoc' + + +# -- Options for LaTeX output -------------------------------------------------- + +# The paper size ('letter' or 'a4'). +#latex_paper_size = 'letter' + +# The font size ('10pt', '11pt' or '12pt'). +#latex_font_size = '10pt' + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, author, documentclass [howto/manual]). +latex_documents = [ + ('index', 'restfullogging.tex', u'restful logging Documentation', + u'tom c', 'manual'), +] + +# The name of an image file (relative to this directory) to place at the top of +# the title page. +#latex_logo = None + +# For "manual" documents, if this is true, then toplevel headings are parts, +# not chapters. +#latex_use_parts = False + +# If true, show page references after internal links. +#latex_show_pagerefs = False + +# If true, show URL addresses after external links. +#latex_show_urls = False + +# Additional stuff for the LaTeX preamble. +#latex_preamble = '' + +# Documents to append as an appendix to all manuals. +#latex_appendices = [] + +# If false, no module index is generated. +#latex_domain_indices = True + +# -- Options for manual page output -------------------------------------------- + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + ('index', 'restfullogging', u'restful logging Documentation', + [u'tom c'], 1) +] diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 00000000..4d063c45 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,12 @@ +FlyWheel Documentation +====================== + +This is the online documentation for FlyWheel - A REST framework for Django. + +Indices and tables +------------------ + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` + diff --git a/src/rest/modelresource.py b/src/rest/modelresource.py index 39358d9b..6719a9ed 100644 --- a/src/rest/modelresource.py +++ b/src/rest/modelresource.py @@ -12,10 +12,38 @@ import re class ModelResource(Resource): + """A specialized type of Resource, for RESTful 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_bound_form(self, data=None, is_response=False): """Return a form that may be used in validation and/or rendering an html emitter""" if self.form: @@ -25,7 +53,7 @@ class ModelResource(Resource): class NewModelForm(ModelForm): class Meta: model = self.model - fields = self.form_fields if self.form_fields else None #self.fields + fields = self.form_fields if self.form_fields else None if data and not is_response: return NewModelForm(data) @@ -36,7 +64,27 @@ class ModelResource(Resource): else: 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): diff --git a/src/rest/parsers.py b/src/rest/parsers.py index 73073243..ac449e49 100644 --- a/src/rest/parsers.py +++ b/src/rest/parsers.py @@ -1,4 +1,5 @@ import json +from rest.status import ResourceException, Status class BaseParser(object): def __init__(self, resource): @@ -10,7 +11,10 @@ class BaseParser(object): class JSONParser(BaseParser): def parse(self, input): - return json.loads(input) + try: + return json.loads(input) + except ValueError, exc: + raise ResourceException(Status.HTTP_400_BAD_REQUEST, {'detail': 'JSON parse error - %s' % str(exc)}) class XMLParser(BaseParser): pass diff --git a/src/rest/resource.py b/src/rest/resource.py index e66cb357..b94854f5 100644 --- a/src/rest/resource.py +++ b/src/rest/resource.py @@ -1,39 +1,29 @@ -from django.http import HttpResponse from django.contrib.sites.models import Site from django.core.urlresolvers import reverse from django.core.handlers.wsgi import STATUS_CODE_TEXT +from django.http import HttpResponse from rest import emitters, parsers +from rest.status import Status, ResourceException from decimal import Decimal import re +# TODO: Authentication # TODO: Display user login in top panel: http://stackoverflow.com/questions/806835/django-redirect-to-previous-page-after-login -# TODO: Return basic object, not tuple +# TODO: Return basic object, not tuple of status code, content, headers # TODO: Take request, not headers -# TODO: Remove self.blah munging (Add a ResponseContext object) -# TODO: Erroring on non-existent fields -# TODO: Standard exception classes and module for status codes +# TODO: Standard exception classes # TODO: Figure how out references and named urls need to work nicely # TODO: POST on existing 404 URL, PUT on existing 404 URL -# TODO: Authentication +# +# 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 -# -STATUS_400_BAD_REQUEST = 400 -STATUS_405_METHOD_NOT_ALLOWED = 405 -STATUS_406_NOT_ACCEPTABLE = 406 -STATUS_415_UNSUPPORTED_MEDIA_TYPE = 415 -STATUS_500_INTERNAL_SERVER_ERROR = 500 -STATUS_501_NOT_IMPLEMENTED = 501 - - -class ResourceException(Exception): - def __init__(self, status, content='', headers={}): - self.status = status - self.content = content - self.headers = headers class Resource(object): @@ -110,13 +100,16 @@ class Resource(object): def reverse(self, view, *args, **kwargs): """Return a fully qualified URI for a given view or resource. - Use the Sites framework if possible, otherwise fallback to using the current request.""" + Add the domain using the Sites framework if possible, otherwise fallback to using the current request.""" return self.add_domain(reverse(view, *args, **kwargs)) 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': @@ -150,7 +143,7 @@ class Resource(object): def not_implemented(self, operation): """Return an HTTP 500 server error if an operation is called which has been allowed by allowed_operations, but which has not been implemented.""" - raise ResourceException(STATUS_500_INTERNAL_SERVER_ERROR, + raise ResourceException(Status.HTTP_500_INTERNAL_SERVER_ERROR, {'detail': '%s operation on this resource has not been implemented' % (operation, )}) @@ -172,18 +165,18 @@ class Resource(object): # if anon_user and not anon_allowed_operations raise PermissionDenied # return + def check_method_allowed(self, method): """Ensure the request method is acceptable for this resource.""" if not method in self.CALLMAP.keys(): - raise ResourceException(STATUS_501_NOT_IMPLEMENTED, + raise ResourceException(Status.HTTP_501_NOT_IMPLEMENTED, {'detail': 'Unknown or unsupported method \'%s\'' % method}) if not self.CALLMAP[method] in self.allowed_operations: - raise ResourceException(STATUS_405_METHOD_NOT_ALLOWED, + raise ResourceException(Status.HTTP_405_METHOD_NOT_ALLOWED, {'detail': 'Method \'%s\' not allowed on this resource.' % method}) - def get_bound_form(self, data=None, is_response=False): """Optionally return a Django Form instance, which may be used for validation and/or rendered by an HTML/XHTML emitter. @@ -207,16 +200,31 @@ class Resource(object): By default this uses form validation to filter the basic input into the required types.""" if form_instance is None: return data - - if not form_instance.is_valid(): - if not form_instance.errors: + + # 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['_extra'] = self.form.non_field_errors() + details['errors'] = self.form.non_field_errors() - raise ResourceException(STATUS_400_BAD_REQUEST, {'detail': details}) + # 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 ResourceException(Status.HTTP_400_BAD_REQUEST, {'detail': details}) return form_instance.cleaned_data @@ -241,7 +249,7 @@ class Resource(object): try: return self.parsers[content_type] except KeyError: - raise ResourceException(STATUS_415_UNSUPPORTED_MEDIA_TYPE, + raise ResourceException(Status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, {'detail': 'Unsupported media type \'%s\'' % content_type}) @@ -295,14 +303,13 @@ class Resource(object): (accept_mimetype == mimetype)): return (mimetype, emitter) - raise ResourceException(STATUS_406_NOT_ACCEPTABLE, + raise ResourceException(Status.HTTP_406_NOT_ACCEPTABLE, {'detail': 'Could not statisfy the client\'s accepted content type', 'accepted_types': [item[0] for item in self.emitters]}) def _handle_request(self, request, *args, **kwargs): """ - Broadly this consists of the following procedure: 0. ensure the operation is permitted @@ -347,9 +354,14 @@ class Resource(object): except ResourceException, exc: + # On exceptions we still serialize the response appropriately (self.resp_status, ret, self.resp_headers) = (exc.status, exc.content, exc.headers) + + # Fall back to the default emitter if we failed to perform content negotiation if emitter is None: - mimetype, emitter = self.emitters[0] + mimetype, emitter = self.emitters[0] + + # Provide an empty bound form if we do not have an existing form and if one is required if self.form_instance is None and emitter.uses_forms: self.form_instance = self.get_bound_form() diff --git a/src/rest/status.py b/src/rest/status.py new file mode 100644 index 00000000..d1b49d69 --- /dev/null +++ b/src/rest/status.py @@ -0,0 +1,50 @@ + +class Status(object): + """Descriptive HTTP status codes, for code readability.""" + 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_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_100_CONTINUE = 100 + HTTP_101_SWITCHING_PROTOCOLS = 101 + 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_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 + +class ResourceException(Exception): + def __init__(self, status, content='', headers={}): + self.status = status + self.content = content + self.headers = headers diff --git a/src/testapp/tests.py b/src/testapp/tests.py index e37c57c0..3fcfc9e1 100644 --- a/src/testapp/tests.py +++ b/src/testapp/tests.py @@ -4,7 +4,7 @@ from django.test import TestCase from django.core.urlresolvers import reverse from testapp import views -import json +#import json #from rest.utils import xml2dict, dict2xml |