[Checkins] SVN: z3c.conditionalviews/ Create a new project that
implements validating conditional HTTP requests.
Michael Kerrin
michael.kerrin at openapp.ie
Thu May 31 14:51:08 EDT 2007
Log message for revision 76052:
Create a new project that implements validating conditional HTTP requests.
This is an other piece of the WebDAV puzzle.
Changed:
A z3c.conditionalviews/
A z3c.conditionalviews/trunk/
A z3c.conditionalviews/trunk/README.txt
A z3c.conditionalviews/trunk/buildout.cfg
A z3c.conditionalviews/trunk/setup.py
A z3c.conditionalviews/trunk/src/
A z3c.conditionalviews/trunk/src/z3c/
A z3c.conditionalviews/trunk/src/z3c/__init__.py
A z3c.conditionalviews/trunk/src/z3c/conditionalviews/
A z3c.conditionalviews/trunk/src/z3c/conditionalviews/README.txt
A z3c.conditionalviews/trunk/src/z3c/conditionalviews/__init__.py
A z3c.conditionalviews/trunk/src/z3c/conditionalviews/adapters.py
A z3c.conditionalviews/trunk/src/z3c/conditionalviews/configure.zcml
A z3c.conditionalviews/trunk/src/z3c/conditionalviews/etag.py
A z3c.conditionalviews/trunk/src/z3c/conditionalviews/ftesting.zcml
A z3c.conditionalviews/trunk/src/z3c/conditionalviews/interfaces.py
A z3c.conditionalviews/trunk/src/z3c/conditionalviews/lastmodification.py
A z3c.conditionalviews/trunk/src/z3c/conditionalviews/tests.py
A z3c.conditionalviews/trunk/src/z3c/conditionalviews/validation.txt
-=-
Added: z3c.conditionalviews/trunk/README.txt
===================================================================
--- z3c.conditionalviews/trunk/README.txt (rev 0)
+++ z3c.conditionalviews/trunk/README.txt 2007-05-31 18:51:07 UTC (rev 76052)
@@ -0,0 +1,3 @@
+This package intergrates with the zope publisher to validate conditional
+requests based on the `If-None-Match`, `If-Match`, and `If-Modified-Since`,
+`If-UnModifiedSince` protocols.
Property changes on: z3c.conditionalviews/trunk/README.txt
___________________________________________________________________
Name: svn:eol-style
+ native
Added: z3c.conditionalviews/trunk/buildout.cfg
===================================================================
--- z3c.conditionalviews/trunk/buildout.cfg (rev 0)
+++ z3c.conditionalviews/trunk/buildout.cfg 2007-05-31 18:51:07 UTC (rev 76052)
@@ -0,0 +1,9 @@
+[buildout]
+develop = .
+parts = test
+
+[test]
+recipe = zc.recipe.testrunner
+working-directory = .
+defaults = ["--tests-pattern", "^f?tests$"]
+eggs = z3c.conditionalviews [test]
Added: z3c.conditionalviews/trunk/setup.py
===================================================================
--- z3c.conditionalviews/trunk/setup.py (rev 0)
+++ z3c.conditionalviews/trunk/setup.py 2007-05-31 18:51:07 UTC (rev 76052)
@@ -0,0 +1,24 @@
+from setuptools import setup, find_packages
+
+setup(
+ name = "z3c.conditionalviews",
+ version = "0.8",
+ author = "Michael Kerrin",
+ author_email = "michael.kerrin at openapp.ie",
+ url = "http://svn.zope.org/z3c.conditionalviews/",
+ description = "Validation mechanism for conditional HTTP requests.",
+ long_description = open("README.txt").read(),
+ license = "ZPL 2.1",
+
+ packages = find_packages("src"),
+ package_dir = {"": "src"},
+ namespace_packages = ["z3c"],
+ install_requires = ["setuptools",
+ "zope.component",
+ "zope.schema"],
+ extras_require = dict(test = ["zope.app.testing",
+ "zope.app.zcmlfiles",
+ "zope.app.securitypolicy",
+ ]),
+ zip_safe = False,
+ )
Added: z3c.conditionalviews/trunk/src/z3c/__init__.py
===================================================================
--- z3c.conditionalviews/trunk/src/z3c/__init__.py (rev 0)
+++ z3c.conditionalviews/trunk/src/z3c/__init__.py 2007-05-31 18:51:07 UTC (rev 76052)
@@ -0,0 +1 @@
+__import__('pkg_resources').declare_namespace(__name__)
Added: z3c.conditionalviews/trunk/src/z3c/conditionalviews/README.txt
===================================================================
--- z3c.conditionalviews/trunk/src/z3c/conditionalviews/README.txt (rev 0)
+++ z3c.conditionalviews/trunk/src/z3c/conditionalviews/README.txt 2007-05-31 18:51:07 UTC (rev 76052)
@@ -0,0 +1,384 @@
+===================
+z3c.condtionalviews
+===================
+
+z3c.conditionalviews is a mechanism to validate a HTTP request based on some
+conditional protocol like entity tags, or last modification date. It is also
+extensible so that protocols like WebDAV can define there own conditional
+protocol like the IF header.
+
+It works by implementing each conditional protocol as a `IHTTPValidator`
+utility, see etag and lastmodification modules for the most common use cases.
+Then when certain views are called by the publisher we lookup these utilities
+and ask them to validate the request object according to whatever protocol
+the utility implements.
+
+At the time of the view is called, and when we validate the request, we
+generally have access to the context, request and view itself. So the
+`IHTTPValidator` utilities generally adapt these 3 objects to an object
+implementing an interface specific to the protocol in question. For example
+the entity tag validator looks up an adapter implementing `IEtag`.
+
+Integration with Zope
+=====================
+
+ >>> import zope.component
+ >>> import zope.interface
+ >>> import z3c.conditionalviews.interfaces
+ >>> import z3c.conditionalviews.tests
+
+Decorator
+---------
+
+In order to integrate common browser views that can be cached, we can decorate
+the views call method with the `z3c.conditionalviews.ConditionalView` object.
+Note that all the views used in this test are defined in the ftesting.zcml
+file.
+
+ >>> response = http(r"""
+ ... GET /@@simpleview.html HTTP/1.1
+ ... Host: localhost
+ ... """, handle_errors = False)
+ >>> response.getStatus()
+ 200
+ >>> response.getHeader('content-length')
+ '82'
+ >>> response.getHeader('content-type')
+ 'text/plain'
+ >>> print response.getBody()
+ xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
+ xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
+
+Since we haven't yet defined an adapter implementing IETag, the response
+contains no ETag header.
+
+ >>> response.getHeader('ETag') is None
+ True
+
+Define our IETag implementation.
+
+ >>> class SimpleEtag(object):
+ ... zope.interface.implements(z3c.conditionalviews.interfaces.IETag)
+ ... def __init__(self, context, request, view):
+ ... pass
+ ... weak = False
+ ... etag = "3d32b-211-bab57a40"
+
+ >>> zope.component.getGlobalSiteManager().registerAdapter(
+ ... SimpleEtag,
+ ... (zope.interface.Interface,
+ ... zope.publisher.interfaces.browser.IBrowserRequest,
+ ... zope.interface.Interface))
+
+ >>> response = http(r"""
+ ... GET /@@simpleview.html HTTP/1.1
+ ... Host: localhost
+ ... """, handle_errors = False)
+ >>> response.getStatus()
+ 200
+ >>> response.getHeader('content-length')
+ '82'
+ >>> response.getHeader('content-type')
+ 'text/plain'
+ >>> response.getHeader('ETag')
+ '"3d32b-211-bab57a40"'
+ >>> print response.getBody()
+ xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
+ xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
+
+Now by setting the request header If-None-Match: "3d32b-211-bab57a40", our
+view fails the validation and a 304 response is returned.
+
+ >>> response = http(r"""
+ ... GET /@@simpleview.html HTTP/1.1
+ ... Host: localhost
+ ... If-None-Match: "3d32b-211-bab57a40"
+ ... """, handle_errors = False)
+ >>> response.getStatus()
+ 304
+ >>> response.getHeader('ETag')
+ '"3d32b-211-bab57a40"'
+ >>> response.getBody()
+ ''
+
+XXX - this seems wrong the content-length and content-type should not be set
+for this response.
+
+ >>> response.getHeader('content-length')
+ '0'
+ >>> response.getHeader('content-type')
+ 'text/plain'
+
+Now make sure that we haven't broken the publisher, by making sure that we
+can still pass arguments to the different views.
+
+ >>> response = http(r"""
+ ... GET /@@simpleview.html?letter=y HTTP/1.1
+ ... Host: localhost
+ ... """, handle_errors = False)
+ >>> response.getStatus()
+ 200
+ >>> response.getHeader('content-length')
+ '82'
+ >>> print response.getBody()
+ yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
+ yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
+
+We are now getting a charset value for this request because the default
+value for the SimpleView is not a unicode string, while the data received
+from the request is automatically converted to unicode by default.
+
+ >>> response.getHeader('content-type')
+ 'text/plain;charset=utf-8'
+
+Since there is a query string present in the request, we don't set the ETag
+header.
+
+ >>> response.getHeader('ETag') is None
+ True
+
+The query string present in the following request causes the request to
+be valid, otherwise it would be invalid.
+
+ >>> response = http(r"""
+ ... GET /@@simpleview.html?letter=y HTTP/1.1
+ ... If-None-Match: "3d32b-211-bab57a40"
+ ... Host: localhost
+ ... """, handle_errors = False)
+ >>> response.getStatus()
+ 200
+
+Generic HTTP conditional publication
+====================================
+
+We can integrate the validation method with the publication call method. This
+as the effect of trying to validate every request that passes through the
+publications `callObject` method. This is useful to validate requests that
+modify objects so that the client can say modify this resource if it hasn't
+changed since it last downloaded the resource, or if there is no existing
+resource at a location.
+
+This has the added benifit in that we don't have to specify how some one
+implements the PUT method.
+
+ >>> resp = http(r"""
+ ... PUT /testfile HTTP/1.1
+ ... Authorization: Basic mgr:mgrpw
+ ... Content-type: text/plain
+ ... Content-length: 55
+ ... aaaaaaaaaa
+ ... aaaaaaaaaa
+ ... aaaaaaaaaa
+ ... aaaaaaaaaa
+ ... aaaaaaaaaa""", handle_errors = False)
+ >>> resp.getStatus()
+ 201
+ >>> resp.getHeader('Content-length')
+ '0'
+ >>> resp.getHeader('Location')
+ 'http://localhost/testfile'
+ >>> resp.getHeader('ETag', None) is None
+ True
+
+We can now get the resource and the entity tag.
+
+ >>> resp = http(r"""
+ ... GET /testfile HTTP/1.1
+ ... Authorization: Basic mgr:mgrpw
+ ... """)
+ >>> resp.getStatus()
+ 200
+ >>> resp.getHeader('ETag')
+ '"testfile:1"'
+ >>> print resp.getBody()
+ aaaaaaaaaa
+ aaaaaaaaaa
+ aaaaaaaaaa
+ aaaaaaaaaa
+ aaaaaaaaaa
+
+We could have used the HEAD method to get the entity tag.
+
+ >>> resp = http(r"""
+ ... HEAD /testfile HTTP/1.1
+ ... Authorization: Basic mgr:mgrpw
+ ... """)
+ >>> resp.getStatus()
+ 200
+ >>> resp.getHeader('ETag')
+ '"testfile:1"'
+
+With no 'If-None-Match' header we override the data.
+
+ >>> resp = http(r"""
+ ... PUT /testfile HTTP/1.1
+ ... Authorization: Basic mgr:mgrpw
+ ... Content-type: text/plain
+ ... Content-length: 55
+ ... bbbbbbbbbb
+ ... bbbbbbbbbb
+ ... bbbbbbbbbb
+ ... bbbbbbbbbb
+ ... bbbbbbbbbb""", handle_errors = False)
+ >>> resp.getStatus()
+ 200
+ >>> resp.getHeader('Content-length')
+ '0'
+ >>> resp.getHeader('Location', None) is None
+ True
+ >>> resp.getHeader('ETag')
+ '"testfile:2"'
+
+ >>> resp = http(r"""
+ ... GET /testfile HTTP/1.1
+ ... Authorization: Basic mgr:mgrpw
+ ... """)
+ >>> resp.getStatus()
+ 200
+ >>> print resp.getBody()
+ bbbbbbbbbb
+ bbbbbbbbbb
+ bbbbbbbbbb
+ bbbbbbbbbb
+ bbbbbbbbbb
+
+Specifying a `If-None-Match: "*"` header, says to upload the data only if there
+is no resource at the location specified in the request URI. If there is a
+resource at the location then a `412 Precondition Failed` response is
+returned and the resource is not modified'
+
+ >>> resp = http(r"""
+ ... PUT /testfile HTTP/1.1
+ ... Authorization: Basic mgr:mgrpw
+ ... If-None-Match: "*"
+ ... Content-type: text/plain
+ ... Content-length: 55
+ ... cccccccccc
+ ... cccccccccc
+ ... cccccccccc
+ ... cccccccccc
+ ... cccccccccc""")
+ >>> resp.getStatus()
+ 412
+ >>> resp.getHeader('Content-length')
+ '0'
+ >>> resp.getHeader('Location', None) is None
+ True
+ >>> resp.getHeader('ETag')
+ '"testfile:2"'
+
+The file does not change.
+
+ >>> resp = http(r"""
+ ... GET /testfile HTTP/1.1
+ ... Authorization: Basic mgr:mgrpw
+ ... """)
+ >>> resp.getStatus()
+ 200
+ >>> print resp.getBody()
+ bbbbbbbbbb
+ bbbbbbbbbb
+ bbbbbbbbbb
+ bbbbbbbbbb
+ bbbbbbbbbb
+
+And now since testfile2 does exist yet we content the content.
+
+ >>> resp = http(r"""
+ ... PUT /testfile2 HTTP/1.1
+ ... Authorization: Basic mgr:mgrpw
+ ... If-None-Match: "*"
+ ... Content-type: text/plain
+ ... Content-length: 55
+ ... yyyyyyyyyy
+ ... yyyyyyyyyy
+ ... yyyyyyyyyy
+ ... yyyyyyyyyy
+ ... yyyyyyyyyy""")
+ >>> resp.getStatus()
+ 201
+ >>> resp.getHeader('Content-length')
+ '0'
+ >>> resp.getHeader('Location')
+ 'http://localhost/testfile2'
+ >>> resp.getHeader('ETag', None) is None # No etag adapter is configured
+ True
+
+ >>> resp = http(r"""
+ ... GET /testfile2 HTTP/1.1
+ ... Authorization: Basic mgr:mgrpw
+ ... """)
+ >>> resp.getStatus()
+ 200
+ >>> print resp.getBody()
+ yyyyyyyyyy
+ yyyyyyyyyy
+ yyyyyyyyyy
+ yyyyyyyyyy
+ yyyyyyyyyy
+
+We can now delete the resource, only if it hasn't changed. So for the
+'/testfile' resource we can use its first entity tag to confirm this.
+
+ >>> resp = http(r"""
+ ... DELETE /testfile HTTP/1.1
+ ... Authorization: Basic mgr:mgrpw
+ ... If-Match: "testfile:1"
+ ... """)
+ >>> resp.getStatus()
+ 412
+
+And the file still exists.
+
+ >>> resp = http(r"""
+ ... GET /testfile HTTP/1.1
+ ... Authorization: Basic mgr:mgrpw
+ ... """)
+ >>> resp.getStatus()
+ 200
+
+But using a valid entity tag we can delete the resource.
+
+ >>> resp = http(r"""
+ ... DELETE /testfile HTTP/1.1
+ ... Authorization: Basic mgr:mgrpw
+ ... If-Match: "testfile:2"
+ ... """)
+ >>> resp.getStatus()
+ 200
+ >>> resp.getBody()
+ ''
+
+ >>> resp = http(r"""
+ ... GET /testfile HTTP/1.1
+ ... Authorization: Basic mgr:mgrpw
+ ... """)
+ >>> resp.getStatus()
+ 404
+
+Method not allowed
+==================
+
+We should still get a `405 Method Not Allowed` status for methods that aren't
+registered yet.
+
+We need to be logged in order to traverse to the file.
+
+ >>> resp = http(r"""
+ ... FROG /testfile2 HTTP/1.1
+ ... Authorization: Basic mgr:mgrpw
+ ... """)
+ >>> resp.getStatus()
+ 405
+ >>> resp.getHeader('ETag', None) is None
+ True
+
+Cleanup
+=======
+
+ >>> zope.component.getGlobalSiteManager().unregisterAdapter(
+ ... SimpleEtag,
+ ... (zope.interface.Interface,
+ ... zope.publisher.interfaces.browser.IBrowserRequest,
+ ... zope.interface.Interface))
+ True
Property changes on: z3c.conditionalviews/trunk/src/z3c/conditionalviews/README.txt
___________________________________________________________________
Name: svn:eol-style
+ native
Added: z3c.conditionalviews/trunk/src/z3c/conditionalviews/__init__.py
===================================================================
--- z3c.conditionalviews/trunk/src/z3c/conditionalviews/__init__.py (rev 0)
+++ z3c.conditionalviews/trunk/src/z3c/conditionalviews/__init__.py 2007-05-31 18:51:07 UTC (rev 76052)
@@ -0,0 +1,114 @@
+##############################################################################
+# Copyright (c) 2007 Zope Corporation and Contributors.
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL). A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+##############################################################################
+
+import zope.component
+import zope.publisher.http
+import zope.publisher.publish
+import zope.publisher.interfaces
+import zope.app.http.interfaces
+import zope.app.publication.http
+import zope.app.publication.interfaces
+
+import interfaces
+
+def validate(context, request, func, viewobj, *args, **kw):
+ # count the number of invalid and evaulated validators, if evaluated is
+ # greater then zero and equal to hte invalid count then the request is
+ # invalid.
+ evaluated = invalid = 0
+
+ validators = [validator
+ for validator_name, validator in
+ zope.component.getUtilitiesFor(interfaces.IHTTPValidator)]
+
+ invalidStatus = 999
+ for validator in validators:
+ if validator.evaluate(context, request, viewobj):
+ evaluated += 1
+ invalid += not validator.valid(context, request, viewobj)
+ invalidStatus = validator.invalidStatus(context, request, viewobj)
+
+ if evaluated > 0 and evaluated == invalid:
+ # The request is invalid so we do not process it.
+ request.response.setStatus(invalidStatus)
+ result = ""
+ else:
+ # The request is valid so we do process it.
+ result = func(viewobj, *args, **kw)
+
+ for validator in validators:
+ validator.updateResponse(context, request, viewobj)
+
+ return result
+
+
+class BoundConditionalView(object):
+ def __init__(self, pt, ob):
+ object.__setattr__(self, "im_func", pt)
+ object.__setattr__(self, "im_self", ob)
+
+ def __call__(self, *args, **kw):
+ args = (self.im_self,) + args
+ return validate(self.im_self.context, self.im_self.request,
+ self.im_func, *args, **kw)
+
+###############################################################################
+#
+# Decorators to turn a specific view into a conditonal view. For example
+# file downloads / upload views.
+#
+###############################################################################
+
+class ConditionalView(object):
+ def __init__(self, viewmethod):
+ self.viewmethod = viewmethod
+
+ def __get__(self, instance, class_):
+ return BoundConditionalView(self.viewmethod, instance)
+
+###############################################################################
+#
+# Generic publication object to turn a whole set of request handlers into
+# conditional views.
+#
+###############################################################################
+
+class ConditionalHTTPRequest(zope.publisher.http.HTTPRequest):
+ zope.interface.classProvides(
+ zope.app.publication.interfaces.IHTTPRequestFactory)
+
+ def setPublication(self, publication):
+ super(ConditionalHTTPRequest, self).setPublication(
+ ConditionalPublication(publication))
+
+
+class ConditionalPublication(object):
+
+ def __init__(self, publication):
+ self._publication = publication
+ for name in zope.publisher.interfaces.IPublication:
+ if name not in ("callObject",):
+ setattr(self, name, getattr(publication, name))
+
+ def callObject(self, request, ob):
+ # Exception handling, dont try to call request.method
+ if not zope.app.http.interfaces.IHTTPException.providedBy(ob):
+ view = zope.component.queryMultiAdapter(
+ (ob, request), name = request.method)
+ method = getattr(view, request.method, None)
+ if method is None:
+ raise zope.app.publication.http.MethodNotAllowed(ob, request)
+
+ ob = BoundConditionalView(method.im_func, method.im_self)
+
+ return zope.publisher.publish.mapply(
+ ob, request.getPositionalArguments(), request)
Added: z3c.conditionalviews/trunk/src/z3c/conditionalviews/adapters.py
===================================================================
--- z3c.conditionalviews/trunk/src/z3c/conditionalviews/adapters.py (rev 0)
+++ z3c.conditionalviews/trunk/src/z3c/conditionalviews/adapters.py 2007-05-31 18:51:07 UTC (rev 76052)
@@ -0,0 +1,69 @@
+##############################################################################
+# Copyright (c) 2007 Zope Corporation and Contributors.
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL). A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+##############################################################################
+
+import zope.interface
+import zope.dublincore.interfaces
+
+import interfaces
+
+class LastModificationDate(object):
+ """
+ >>> import datetime
+ >>> import zope.component
+ >>> import zope.publisher.browser
+ >>> from zope.interface.verify import verifyObject
+
+ >>> lmt = datetime.datetime(2007, 3, 2, 13, 34, 23)
+ >>> class SimpleContent(object):
+ ... def __init__(self):
+ ... self.lastmodified = lmt
+
+ >>> class DublinCore(object):
+ ... zope.interface.implements(
+ ... zope.dublincore.interfaces.IZopeDublinCore)
+ ... zope.component.adapts(SimpleContent)
+ ... def __init__(self, context):
+ ... self.context = context
+ ... @property
+ ... def modified(self):
+ ... return self.context.lastmodified
+
+ >>> zope.component.getGlobalSiteManager().registerAdapter(DublinCore)
+
+ >>> class SimpleView(zope.publisher.browser.BrowserView):
+ ... pass
+
+ >>> content = SimpleContent()
+
+ Adapting our simple view goes us our desired result.
+
+ >>> validatordata = LastModificationDate(
+ ... content, None, SimpleView(content, None))
+ >>> verifyObject(interfaces.ILastModificationDate, validatordata)
+ True
+ >>> validatordata.lastmodified == lmt
+ True
+
+ Cleanup
+
+ >>> zope.component.getGlobalSiteManager().unregisterAdapter(DublinCore)
+ True
+
+ """
+ zope.interface.implements(interfaces.ILastModificationDate)
+
+ def __init__(self, context, request, view):
+ self.dcadapter = zope.dublincore.interfaces.IZopeDublinCore(context)
+
+ @property
+ def lastmodified(self):
+ return self.dcadapter.modified
Added: z3c.conditionalviews/trunk/src/z3c/conditionalviews/configure.zcml
===================================================================
--- z3c.conditionalviews/trunk/src/z3c/conditionalviews/configure.zcml (rev 0)
+++ z3c.conditionalviews/trunk/src/z3c/conditionalviews/configure.zcml 2007-05-31 18:51:07 UTC (rev 76052)
@@ -0,0 +1,20 @@
+<configure xmlns="http://namespaces.zope.org/zope">
+
+ <class class="z3c.conditionalviews.BoundConditionalView">
+ <require
+ attributes="__call__"
+ permission="zope.Public"
+ />
+ </class>
+
+ <utility
+ factory=".lastmodification.ModifiedSinceValidator"
+ name="http.modifiedsince"
+ />
+
+ <utility
+ factory=".etag.ETagValidator"
+ name="http.etag"
+ />
+
+</configure>
Added: z3c.conditionalviews/trunk/src/z3c/conditionalviews/etag.py
===================================================================
--- z3c.conditionalviews/trunk/src/z3c/conditionalviews/etag.py (rev 0)
+++ z3c.conditionalviews/trunk/src/z3c/conditionalviews/etag.py 2007-05-31 18:51:07 UTC (rev 76052)
@@ -0,0 +1,487 @@
+##############################################################################
+# Copyright (c) 2007 Zope Corporation and Contributors.
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL). A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+##############################################################################
+
+import zope.interface
+import zope.component
+from zope.app.http.interfaces import INullResource
+
+import interfaces
+
+class ETagValidator(object):
+ """
+
+ >>> from zope.interface.verify import verifyObject
+ >>> from zope.publisher.interfaces.browser import IBrowserRequest
+ >>> from zope.publisher.browser import TestRequest
+ >>> from zope.publisher.browser import BrowserView
+
+ >>> class SimpleView(BrowserView):
+ ... def __call__(self):
+ ... self.request.response.setStatus(200)
+ ... self.request.response.setHeader('ETag', '"xxxetag"')
+ ... return 'Rendered view representation'
+
+ The ETagValidator is a HTTP utility validator that implements the entity
+ tag protocol.
+
+ >>> validator = ETagValidator()
+ >>> verifyObject(interfaces.IHTTPValidator, validator)
+ True
+
+ We need to make sure that we can corrctly parse any `If-Match`, or
+ `If-None-Match` HTTP header.
+
+ >>> request = TestRequest(environ = {
+ ... 'NOQUOTE': 'aa',
+ ... 'ONE': '"aa"',
+ ... 'TWO': '"aa", "bb"',
+ ... 'ALL': '"aa", *, "bb"',
+ ... 'WEAK': 'W/"w1"',
+ ... 'WEAK_TWO': 'W/"w1", W/"w2"',
+ ... })
+ >>> view = SimpleView(None, request)
+
+ >>> validator.parseMatchList(request, 'missing')
+ []
+ >>> validator.parseMatchList(request, 'noquote')
+ []
+ >>> validator.parseMatchList(request, 'one')
+ ['aa']
+ >>> validator.parseMatchList(request, 'two')
+ ['aa', 'bb']
+ >>> validator.parseMatchList(request, 'all')
+ ['aa', '*', 'bb']
+ >>> validator.parseMatchList(request, 'weak')
+ ['w1']
+ >>> validator.parseMatchList(request, 'weak_two')
+ ['w1', 'w2']
+
+ When neither a `If-None-Match` or a `If-Match` header is present in the
+ request then we cannot evaluate this request as a conditional request,
+ according to the `If-None-Match` or `If-Match` protocol.
+
+ >>> validator.evaluate(None, request, view)
+ False
+
+ But if someone does try and validate the request then it is just True.
+
+ >>> validator.valid(None, request, view)
+ True
+
+ If-None-Match
+ =============
+
+ Define a simple ETag adapter for getting the current entity tag of a
+ view. We can change the value of the current etag by setting the
+ etag class attribute.
+
+ >>> class CurrentETag(object):
+ ... zope.interface.implements(interfaces.IETag)
+ ... def __init__(self, context, request, view):
+ ... pass
+ ... weak = False
+ ... etag = 'xyzzy'
+
+ >>> request = TestRequest(environ = {'IF_NONE_MATCH': '"xyzzy"'})
+ >>> view = SimpleView(None, request)
+
+ Since we have a conditional header present the validator can evaluate
+ this request.
+
+ >>> validator.evaluate(None, request, view)
+ True
+
+ No current IETag adapter available so the request is valid, and the
+ update response method works but doesn't set any headers.
+
+ >>> validator.valid(None, request, view)
+ True
+ >>> validator.updateResponse(None, request, view) is None
+ True
+ >>> request.response.getHeader('ETag', None) is None
+ True
+
+ But if the `If-None-Match` value is '*' then the request is invalid
+ even tough the resource has no entity tag.
+
+ >>> request._environ['IF_NONE_MATCH'] = '"*"'
+ >>> validator.valid(None, request, view)
+ False
+ >>> request._environ['IF_NONE_MATCH'] = '"xyzzy"'
+
+ The default value for the current entity tab is the same as in the request,
+ so the request is invalid, that is none of the entity tags match.
+
+ >>> zope.component.getGlobalSiteManager().registerAdapter(
+ ... CurrentETag, (None, IBrowserRequest, None))
+
+ >>> validator.valid(None, request, view)
+ False
+
+ Etags don't match so the request is valid.
+
+ >>> CurrentETag.etag = 'xxx'
+ >>> validator.valid(None, request, view)
+ True
+
+ Test '*' which matches all values.
+
+ >>> request._environ['IF_NONE_MATCH'] = '"*"'
+ >>> validator.valid(None, request, view)
+ False
+
+ Test multiple `If-None-Match` values.
+
+ >>> request._environ['IF_NONE_MATCH'] = '"xxx", "yyy"'
+ >>> validator.valid(None, request, view)
+ False
+ >>> CurrentETag.etag = 'xxxzz'
+ >>> validator.valid(None, request, view)
+ True
+
+ Test multiple `If-None-Match` values with a '*'
+
+ >>> request._environ['IF_NONE_MATCH'] = '"*", "xxx", "yyy"'
+ >>> validator.valid(None, request, view)
+ False
+ >>> CurrentETag.etag = None
+ >>> validator.valid(None, request, view)
+ False
+
+ If-Match
+ ========
+
+ >>> request = TestRequest(environ = {'IF_MATCH': '"xyzzy"'})
+ >>> view = SimpleView(None, request)
+ >>> CurrentETag.etag = None
+
+ Since we have a conditional header present the validator can
+ evaluate this request.
+
+ >>> validator.evaluate(None, request, view)
+ True
+
+ Since there is no entity tag for the view, we don't match 'xyzzy', or
+ equivalently it is set to None.
+
+ >>> validator.valid(None, request, view)
+ False
+
+ The entity tags differ.
+
+ >>> CurrentETag.etag = 'xxx'
+ >>> validator.valid(None, request, view)
+ False
+
+ The entity tags are the same.
+
+ >>> CurrentETag.etag = 'xyzzy'
+ >>> validator.valid(None, request, view)
+ True
+
+ A `If-Match` header value of '*' matches everything.
+
+ >>> request._environ['IF_MATCH'] = '"*"'
+ >>> validator.valid(None, request, view)
+ True
+
+ Try multiple values.
+
+ >>> request._environ['IF_MATCH'] = '"xxx", "yyy"'
+ >>> validator.valid(None, request, view)
+ False
+
+ >>> request._environ['IF_MATCH'] = '"xyzzy", "yyy"'
+ >>> validator.valid(None, request, view)
+ True
+
+ Try multiple values with an '*' value.
+
+ >>> request._environ['IF_MATCH'] = '"xxx", "*", "yyy"'
+ >>> validator.valid(None, request, view)
+ True
+
+ Common responses
+ ================
+
+ Whether or not a request is valid or invalid the updateResponse method
+ is called after the status of the request is set by either the view / or
+ the `invalidStatus` method. Note that the updateResponse method does not
+ return any value. If it did then this value is not used for anything.
+
+ >>> CurrentETag.etag = 'xxx'
+ >>> request = TestRequest(environ = {'IF_NONE_MATCH': '"xyzzy"'})
+ >>> view = SimpleView(None, request)
+ >>> validator.valid(None, request, view)
+ True
+
+ >>> validator.updateResponse(None, request, view) is None
+ True
+ >>> request.response.getStatus() # status is unset
+ 599
+ >>> request.response.getHeader('ETag')
+ '"xxx"'
+
+ Since the `ETag` response header is set, we don't override it. Changing
+ the current entity tag and recalling the updateResponse method confirms
+ this. Note that this feature is neccessary to avoid situations where
+ a developer manages the entity tag of a view independently of this package.
+
+ >>> CurrentETag.etag = 'yyy'
+ >>> validator.updateResponse(None, request, view) is None
+ True
+ >>> request.response.getStatus()
+ 599
+ >>> request.response.getHeader('ETag')
+ '"xxx"'
+
+ Marking the current entity tag as weak.
+
+ >>> CurrentETag.weak = True
+
+ >>> validator.updateResponse(None, request, view)
+ >>> request.response.getHeader('ETag')
+ '"xxx"'
+
+ Since the 'ETag' header is already set we need to recreate the response
+ object to test the condition when the weak attribute is True.
+
+ >>> request = TestRequest(environ = {'IF_NONE_MATCH': '"xyzzy"'})
+ >>> view = SimpleView(None, request)
+ >>> validator.valid(None, request, view)
+ True
+
+ >>> validator.updateResponse(None, request, view)
+ >>> request.response.getHeader('ETag')
+ 'W/"yyy"'
+
+ >>> CurrentETag.weak = False
+
+ Invalid header
+ ==============
+
+ If the header doesn't parse then the requets is True.
+
+ >>> request._environ['IF_NONE_MATCH'] = '"yyy"'
+ >>> validator.valid(None, request, view)
+ False
+
+ Now if we are missing the double quotes around the entity tag.
+
+ >>> request._environ['IF_NONE_MATCH'] = 'yyy'
+ >>> validator.valid(None, request, view)
+ True
+
+ Invalid status
+ ==============
+
+ In the case of entity tags and invalid responses we should return a 304
+ for the GET, and HEAD method requests and 412 otherwise.
+
+ >>> CurrentETag.etag = 'xyzzy'
+ >>> request = TestRequest(environ = {'IF_NONE_MATCH': '"xyzzy"'})
+ >>> request.method
+ 'GET'
+ >>> view = SimpleView(None, request)
+ >>> validator.valid(None, request, view)
+ False
+
+ >>> validator.updateResponse(None, request, view) is None
+ True
+ >>> request.response.getStatus()
+ 599
+ >>> request.response.getHeader('ETag')
+ '"xyzzy"'
+
+ >>> validator.invalidStatus(None, request, view)
+ 304
+
+ And the same for `HEAD` methods.
+
+ >>> CurrentETag.etag = 'xyzzy'
+ >>> request = TestRequest(environ = {'IF_NONE_MATCH': '"xyzzy"',
+ ... 'REQUEST_METHOD': 'HEAD'})
+ >>> request.method
+ 'HEAD'
+ >>> view = SimpleView(None, request)
+ >>> validator.valid(None, request, view)
+ False
+
+ >>> validator.invalidStatus(None, request, view)
+ 304
+
+ For anyother request method we get a `412 Precondition failed` response.
+
+ >>> request = TestRequest(environ = {'IF_NONE_MATCH': '"xyzzy"',
+ ... 'REQUEST_METHOD': 'FROG'})
+ >>> request.method
+ 'FROG'
+ >>> view = SimpleView(None, request)
+ >>> validator.valid(None, request, view)
+ False
+
+ >>> validator.invalidStatus(None, request, view)
+ 412
+
+ Query strings
+ =============
+
+ If a query is present in the request then either the client is filling
+ in a form with a GET method (oh why..), or else they are trying to do
+ some content negotiation, and hence the data supplied by the IETag adapter
+ does not apply to the view. But there are still cases where a request with
+ a query string can still fail.
+
+ >>> request = TestRequest(environ = {'IF_NONE_MATCH': '"xyzzy"',
+ ... 'REQUEST_METHOD': 'GET',
+ ... 'QUERY_STRING': 'argument=value'})
+ >>> view = SimpleView(None, request)
+ >>> validator.evaluate(None, request, view)
+ True
+ >>> validator.valid(None, request, view)
+ True
+ >>> request._environ['IF_NONE_MATCH'] = '"*"'
+ >>> validator.valid(None, request, view)
+ False
+
+ >>> request = TestRequest(environ = {'IF_MATCH': '"xyzzy"',
+ ... 'REQUEST_METHOD': 'GET',
+ ... 'QUERY_STRING': 'argument=value'})
+ >>> view = SimpleView(None, request)
+ >>> validator.evaluate(None, request, view)
+ True
+ >>> validator.valid(None, request, view)
+ False
+ >>> request._environ['IF_MATCH'] = '"*"'
+ >>> validator.valid(None, request, view)
+ True
+
+ Finally if we have a query string then when we try and update the
+ response we should not set the entity tag.
+
+ >>> validator.updateResponse(None, request, view) is None
+ True
+ >>> request.response.getHeader('ETag', None) is None
+ True
+
+ Null resources
+ ==============
+
+ Additional functionality is needed to deal with null resources.
+
+ >>> import zope.app.http.put
+ >>> nullresource = zope.app.http.put.NullResource(None, 'test')
+
+ The `If-None-Match: "*"' says that a request is valid if the resource
+ corresponding to the resource does not exsist.
+
+ >>> request = TestRequest(environ = {'IF_NONE_MATCH': '"*"'})
+ >>> view = SimpleView(nullresource, request)
+ >>> validator.valid(nullresource, request, view)
+ True
+
+ The `If-Match: "*"` header says that a request is valid if the resource
+ corresponding the the request exsists. A null resource does not exist - it
+ is a temporary non-presistent content object used as an location in the
+ database that might exist, if a user PUT's data to it.
+
+ >>> request = TestRequest(environ = {'IF_MATCH': '"*"'})
+ >>> view = SimpleView(nullresource, request)
+ >>> validator.valid(nullresource, request, view)
+ False
+
+ Cleanup
+ -------
+
+ >>> zope.component.getGlobalSiteManager().unregisterAdapter(
+ ... CurrentETag, (None, IBrowserRequest, None))
+ True
+
+ """
+ zope.interface.implements(interfaces.IHTTPValidator)
+
+ def parseMatchList(self, request, header):
+ ret = []
+ matches = request.getHeader(header, None)
+ if matches is not None:
+ for val in matches.split(","):
+ val = val.strip()
+ if val == "*":
+ ret.append(val)
+ else:
+ if val[:2] == "W/":
+ val = val[2:]
+ if val[0] + val[-1] == '""' and len(val) > 2:
+ ret.append(val[1:-1])
+ return ret
+
+ def evaluate(self, context, request, view):
+ return request.getHeader("If-None-Match", None) is not None or \
+ request.getHeader("If-Match") is not None
+
+ def getDataStorage(self, context, request, view):
+ return zope.component.queryMultiAdapter(
+ (context, request, view), interfaces.IETag)
+
+ def _matches(self, context, request, etag, matchset):
+ if "*" in matchset:
+ if INullResource.providedBy(context):
+ return False
+ return True
+
+ if request.get("QUERY_STRING", "") == "" and etag in matchset:
+ return True
+
+ return False
+
+ def valid(self, context, request, view):
+ etag = self.getDataStorage(context, request, view)
+ if etag is not None:
+ # A request can still be invalid without knowing entity tag.
+ # If-Match: "*" matches everything
+ etag = etag.etag
+
+ # Test the most common validator first.
+ matchset = self.parseMatchList(request, "If-None-Match")
+ if matchset:
+ return not self._matches(context, request, etag, matchset)
+
+ matchset = self.parseMatchList(request, "If-Match")
+ if matchset:
+ return self._matches(context, request, etag, matchset)
+
+ # Always default to True, this can happen if the requests contains
+ # invalid data.
+ return True
+
+ def invalidStatus(self, context, request, view):
+ if request.method in ("GET", "HEAD"):
+ return 304
+ else:
+ # RFC2616 Section 14.26:
+ # Instead, if the request method was GET or HEAD, the server
+ # SHOULD respond with a 304 (Not Modified) response, including
+ # the cache-related header fields (particularly ETag) of one of
+ # the entities that matched.
+ return 412
+
+ def updateResponse(self, context, request, view):
+ if request.response.getHeader("ETag", None) is None and \
+ request.get("QUERY_STRING", "") == "":
+ etag = self.getDataStorage(context, request, view)
+ weak = etag and etag.weak
+ etag = etag and etag.etag
+ if etag:
+ if weak:
+ request.response.setHeader("ETag", 'W/"%s"' % etag)
+ else:
+ request.response.setHeader("ETag", '"%s"' % etag)
Added: z3c.conditionalviews/trunk/src/z3c/conditionalviews/ftesting.zcml
===================================================================
--- z3c.conditionalviews/trunk/src/z3c/conditionalviews/ftesting.zcml (rev 0)
+++ z3c.conditionalviews/trunk/src/z3c/conditionalviews/ftesting.zcml 2007-05-31 18:51:07 UTC (rev 76052)
@@ -0,0 +1,118 @@
+<configure xmlns="http://namespaces.zope.org/zope"
+ xmlns:zcml="http://namespaces.zope.org/zcml"
+ xmlns:browser="http://namespaces.zope.org/browser"
+ i18n_domain="zope">
+
+ <include zcml:condition="installed zope.app.zcmlfiles"
+ package="zope.app.zcmlfiles" />
+ <include zcml:condition="not-installed zope.app.zcmlfiles"
+ package="zope.app.zcmlfiles" />
+
+ <include package="zope.app.securitypolicy" file="meta.zcml" />
+ <include package="zope.app.securitypolicy" />
+ <include package="zope.app.authentication" />
+ <securityPolicy
+ component="zope.app.securitypolicy.zopepolicy.ZopeSecurityPolicy"
+ />
+ <unauthenticatedPrincipal
+ id="zope.anybody"
+ title="Unauthenticated User"
+ />
+
+ <role id="zope.Manager" title="Site Manager" />
+ <grantAll role="zope.Manager" />
+
+ <principal
+ id="zope.mgr"
+ title="Manager"
+ login="mgr"
+ password="mgrpw"
+ />
+ <grant
+ role="zope.Manager"
+ principal="zope.mgr"
+ />
+
+ <include package="z3c.conditionalviews" />
+
+ <!--
+ This view is used only for the tests to demonstrate how to use the
+ ConditionalView decorator. It is easier define the view here and to
+ let the browser:page directive setup all the neccessary security to
+ get this view to work.
+ -->
+
+ <browser:page
+ name="simpleview.html"
+ for="zope.interface.Interface"
+ class="z3c.conditionalviews.tests.Simpleview"
+ permission="zope.Public"
+ />
+
+ <!--
+ Test against the PUT method from zope.app.http. First we need to
+ configure some contents types and some adapters for this work
+ with the PUT method.
+ -->
+ <class class="z3c.conditionalviews.tests.File">
+ <require
+ permission="zope.View"
+ interface="z3c.conditionalviews.tests.IFile"
+ />
+
+ <require
+ permission="zope.ManageContent"
+ set_schema="z3c.conditionalviews.tests.IFile"
+ />
+
+ <implements
+ interface="zope.annotation.interfaces.IAttributeAnnotatable"
+ />
+ </class>
+
+ <adapter
+ for="zope.app.folder.interfaces.IFolder"
+ provides="zope.filerepresentation.interfaces.IFileFactory"
+ factory="z3c.conditionalviews.tests.FileFactory"
+ permission="zope.ManageContent"
+ />
+
+ <adapter
+ for="z3c.conditionalviews.tests.IFile"
+ provides="zope.filerepresentation.interfaces.IWriteFile"
+ factory="z3c.conditionalviews.tests.WriteFile"
+ permission="zope.ManageContent"
+ />
+
+ <browser:page
+ for="z3c.conditionalviews.tests.IFile"
+ class="z3c.conditionalviews.tests.ViewFile"
+ permission="zope.View"
+ name="index.html"
+ />
+
+ <!--
+ Setup up a conditional publication, so that every HTTP request
+ is validated with this package.
+ -->
+ <utility
+ component="z3c.conditionalviews.ConditionalHTTPRequest"
+ provides="zope.app.publication.interfaces.IHTTPRequestFactory"
+ />
+
+ <class class="z3c.conditionalviews.tests.FileETag">
+ <allow
+ interface="z3c.conditionalviews.interfaces.IETag"
+ />
+ </class>
+
+ <adapter
+ for="z3c.conditionalviews.tests.IFile
+ zope.publisher.interfaces.http.IHTTPRequest
+ zope.interface.Interface"
+ factory="z3c.conditionalviews.tests.FileETag"
+ provides="z3c.conditionalviews.interfaces.IETag"
+ trusted="1"
+ />
+
+</configure>
Added: z3c.conditionalviews/trunk/src/z3c/conditionalviews/interfaces.py
===================================================================
--- z3c.conditionalviews/trunk/src/z3c/conditionalviews/interfaces.py (rev 0)
+++ z3c.conditionalviews/trunk/src/z3c/conditionalviews/interfaces.py 2007-05-31 18:51:07 UTC (rev 76052)
@@ -0,0 +1,104 @@
+##############################################################################
+# Copyright (c) 2007 Zope Corporation and Contributors.
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL). A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+##############################################################################
+
+from zope import interface
+from zope import schema
+
+class IETag(interface.Interface):
+ """
+ Entity tags are used for comparing two or more entities from the same
+ requested view.
+
+ Used by the .etag.ETagValidator to validate a request against `If-Match`
+ and `If-None-Match` conditional HTTP headers.
+ """
+
+ weak = interface.Attribute("""
+ Boolean value indicated that the entity tag is weak.
+ """)
+
+ etag = interface.Attribute("""
+ The current entity tag of this view.
+ """)
+
+
+class ILastModificationDate(interface.Interface):
+ """
+ Used by the ModificationSinceValidator to adapt a view in order to
+ validate the `If-Modified-Since` and `If-UnModified-Since` conditional
+ HTTP headers.
+ """
+
+ lastmodified = schema.Datetime(
+ title = u"Last modification date",
+ description = u"Indicates the last time this view last changed.",
+ required = False)
+
+
+class IHTTPValidator(interface.Interface):
+ """
+ This adapter is responsible for validating a HTTP request against one
+ or more conditional headers.
+
+ When a view is called then for each validator that is registered with
+ the system, all validators that could possible return False, that is
+ their `evaluate` method returns True are counted. And if the number
+ of validators whose evaluate method returns True equals the number
+ of validators who say that the request is invalid (i.e. the valid
+ method returns False) then the request is considered invalid by this
+ package.
+
+ When a request is invalid then the `invalidStatus` method is called to
+ set the status code of the response and the view we are adapter is not
+ called.
+
+ If a request is valid then the view we adapted is called.
+
+ In both situations, after the view is called or the `invalidStatus`
+ then the `updateResponse` method is called for each registered validators.
+ This method should (if not present) add a validator HTTP header to
+ the response, so clients know how to make a request conditional.
+ """
+
+ def evaluate(context, request, view):
+ """
+ Return `True` if this request is a conditional request and this
+ validator knows can validate the request.
+ """
+
+ def valid(context, request, view):
+ """
+ Return `True` request is valid and should be executed.
+
+ If the request is not valid then the `invalidStatus` method will
+ be called.
+
+ By default this method should always return `True`.
+ """
+
+ def invalidStatus(context, request, view):
+ """
+ Return the integer status of that the response should be considering
+ that this validator evaluated the request as invalid.
+
+ If more then more validator are involved in failing a request then
+ the first validator used will in the validation algorithm will be
+ used. This isn't ideal but in practice, this problem is unlikely to
+ show up.
+ """
+
+ def updateResponse(context, request, view):
+ """
+ This method is always called, and should set a HTTP header informing
+ the current data needed to invalid a request the next time they
+ request the adapted view.
+ """
Added: z3c.conditionalviews/trunk/src/z3c/conditionalviews/lastmodification.py
===================================================================
--- z3c.conditionalviews/trunk/src/z3c/conditionalviews/lastmodification.py (rev 0)
+++ z3c.conditionalviews/trunk/src/z3c/conditionalviews/lastmodification.py 2007-05-31 18:51:07 UTC (rev 76052)
@@ -0,0 +1,328 @@
+##############################################################################
+# Copyright (c) 2007 Zope Corporation and Contributors.
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL). A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+##############################################################################
+
+import time
+import calendar
+import zope.component
+import zope.datetime
+import zope.interface
+
+import interfaces
+
+class ModifiedSinceValidator(object):
+ """
+
+ >>> import datetime
+ >>> from zope.interface.verify import verifyObject
+ >>> from zope.publisher.interfaces.browser import IBrowserRequest
+ >>> from zope.publisher.browser import TestRequest
+ >>> from zope.publisher.browser import BrowserView
+
+ >>> def format(dt):
+ ... return zope.datetime.rfc1123_date(time.mktime(dt.utctimetuple()))
+
+ >>> lmt = datetime.datetime(2007, 1, 6, 13, 42, 12,
+ ... tzinfo = zope.datetime.tzinfo(60))
+
+ ModifiedSinceValidator is a HTTP validator utility that will evaluate
+ a HTTP request to see if it passes or fails the If-Modifed* protocol.
+
+ >>> validator = ModifiedSinceValidator()
+ >>> verifyObject(interfaces.IHTTPValidator, validator)
+ True
+
+ If-Modified-Since
+ =================
+
+ The If-Modified-Since request-header field is used with a method to
+ make it conditional: if the requested variant has not been modified
+ since the time specified in this field, an entity will not be
+ returned from the server; instead, a 304 (not modified) response will
+ be returned without any message-body.
+
+ >>> class SimpleView(BrowserView):
+ ... def __call__(self):
+ ... self.request.response.setStatus(200)
+ ... self.request.response.setHeader('Last-Modified', format(lmt))
+ ... return 'Rendered view representation.'
+
+ Create a context adapter to find the last modified date of the context
+ object. We store the lastmodifed datatime object as a class attribute so
+ that we can easily change its value during these tests.
+
+ >>> class LastModification(object):
+ ... zope.interface.implements(interfaces.ILastModificationDate)
+ ... lastmodified = None
+ ... def __init__(self, context, request, view):
+ ... pass
+
+ A ILastModificationDate adapter must be registered for this validator
+ to know when a view was last modified.
+
+ >>> request = TestRequest(
+ ... environ = {'IF_MODIFIED_SINCE': format(lmt)})
+ >>> request['IF_MODIFIED_SINCE']
+ 'Sat, 06 Jan 2007 12:42:12 GMT'
+ >>> view = SimpleView(None, request)
+
+ >>> validator.valid(None, request, view) #doctest:+ELLIPSIS
+ True
+
+ Now register the last modified data adapter, and set up
+
+ >>> gsm = zope.component.getGlobalSiteManager()
+ >>> gsm.registerAdapter(LastModification, (None, IBrowserRequest, None))
+
+ Since there is no last modification date by default the request is valid.
+
+ >>> validator.valid(None, request, view)
+ True
+
+ Set the last modification date to be equal.
+
+ >>> LastModification.lastmodified = lmt
+ >>> validator.valid(None, request, view)
+ False
+
+ Increase the current last modification time of the view by 1 second.
+
+ >>> LastModification.lastmodified = lmt + datetime.timedelta(seconds = 1)
+ >>> validator.valid(None, request, view)
+ True
+
+ Decrease the current last modification time of the view by 1 second.
+
+ >>> LastModification.lastmodified = lmt - datetime.timedelta(seconds = 1)
+ >>> validator.valid(None, request, view)
+ False
+
+ Test invalid request data.
+
+ >>> invalidrequest = TestRequest(environ = {'IF_MODIFIED_SINCE': 'XXX'})
+ >>> validator.valid(None, invalidrequest, view)
+ True
+
+ >>> LastModification.lastmodified = lmt
+
+ If-UnModified-Since
+ ===================
+
+ >>> request = TestRequest(
+ ... environ = {'IF_UNMODIFIED_SINCE': format(lmt)})
+ >>> request['IF_UNMODIFIED_SINCE']
+ 'Sat, 06 Jan 2007 12:42:12 GMT'
+
+ >>> view = SimpleView(None, request)
+
+ The If-Unmodified-Since request-header field is used with a method to
+ make it conditional. If the requested resource has not been modified
+ since the time specified in this field, the server SHOULD perform the
+ requested operation as if the If-Unmodified-Since header were not
+ present.
+
+ >>> validator.valid(None, request, view)
+ True
+
+ Increase the current last modified time of the view by 1 second.
+
+ >>> LastModification.lastmodified = lmt + datetime.timedelta(seconds = 1)
+ >>> validator.valid(None, request, view)
+ False
+
+ Decrease the current last modified time of the view by 1 second.
+
+ >>> LastModification.lastmodified = lmt - datetime.timedelta(seconds = 1)
+ >>> validator.valid(None, request, view)
+ True
+
+ Invalid date header.
+
+ >>> request = TestRequest(
+ ... environ = {'IF_UNMODIFIED_SINCE': 'xxx'})
+ >>> view = SimpleView(None, request)
+
+ >>> validator.valid(None, request, view)
+ False
+
+ If no `If-Modified-Since` or `If-UnModified-Since` conditional HTTP
+ headers are set then the request is valid.
+
+ >>> request = TestRequest()
+ >>> view = SimpleView(None, request)
+ >>> validator.valid(None, request, view)
+ Traceback (most recent call last):
+ ...
+ ValueError: Protocol implementation is broken - evaluate should be False
+
+ But then the validator should not evaluate the request.
+
+ >>> validator.evaluate(None, request, view)
+ False
+
+ Valid responses
+ ===============
+
+ >>> LastModification.lastmodified = lmt
+ >>> request = TestRequest(
+ ... environ = {'IF_UNMODIFIED_SINCE': format(lmt)})
+ >>> view = SimpleView(None, request)
+
+ Since we have a conditional header present the validator can evaluate
+ the data.
+
+ >>> validator.evaluate(None, request, view)
+ True
+
+ >>> validator.valid(None, request, view)
+ True
+
+ >>> validator.updateResponse(None, request, view) is None
+ True
+ >>> request.response.getStatus()
+ 599
+ >>> request.response.getHeader('Last-Modified')
+ 'Sat, 06 Jan 2007 12:42:12 GMT'
+
+ Since the `Last-Modified` header is already set, it doesn't get overriden.
+
+ >>> LastModification.lastmodified = lmt + datetime.timedelta(seconds = 1)
+ >>> validator.updateResponse(None, request, view)
+ >>> request.response.getStatus()
+ 599
+ >>> request.response.getHeader('Last-Modified')
+ 'Sat, 06 Jan 2007 12:42:12 GMT'
+
+ Invalid responses
+ =================
+
+ >>> LastModification.lastmodified = lmt + datetime.timedelta(seconds = 1)
+ >>> request = TestRequest(environ = {'IF_UNMODIFIED_SINCE': format(lmt)})
+ >>> view = SimpleView(None, request)
+
+ Since we have a conditional header present the validator can evaluate
+ the data.
+
+ >>> validator.evaluate(None, request, view)
+ True
+ >>> validator.valid(None, request, view)
+ False
+
+ >>> validator.updateResponse(None, request, view) is None
+ True
+ >>> request.response.getStatus()
+ 599
+ >>> request.response.getHeader('Last-Modified')
+ 'Sat, 06 Jan 2007 12:42:13 GMT'
+ >>> validator.invalidStatus(None, request, view)
+ 304
+
+ Since the `Last-Modified` header is already set, it doesn't get overriden.
+
+ >>> LastModification.lastmodified = lmt
+ >>> validator.updateResponse(None, request, view)
+ >>> request.response.getHeader('Last-Modified')
+ 'Sat, 06 Jan 2007 12:42:13 GMT'
+
+ Query strings
+ =============
+
+ If a query string is present in the request, then the client requested
+ a content negotiated view, or else they tried to fill out a form. Either
+ way the request must evaluate to True, and be executed.
+
+ >>> LastModification.lastmodified = lmt + datetime.timedelta(seconds = 1)
+ >>> request = TestRequest(environ = {'IF_UNMODIFIED_SINCE': format(lmt),
+ ... 'QUERY_STRING': 'argument=true'})
+ >>> view = SimpleView(None, request)
+ >>> validator.evaluate(None, request, view)
+ True
+ >>> validator.valid(None, request, view)
+ True
+
+ We should not update the response when there is a query string present.
+
+ >>> validator.updateResponse(None, request, view) is None
+ True
+ >>> request.response.getHeader('Last-Modified') is None
+ True
+
+ Cleanup
+ -------
+
+ >>> gsm.unregisterAdapter(LastModification,
+ ... (None, IBrowserRequest, None))
+ True
+
+ """
+ zope.interface.implements(interfaces.IHTTPValidator)
+
+ def ifModifiedSince(self, request, mtime, header):
+ headervalue = request.getHeader(header, None)
+ if headervalue is not None:
+ # current last modification time for this view
+ last_modification_time = long(time.mktime(mtime.utctimetuple()))
+ try:
+ headervalue = long(zope.datetime.time(
+ headervalue.split(";", 1)[0]))
+ except:
+ # error processing the HTTP-date value - return the
+ # default value.
+ return True
+ else:
+ return last_modification_time > headervalue
+ # By default all HTTP Cache validators should return True so that the
+ # request proceeds as normal.
+ return True
+
+ def evaluate(self, context, request, view):
+ return \
+ request.getHeader("If-Modified-Since", None) is not None or \
+ request.getHeader("If-UnModified-Since", None) is not None
+
+ def getDataStorage(self, context, request, view):
+ return zope.component.queryMultiAdapter(
+ (context, request, view), interfaces.ILastModificationDate)
+
+ def valid(self, context, request, view):
+ if request.get("QUERY_STRING", "") != "":
+ # a query string was supplied in the URL, so the data supplied
+ # by the ILastModificationDate does not apply to this view.
+ return True
+
+ lmd = self.getDataStorage(context, request, view)
+ if lmd is None:
+ return True
+
+ lmd = lmd.lastmodified
+ if lmd is None:
+ return True
+
+ if request.getHeader("If-Modified-Since", None) is not None:
+ return self.ifModifiedSince(request, lmd, "If-Modified-Since")
+ if request.getHeader("If-UnModified-Since", None) is not None:
+ return not self.ifModifiedSince(
+ request, lmd, "If-UnModified-Since")
+
+ raise ValueError(
+ "Protocol implementation is broken - evaluate should be False")
+
+ def invalidStatus(self, context, request, view):
+ return 304
+
+ def updateResponse(self, context, request, view):
+ if request.response.getHeader("Last-Modified", None) is None and \
+ request.get("QUERY_STRING", "") == "":
+ storage = self.getDataStorage(context, request, view)
+ if storage is not None and storage.lastmodified is not None:
+ lmd = zope.datetime.rfc1123_date(
+ calendar.timegm(storage.lastmodified.utctimetuple()))
+ request.response.setHeader("Last-Modified", lmd)
Added: z3c.conditionalviews/trunk/src/z3c/conditionalviews/tests.py
===================================================================
--- z3c.conditionalviews/trunk/src/z3c/conditionalviews/tests.py (rev 0)
+++ z3c.conditionalviews/trunk/src/z3c/conditionalviews/tests.py 2007-05-31 18:51:07 UTC (rev 76052)
@@ -0,0 +1,138 @@
+##############################################################################
+# Copyright (c) 2007 Zope Corporation and Contributors.
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL). A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+##############################################################################
+
+import os
+import datetime
+import unittest
+
+import persistent
+import zope.interface
+import zope.schema
+import zope.filerepresentation.interfaces
+import zope.annotation.interfaces
+import zope.publisher.browser
+from zope.security.proxy import removeSecurityProxy
+from zope.testing import doctest
+import zope.app.testing.functional
+
+import z3c.conditionalviews
+import z3c.conditionalviews.interfaces
+
+here = os.path.dirname(os.path.realpath(__file__))
+ConditionalViewLayer = zope.app.testing.functional.ZCMLLayer(
+ os.path.join(here, "ftesting.zcml"), __name__, "ConditionalViewLayer")
+
+
+class Simpleview(zope.publisher.browser.BrowserView):
+
+ @z3c.conditionalviews.ConditionalView
+ def __call__(self, letter = "x"):
+ return "%s\n%s\n" %(letter * 40, letter * 40)
+
+
+class IFile(zope.interface.Interface):
+
+ data = zope.schema.Bytes(
+ title = u"Data")
+
+
+class File(persistent.Persistent):
+ zope.interface.implements(IFile)
+
+ def __init__(self, data):
+ self.data = data
+
+
+class ViewFile(zope.publisher.browser.BrowserView):
+
+ @z3c.conditionalviews.ConditionalView
+ def __call__(self):
+ return self.context.data
+
+
+class FileFactory(object):
+ zope.interface.implements(zope.filerepresentation.interfaces.IFileFactory)
+
+ def __init__(self, container):
+ self.container = container
+
+ def __call__(self, name, content_type, data):
+ fileobj = File(data)
+ setETag(fileobj, None)
+ return fileobj
+
+
+class WriteFile(object):
+ zope.interface.implements(zope.filerepresentation.interfaces.IWriteFile)
+
+ def __init__(self, context):
+ self.context = context
+
+ def write(self, data):
+ self.context.data = data
+ setETag(self.context, None)
+
+
+class FileETag(object):
+ zope.interface.implements(z3c.conditionalviews.interfaces.IETag)
+
+ def __init__(self, context, request, view):
+ self.context = self.__parent__ = context
+
+ weak = False
+
+ @property
+ def etag(self):
+ annots = zope.annotation.interfaces.IAnnotations(self.context)
+ return "%s:%d" %(self.context.__name__, annots["ETAG"])
+
+
+def setETag(fileobj, event):
+ annots = zope.annotation.interfaces.IAnnotations(
+ removeSecurityProxy(fileobj))
+ annots.setdefault("ETAG", 0)
+ annots["ETAG"] = annots["ETAG"] + 1
+
+
+class LastModification(object):
+ zope.interface.implements(
+ z3c.conditionalviews.interfaces.ILastModificationDate)
+
+ def __init__(self, view):
+ self.view = view
+
+ lastmodified = datetime.datetime(2007, 2, 5, 5, 43, 23)
+
+
+def integrationSetup(test):
+ test.globs["http"] = zope.app.testing.functional.HTTPCaller()
+
+
+def integrationTeardown(test):
+ del test.globs["http"]
+
+
+def test_suite():
+ readme = doctest.DocFileSuite(
+ "README.txt",
+ setUp = integrationSetup,
+ tearDown = integrationTeardown,
+ optionflags = doctest.NORMALIZE_WHITESPACE)
+ readme.layer = ConditionalViewLayer
+
+ return unittest.TestSuite((
+ doctest.DocFileSuite("validation.txt"),
+ doctest.DocTestSuite("z3c.conditionalviews.lastmodification"),
+ doctest.DocTestSuite("z3c.conditionalviews.etag"),
+ doctest.DocTestSuite("z3c.conditionalviews.adapters"),
+ readme,
+ ))
Added: z3c.conditionalviews/trunk/src/z3c/conditionalviews/validation.txt
===================================================================
--- z3c.conditionalviews/trunk/src/z3c/conditionalviews/validation.txt (rev 0)
+++ z3c.conditionalviews/trunk/src/z3c/conditionalviews/validation.txt 2007-05-31 18:51:07 UTC (rev 76052)
@@ -0,0 +1,157 @@
+====================================
+Validating conditional HTTP requests
+====================================
+
+RFC2626::
+
+ The semantics of the GET method change to a 'conditional GET' if the
+ request message includes an If-Modified-Since, If-Unmodified-Since,
+ If-Match, If-None-Match, or If-Range header field. A conditional GET
+ method requests that the entity be transferred only under the
+ circumstances described by the conditional header field(s).
+
+ >>> import z3c.conditionalviews
+ >>> import z3c.conditionalviews.interfaces
+
+ >>> import zope.interface
+ >>> import zope.interface.verify
+ >>> from zope.publisher.browser import BrowserView
+ >>> from zope.publisher.browser import TestRequest
+ >>> from zope.publisher.interfaces.http import IHTTPRequest
+ >>> from zope.publisher.interfaces.browser import IBrowserView
+
+A really simple view to test the conditional views.
+
+ >>> class SimpleView(BrowserView):
+ ... @z3c.conditionalviews.ConditionalView
+ ... def __call__(self):
+ ... self.request.response.setStatus(200)
+ ... return "xxxxx"
+
+ >>> class SimpleValidator(object):
+ ... zope.interface.implements(
+ ... z3c.conditionalviews.interfaces.IHTTPValidator)
+ ... def __init__(self):
+ ... self._header = 'COND_HEADER'
+ ... self._value = True # default value for the `COND_HEADER`
+ ... def evaluate(self, context, request, view):
+ ... return request.get(self._header, None) is not None
+ ... def valid(self, context, request, view):
+ ... return request[self._header]
+ ... def updateResponse(self, context, request, view):
+ ... request.response.setHeader(self._header, self._value)
+ ... def invalidStatus(self, context, request, view):
+ ... return 304
+
+ >>> request = TestRequest()
+ >>> view = SimpleView(None, request)
+
+No validators registered so the view runs as normal.
+
+ >>> view()
+ 'xxxxx'
+ >>> request.response.getStatus()
+ 200
+ >>> request.response.getHeader('COND_HEADER', None) is None
+ True
+
+Now register the HTTP validator.
+
+ >>> simplevalidator = SimpleValidator()
+ >>> zope.interface.verify.verifyObject(
+ ... z3c.conditionalviews.interfaces.IHTTPValidator, simplevalidator)
+ True
+ >>> zope.component.getGlobalSiteManager().registerUtility(
+ ... simplevalidator, name = 'simplevalidator')
+
+ >>> view()
+ 'xxxxx'
+ >>> request.response.getStatus()
+ 200
+ >>> request.response.getHeader('COND_HEADER')
+ 'True'
+
+Set the `COND_HEADER` in the request which should mark the request as
+invalid.
+
+ >>> request._environ['COND_HEADER'] = True
+ >>> view()
+ 'xxxxx'
+ >>> request.response.getStatus()
+ 200
+ >>> request.response.getHeader('COND_HEADER')
+ 'True'
+
+Setting the COND_HEADER to false, indicates that the request is `invalid`
+and as such should not be executed.
+
+ >>> request._environ['COND_HEADER'] = False
+ >>> view()
+ ''
+ >>> request.response.getStatus()
+ 304
+ >>> request.response.getHeader('COND_HEADER')
+ 'True'
+
+If we have two validators registered in our application then.
+
+ >>> request = TestRequest(environ = {'COND_HEADER': True,
+ ... 'SECOND_COND_HEADER': False})
+ >>> view = SimpleView(None, request)
+ >>> view()
+ 'xxxxx'
+
+ >>> class SimpleValidator2(SimpleValidator):
+ ... def __init__(self):
+ ... super(SimpleValidator2, self).__init__()
+ ... self._header = 'SECOND_COND_HEADER'
+
+ >>> simplevalidator2 = SimpleValidator2()
+ >>> zope.interface.verify.verifyObject(
+ ... z3c.conditionalviews.interfaces.IHTTPValidator, simplevalidator2)
+ True
+ >>> zope.component.getGlobalSiteManager().registerUtility(
+ ... simplevalidator2, name = 'simplevalidator2')
+
+In this example the second validator should return False and
+
+ >>> request = TestRequest(environ = {'COND_HEADER': True,
+ ... 'SECOND_COND_HEADER': False})
+ >>> view = SimpleView(None, request)
+ >>> view()
+ 'xxxxx'
+
+ >>> request = TestRequest(environ = {'COND_HEADER': False,
+ ... 'SECOND_COND_HEADER': False})
+ >>> view = SimpleView(None, request)
+ >>> view()
+ ''
+ >>> request.response.getStatus()
+ 304
+ >>> request.response.getHeader('COND_HEADER')
+ 'True'
+ >>> request.response.getHeader('SECOND_COND_HEADER')
+ 'True'
+
+If the conditional HTTP header corresponding to the second protocol is missing
+and the first protocol is invalid then we should treat this as if the
+second validator was unregistered.
+
+ >>> request = TestRequest(environ = {'COND_HEADER': False})
+ >>> view = SimpleView(None, request)
+ >>> view()
+ ''
+ >>> request.response.getStatus()
+ 304
+ >>> request.response.getHeader('COND_HEADER')
+ 'True'
+
+Cleanup
+-------
+
+ >>> zope.component.getGlobalSiteManager().unregisterUtility(
+ ... simplevalidator, name = 'simplevalidator')
+ True
+ >>> zope.component.getGlobalSiteManager().unregisterUtility(
+ ... simplevalidator2, name = 'simplevalidator2')
+ True
Property changes on: z3c.conditionalviews/trunk/src/z3c/conditionalviews/validation.txt
___________________________________________________________________
Name: svn:eol-style
+ native
More information about the Checkins
mailing list