Merge lp:~benji/lazr.restful/bug-413174 into lp:lazr.restful

Proposed by Benji York
Status: Merged
Approved by: Gary Poster
Approved revision: 146
Merged at revision: 145
Proposed branch: lp:~benji/lazr.restful/bug-413174
Merge into: lp:lazr.restful
Diff against target: 363 lines (+208/-31)
7 files modified
src/lazr/restful/_resource.py (+40/-25)
src/lazr/restful/configure.zcml (+9/-0)
src/lazr/restful/directives/__init__.py (+1/-1)
src/lazr/restful/error.py (+38/-3)
src/lazr/restful/interfaces/_rest.py (+15/-0)
src/lazr/restful/testing/webservice.py (+17/-2)
src/lazr/restful/tests/test_error.py (+88/-0)
To merge this branch: bzr merge lp:~benji/lazr.restful/bug-413174
Reviewer Review Type Date Requested Status
Gary Poster Approve
Review via email: mp+34686@code.launchpad.net

Description of the change

When raising exceptions it is sometimes useful to allow web service API
users to see the error details. OOPS-1318S626 and OOPS-1694EB1486 are
examples of this kind of situation.

Therefore this branch adds a method (lazr.restful.error.expose) that
lets you signal to lazr.restful that the message associated with the
error should be relayed to the web service API user.

The approach was discussed with Gary Poster and Leonard Richardson.

The expose function decorates the exception instance with a marker
interface which has an exception view (ClientErrorView) associated with
it.

The test for this feature can be run with the command

    bin/test -t TestLaunchpadlibAPI

To post a comment you must log in.
Revision history for this message
Gary Poster (gary) wrote :

Very nice catch with the check for isinstance of BaseException.

I question the view pattern for the registration and lookup--using "index.html" as the distinguishing marker. I'd prefer a custom interface myself. If, in retrospect, you agree, great. If not, I can see where you are coming from: leave as is.

We need a linter. Either ``from types import ClassType`` in src/lazr/restful/error.py shouldn't be there now, or it should have been caught earlier. Which is it?

In error.py, you say "# Bug 11512 describes the possibility of controlling the HTTP status of...." I think you mean bug 631711.

review: Approve
Revision history for this message
Benji York (benji) wrote :

On Mon, Sep 6, 2010 at 1:48 PM, Gary Poster <email address hidden> wrote:
> Review: Approve
> Very nice catch with the check for isinstance of BaseException.
>
> I question the view pattern for the registration and lookup--using
> "index.html" as the distinguishing marker.  I'd prefer a custom interface
> myself.  If, in retrospect, you agree, great.  If not, I can see where you
> are coming from: leave as is.

I was following the pattern used for the other error views. I don't feel
strongly about it one way or the other so I'll change it.

> We need a linter.

Yep. I'll take a quick look to see if pyflakes or similar is easily
usable with our lazr buildouts.

> Either ``from types import ClassType`` in
> src/lazr/restful/error.py shouldn't be there now, or it should have been
> caught earlier.  Which is it?

That's left over from a previous revision of the code. Removed.

> In error.py, you say "# Bug 11512 describes the possibility of controlling
> the HTTP status of...."  I think you mean bug 631711.

Indeed. Fixed.
--
Benji York

lp:~benji/lazr.restful/bug-413174 updated
147. By Benji York

fix bug in bug number

148. By Benji York

integrate review suggestion to use a custom interface for the client
error view

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'src/lazr/restful/_resource.py'
--- src/lazr/restful/_resource.py 2010-08-24 22:12:38 +0000
+++ src/lazr/restful/_resource.py 2010-09-06 19:28:41 +0000
@@ -77,6 +77,7 @@
77from lazr.enum import BaseItem77from lazr.enum import BaseItem
78from lazr.lifecycle.event import ObjectModifiedEvent78from lazr.lifecycle.event import ObjectModifiedEvent
79from lazr.lifecycle.snapshot import Snapshot79from lazr.lifecycle.snapshot import Snapshot
80from lazr.restful.interfaces import IClientError, IClientErrorView
8081
81from lazr.restful.interfaces import (82from lazr.restful.interfaces import (
82 ICollection, ICollectionField, ICollectionResource, IEntry, IEntryField,83 ICollection, ICollectionField, ICollectionResource, IEntry, IEntryField,
@@ -894,31 +895,45 @@
894 """Handle a GET, PUT, or PATCH request."""895 """Handle a GET, PUT, or PATCH request."""
895 result = ""896 result = ""
896 method = self.getRequestMethod()897 method = self.getRequestMethod()
897 if method is None:898 try:
898 result = self.HTTP_METHOD_OVERRIDE_ERROR899 if method is None:
899 elif method == "GET":900 result = self.HTTP_METHOD_OVERRIDE_ERROR
900 result = self.do_GET()901 elif method == "GET":
901 elif method in ["PUT", "PATCH"]:902 result = self.do_GET()
902 media_type = self.handleConditionalWrite()903 elif method in ["PUT", "PATCH"]:
903 if media_type is not None:904 media_type = self.handleConditionalWrite()
904 stream = self.request.bodyStream905 if media_type is not None:
905 representation = stream.getCacheStream().read()906 stream = self.request.bodyStream
906 if method == "PUT":907 representation = stream.getCacheStream().read()
907 result = self.do_PUT(media_type, representation)908 if method == "PUT":
908 else:909 result = self.do_PUT(media_type, representation)
909 result = self.do_PATCH(media_type, representation)910 else:
910 elif method == "POST" and self.implementsPOST():911 result = self.do_PATCH(media_type, representation)
911 result = self.do_POST()912 elif method == "POST" and self.implementsPOST():
912 elif method == "DELETE" and self.implementsDELETE():913 result = self.do_POST()
913 result = self.do_DELETE()914 elif method == "DELETE" and self.implementsDELETE():
914 else:915 result = self.do_DELETE()
915 allow_string = "GET PUT PATCH"916 else:
916 if self.implementsPOST():917 allow_string = "GET PUT PATCH"
917 allow_string += " POST"918 if self.implementsPOST():
918 if self.implementsDELETE():919 allow_string += " POST"
919 allow_string += " DELETE"920 if self.implementsDELETE():
920 self.request.response.setStatus(405)921 allow_string += " DELETE"
921 self.request.response.setHeader("Allow", allow_string)922 self.request.response.setStatus(405)
923 self.request.response.setHeader("Allow", allow_string)
924 except Exception as e:
925 # If the execption was decorated in such a way to indicate that
926 # the error is the client's fault (i.e., not a bug in the web
927 # service), then the error should be communicated to the client
928 # without the publisher framework treating the response as an
929 # error.
930 if IClientError.providedBy(e):
931 view = getMultiAdapter((e, self.request), IClientErrorView)
932 result = view()
933 self.request.response.setStatus(view.status)
934 else:
935 raise
936
922 return result937 return result
923938
924939
925940
=== modified file 'src/lazr/restful/configure.zcml'
--- src/lazr/restful/configure.zcml 2009-05-28 16:51:15 +0000
+++ src/lazr/restful/configure.zcml 2010-09-06 19:28:41 +0000
@@ -312,4 +312,13 @@
312 name="index.html"312 name="index.html"
313 />313 />
314314
315 <!-- Exceptions that are explicitly marked to be exposed via the web
316 service. -->
317 <adapter
318 for="lazr.restful.interfaces.IClientError
319 lazr.restful.interfaces.IWebServiceLayer"
320 provides="lazr.restful.interfaces.IClientErrorView"
321 factory="lazr.restful.error.ClientErrorView"
322 />
323
315</configure>324</configure>
316325
=== modified file 'src/lazr/restful/directives/__init__.py'
--- src/lazr/restful/directives/__init__.py 2010-02-11 17:57:16 +0000
+++ src/lazr/restful/directives/__init__.py 2010-09-06 19:28:41 +0000
@@ -18,7 +18,7 @@
18from lazr.restful.interfaces import (18from lazr.restful.interfaces import (
19 IServiceRootResource, IWebServiceClientRequest, IWebServiceConfiguration,19 IServiceRootResource, IWebServiceClientRequest, IWebServiceConfiguration,
20 IWebServiceLayer, IWebServiceVersion)20 IWebServiceLayer, IWebServiceVersion)
21from lazr.restful import (21from lazr.restful._resource import (
22 register_versioned_request_utility, ServiceRootResource)22 register_versioned_request_utility, ServiceRootResource)
23from lazr.restful.simple import (23from lazr.restful.simple import (
24 BaseWebServiceConfiguration, Publication, Request,24 BaseWebServiceConfiguration, Publication, Request,
2525
=== modified file 'src/lazr/restful/error.py'
--- src/lazr/restful/error.py 2009-04-16 17:29:43 +0000
+++ src/lazr/restful/error.py 2010-09-06 19:28:41 +0000
@@ -4,6 +4,8 @@
44
5__metaclass__ = type5__metaclass__ = type
6__all__ = [6__all__ = [
7 'ClientErrorView',
8 'expose',
7 'NotFoundView',9 'NotFoundView',
8 'RequestExpiredView',10 'RequestExpiredView',
9 'SystemErrorView',11 'SystemErrorView',
@@ -14,7 +16,8 @@
14import traceback16import traceback
1517
16from zope.component import getUtility18from zope.component import getUtility
17from zope.interface import implements19from zope.interface import implements, directlyProvides
20
18try:21try:
19 # This import brings some big ugly dependencies, and is not strictly22 # This import brings some big ugly dependencies, and is not strictly
20 # necessary.23 # necessary.
@@ -24,11 +27,12 @@
24 class ISystemErrorView(Interface):27 class ISystemErrorView(Interface):
25 """Error views that can classify their contexts as system errors28 """Error views that can classify their contexts as system errors
26 """29 """
27 30
28 def isSystemError():31 def isSystemError():
29 """Return a boolean indicating whether the error is a system error"""32 """Return a boolean indicating whether the error is a system error"""
3033
31from lazr.restful.interfaces import IWebServiceConfiguration34from lazr.restful.interfaces import IWebServiceConfiguration, IClientError
35
3236
33class WebServiceExceptionView:37class WebServiceExceptionView:
34 """Generic view handling exceptions on the web service."""38 """Generic view handling exceptions on the web service."""
@@ -71,6 +75,20 @@
71 return ''.join(result)75 return ''.join(result)
7276
7377
78class ClientErrorView(WebServiceExceptionView):
79 """Client-induced error."""
80 implements(ISystemErrorView)
81
82 status = 400
83
84 def isSystemError(self):
85 """See `ISystemErrorView`.
86
87 We *do not* want these logged in the SiteError log.
88 """
89 return False
90
91
74class SystemErrorView(WebServiceExceptionView):92class SystemErrorView(WebServiceExceptionView):
75 """Server error."""93 """Server error."""
76 implements(ISystemErrorView)94 implements(ISystemErrorView)
@@ -101,3 +119,20 @@
101 """View for RequestExpired exception."""119 """View for RequestExpired exception."""
102120
103 status = 503121 status = 503
122
123
124def expose(exception):
125 """Tell lazr.restful to deliver details about the exception to the client.
126 """
127 # Bug 631711 describes the possibility of controlling the HTTP status of
128 # the response (so it can be something other than 400).
129
130 # Since Python lets us raise exception types without instantiating them
131 # (like "raise RuntimeError" instead of "raise RuntimeError()", we want to
132 # make sure the caller doesn't get confused and try that with us.
133 if not isinstance(exception, BaseException):
134 raise ValueError('this function only accepts exception instanaces')
135 # Mark the exception to indicate that its details should be sent to the
136 # web service client.
137 directlyProvides(exception, IClientError)
138 return exception
104139
=== modified file 'src/lazr/restful/interfaces/_rest.py'
--- src/lazr/restful/interfaces/_rest.py 2010-08-03 15:52:46 +0000
+++ src/lazr/restful/interfaces/_rest.py 2010-09-06 19:28:41 +0000
@@ -22,6 +22,8 @@
22__all__ = [22__all__ = [
23 'IByteStorage',23 'IByteStorage',
24 'IByteStorageResource',24 'IByteStorageResource',
25 'IClientError',
26 'IClientErrorView',
25 'ICollection',27 'ICollection',
26 'ICollectionResource',28 'ICollectionResource',
27 'IEntry',29 'IEntry',
@@ -230,6 +232,19 @@
230 """Render the given string as HTML."""232 """Render the given string as HTML."""
231233
232234
235class IClientError(Interface):
236 """This denotes that an exception should be relayed to the API client.
237
238 This is a marker interface that is "slammed" onto exceptions by users of
239 lazr.restful by calling lazr.restful.error.expose on an exception before
240 they raise it.
241 """
242
243
244class IClientErrorView(Interface):
245 """The view that defines how errors are related to API clients."""
246
247
233class IEntryField(Interface):248class IEntryField(Interface):
234 """An individual field of an entry."""249 """An individual field of an entry."""
235250
236251
=== modified file 'src/lazr/restful/testing/webservice.py'
--- src/lazr/restful/testing/webservice.py 2010-08-05 14:08:20 +0000
+++ src/lazr/restful/testing/webservice.py 2010-09-06 19:28:41 +0000
@@ -98,8 +98,7 @@
98class FakeResponse:98class FakeResponse:
99 """Simple response wrapper object."""99 """Simple response wrapper object."""
100 def __init__(self):100 def __init__(self):
101 self.status = 599101 self.reset()
102 self.headers = {}
103102
104 def setStatus(self, new_status):103 def setStatus(self, new_status):
105 self.status = new_status104 self.status = new_status
@@ -115,6 +114,21 @@
115 """Return the response status code."""114 """Return the response status code."""
116 return self.status115 return self.status
117116
117 def reset(self):
118 """Set response values back to their initial state."""
119 self.status = 599
120 self.headers = {}
121 self.result = ''
122
123 def setResult(self, result):
124 if result is None:
125 result = ''
126
127 if not isinstance(result, basestring):
128 raise ValueError('only strings and None results are handled')
129
130 self.result = result
131
118132
119class FakeRequest:133class FakeRequest:
120 """Simple request object for testing purpose."""134 """Simple request object for testing purpose."""
@@ -136,6 +150,7 @@
136 self.query_string_params = {}150 self.query_string_params = {}
137 self.method = 'GET'151 self.method = 'GET'
138 self.URL = 'http://api.example.org/'152 self.URL = 'http://api.example.org/'
153 self.headers = {}
139154
140155
141 def getTraversalStack(self):156 def getTraversalStack(self):
142157
=== added file 'src/lazr/restful/tests/test_error.py'
--- src/lazr/restful/tests/test_error.py 1970-01-01 00:00:00 +0000
+++ src/lazr/restful/tests/test_error.py 2010-09-06 19:28:41 +0000
@@ -0,0 +1,88 @@
1# Copyright 2008 Canonical Ltd. All rights reserved.
2
3"""Tests of lazr.restful navigation."""
4
5__metaclass__ = type
6
7from pkg_resources import resource_filename
8import os
9import unittest
10
11from van.testing.layer import zcml_layer
12from zope.component import getMultiAdapter
13
14from lazr.restful._resource import ReadWriteResource
15from lazr.restful.error import expose, ClientErrorView, SystemErrorView
16from lazr.restful.interfaces import IClientErrorView
17from lazr.restful.testing.webservice import FakeRequest
18
19
20class TestResource(ReadWriteResource):
21
22 def __init__(self, callable, request):
23 self.callable = callable
24 super(TestResource, self).__init__(context=None, request=request)
25
26 def do_GET(self):
27 return self.callable()
28
29
30class ErrorsTestCase(unittest.TestCase):
31
32 def test_client_error_view_lookup(self):
33 # An error decorated with lazr.restful.error.exposed will be adaptable
34 # to IClientErrorView.
35 request = FakeRequest(version='trunk')
36 exception_view = getMultiAdapter(
37 (expose(ValueError()), request), IClientErrorView)
38 self.assertEquals(exception_view.__class__, ClientErrorView)
39
40 def test_undecorated_exception(self):
41 # If an undecorated exception (a regular Python exception) is
42 # generated during a request the exception percolates all the way out
43 # of the resource (to be handled by the publisher).
44
45 def broken():
46 raise RuntimeError('something broke')
47
48 request = FakeRequest(version='trunk')
49 resource = TestResource(broken, request)
50 self.assertRaises(RuntimeError, resource)
51
52 def test_decorated_exception(self):
53 # If an undecorated exception is generated during a request the
54 # SystemErrorView is used to generate the response.
55
56 def broken():
57 raise expose(RuntimeError('something broke'))
58
59 request = FakeRequest(version='trunk')
60 resource = TestResource(broken, request)
61 result = resource()
62 # The client error view sets the status code to 400 (client error).
63 self.assertEqual(request.response.getStatus(), 400)
64 # The message used to construct the exception is included in the body
65 # of the response.
66 self.assert_(result.startswith('something broke'))
67
68 def test_passing_bad_things_to_expose(self):
69 # The expose function only accepts instances of exceptions. It
70 # generates errors otherwise.
71 self.assertRaises(ValueError, expose, 1)
72 self.assertRaises(ValueError, expose, 'x')
73 self.assertRaises(ValueError, expose, RuntimeError)
74
75
76class FunctionalLayer:
77 allow_teardown = False
78 zcml = os.path.abspath(resource_filename('lazr.restful', 'ftesting.zcml'))
79
80
81zcml_layer(FunctionalLayer)
82
83
84def additional_tests():
85 """See `zope.testing.testrunner`."""
86 suite = unittest.TestLoader().loadTestsFromName(__name__)
87 suite.layer = FunctionalLayer
88 return suite

Subscribers

People subscribed via source and target branches