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
1=== modified file 'src/lazr/restful/_resource.py'
2--- src/lazr/restful/_resource.py 2010-08-24 22:12:38 +0000
3+++ src/lazr/restful/_resource.py 2010-09-06 19:28:41 +0000
4@@ -77,6 +77,7 @@
5 from lazr.enum import BaseItem
6 from lazr.lifecycle.event import ObjectModifiedEvent
7 from lazr.lifecycle.snapshot import Snapshot
8+from lazr.restful.interfaces import IClientError, IClientErrorView
9
10 from lazr.restful.interfaces import (
11 ICollection, ICollectionField, ICollectionResource, IEntry, IEntryField,
12@@ -894,31 +895,45 @@
13 """Handle a GET, PUT, or PATCH request."""
14 result = ""
15 method = self.getRequestMethod()
16- if method is None:
17- result = self.HTTP_METHOD_OVERRIDE_ERROR
18- elif method == "GET":
19- result = self.do_GET()
20- elif method in ["PUT", "PATCH"]:
21- media_type = self.handleConditionalWrite()
22- if media_type is not None:
23- stream = self.request.bodyStream
24- representation = stream.getCacheStream().read()
25- if method == "PUT":
26- result = self.do_PUT(media_type, representation)
27- else:
28- result = self.do_PATCH(media_type, representation)
29- elif method == "POST" and self.implementsPOST():
30- result = self.do_POST()
31- elif method == "DELETE" and self.implementsDELETE():
32- result = self.do_DELETE()
33- else:
34- allow_string = "GET PUT PATCH"
35- if self.implementsPOST():
36- allow_string += " POST"
37- if self.implementsDELETE():
38- allow_string += " DELETE"
39- self.request.response.setStatus(405)
40- self.request.response.setHeader("Allow", allow_string)
41+ try:
42+ if method is None:
43+ result = self.HTTP_METHOD_OVERRIDE_ERROR
44+ elif method == "GET":
45+ result = self.do_GET()
46+ elif method in ["PUT", "PATCH"]:
47+ media_type = self.handleConditionalWrite()
48+ if media_type is not None:
49+ stream = self.request.bodyStream
50+ representation = stream.getCacheStream().read()
51+ if method == "PUT":
52+ result = self.do_PUT(media_type, representation)
53+ else:
54+ result = self.do_PATCH(media_type, representation)
55+ elif method == "POST" and self.implementsPOST():
56+ result = self.do_POST()
57+ elif method == "DELETE" and self.implementsDELETE():
58+ result = self.do_DELETE()
59+ else:
60+ allow_string = "GET PUT PATCH"
61+ if self.implementsPOST():
62+ allow_string += " POST"
63+ if self.implementsDELETE():
64+ allow_string += " DELETE"
65+ self.request.response.setStatus(405)
66+ self.request.response.setHeader("Allow", allow_string)
67+ except Exception as e:
68+ # If the execption was decorated in such a way to indicate that
69+ # the error is the client's fault (i.e., not a bug in the web
70+ # service), then the error should be communicated to the client
71+ # without the publisher framework treating the response as an
72+ # error.
73+ if IClientError.providedBy(e):
74+ view = getMultiAdapter((e, self.request), IClientErrorView)
75+ result = view()
76+ self.request.response.setStatus(view.status)
77+ else:
78+ raise
79+
80 return result
81
82
83
84=== modified file 'src/lazr/restful/configure.zcml'
85--- src/lazr/restful/configure.zcml 2009-05-28 16:51:15 +0000
86+++ src/lazr/restful/configure.zcml 2010-09-06 19:28:41 +0000
87@@ -312,4 +312,13 @@
88 name="index.html"
89 />
90
91+ <!-- Exceptions that are explicitly marked to be exposed via the web
92+ service. -->
93+ <adapter
94+ for="lazr.restful.interfaces.IClientError
95+ lazr.restful.interfaces.IWebServiceLayer"
96+ provides="lazr.restful.interfaces.IClientErrorView"
97+ factory="lazr.restful.error.ClientErrorView"
98+ />
99+
100 </configure>
101
102=== modified file 'src/lazr/restful/directives/__init__.py'
103--- src/lazr/restful/directives/__init__.py 2010-02-11 17:57:16 +0000
104+++ src/lazr/restful/directives/__init__.py 2010-09-06 19:28:41 +0000
105@@ -18,7 +18,7 @@
106 from lazr.restful.interfaces import (
107 IServiceRootResource, IWebServiceClientRequest, IWebServiceConfiguration,
108 IWebServiceLayer, IWebServiceVersion)
109-from lazr.restful import (
110+from lazr.restful._resource import (
111 register_versioned_request_utility, ServiceRootResource)
112 from lazr.restful.simple import (
113 BaseWebServiceConfiguration, Publication, Request,
114
115=== modified file 'src/lazr/restful/error.py'
116--- src/lazr/restful/error.py 2009-04-16 17:29:43 +0000
117+++ src/lazr/restful/error.py 2010-09-06 19:28:41 +0000
118@@ -4,6 +4,8 @@
119
120 __metaclass__ = type
121 __all__ = [
122+ 'ClientErrorView',
123+ 'expose',
124 'NotFoundView',
125 'RequestExpiredView',
126 'SystemErrorView',
127@@ -14,7 +16,8 @@
128 import traceback
129
130 from zope.component import getUtility
131-from zope.interface import implements
132+from zope.interface import implements, directlyProvides
133+
134 try:
135 # This import brings some big ugly dependencies, and is not strictly
136 # necessary.
137@@ -24,11 +27,12 @@
138 class ISystemErrorView(Interface):
139 """Error views that can classify their contexts as system errors
140 """
141-
142+
143 def isSystemError():
144 """Return a boolean indicating whether the error is a system error"""
145
146-from lazr.restful.interfaces import IWebServiceConfiguration
147+from lazr.restful.interfaces import IWebServiceConfiguration, IClientError
148+
149
150 class WebServiceExceptionView:
151 """Generic view handling exceptions on the web service."""
152@@ -71,6 +75,20 @@
153 return ''.join(result)
154
155
156+class ClientErrorView(WebServiceExceptionView):
157+ """Client-induced error."""
158+ implements(ISystemErrorView)
159+
160+ status = 400
161+
162+ def isSystemError(self):
163+ """See `ISystemErrorView`.
164+
165+ We *do not* want these logged in the SiteError log.
166+ """
167+ return False
168+
169+
170 class SystemErrorView(WebServiceExceptionView):
171 """Server error."""
172 implements(ISystemErrorView)
173@@ -101,3 +119,20 @@
174 """View for RequestExpired exception."""
175
176 status = 503
177+
178+
179+def expose(exception):
180+ """Tell lazr.restful to deliver details about the exception to the client.
181+ """
182+ # Bug 631711 describes the possibility of controlling the HTTP status of
183+ # the response (so it can be something other than 400).
184+
185+ # Since Python lets us raise exception types without instantiating them
186+ # (like "raise RuntimeError" instead of "raise RuntimeError()", we want to
187+ # make sure the caller doesn't get confused and try that with us.
188+ if not isinstance(exception, BaseException):
189+ raise ValueError('this function only accepts exception instanaces')
190+ # Mark the exception to indicate that its details should be sent to the
191+ # web service client.
192+ directlyProvides(exception, IClientError)
193+ return exception
194
195=== modified file 'src/lazr/restful/interfaces/_rest.py'
196--- src/lazr/restful/interfaces/_rest.py 2010-08-03 15:52:46 +0000
197+++ src/lazr/restful/interfaces/_rest.py 2010-09-06 19:28:41 +0000
198@@ -22,6 +22,8 @@
199 __all__ = [
200 'IByteStorage',
201 'IByteStorageResource',
202+ 'IClientError',
203+ 'IClientErrorView',
204 'ICollection',
205 'ICollectionResource',
206 'IEntry',
207@@ -230,6 +232,19 @@
208 """Render the given string as HTML."""
209
210
211+class IClientError(Interface):
212+ """This denotes that an exception should be relayed to the API client.
213+
214+ This is a marker interface that is "slammed" onto exceptions by users of
215+ lazr.restful by calling lazr.restful.error.expose on an exception before
216+ they raise it.
217+ """
218+
219+
220+class IClientErrorView(Interface):
221+ """The view that defines how errors are related to API clients."""
222+
223+
224 class IEntryField(Interface):
225 """An individual field of an entry."""
226
227
228=== modified file 'src/lazr/restful/testing/webservice.py'
229--- src/lazr/restful/testing/webservice.py 2010-08-05 14:08:20 +0000
230+++ src/lazr/restful/testing/webservice.py 2010-09-06 19:28:41 +0000
231@@ -98,8 +98,7 @@
232 class FakeResponse:
233 """Simple response wrapper object."""
234 def __init__(self):
235- self.status = 599
236- self.headers = {}
237+ self.reset()
238
239 def setStatus(self, new_status):
240 self.status = new_status
241@@ -115,6 +114,21 @@
242 """Return the response status code."""
243 return self.status
244
245+ def reset(self):
246+ """Set response values back to their initial state."""
247+ self.status = 599
248+ self.headers = {}
249+ self.result = ''
250+
251+ def setResult(self, result):
252+ if result is None:
253+ result = ''
254+
255+ if not isinstance(result, basestring):
256+ raise ValueError('only strings and None results are handled')
257+
258+ self.result = result
259+
260
261 class FakeRequest:
262 """Simple request object for testing purpose."""
263@@ -136,6 +150,7 @@
264 self.query_string_params = {}
265 self.method = 'GET'
266 self.URL = 'http://api.example.org/'
267+ self.headers = {}
268
269
270 def getTraversalStack(self):
271
272=== added file 'src/lazr/restful/tests/test_error.py'
273--- src/lazr/restful/tests/test_error.py 1970-01-01 00:00:00 +0000
274+++ src/lazr/restful/tests/test_error.py 2010-09-06 19:28:41 +0000
275@@ -0,0 +1,88 @@
276+# Copyright 2008 Canonical Ltd. All rights reserved.
277+
278+"""Tests of lazr.restful navigation."""
279+
280+__metaclass__ = type
281+
282+from pkg_resources import resource_filename
283+import os
284+import unittest
285+
286+from van.testing.layer import zcml_layer
287+from zope.component import getMultiAdapter
288+
289+from lazr.restful._resource import ReadWriteResource
290+from lazr.restful.error import expose, ClientErrorView, SystemErrorView
291+from lazr.restful.interfaces import IClientErrorView
292+from lazr.restful.testing.webservice import FakeRequest
293+
294+
295+class TestResource(ReadWriteResource):
296+
297+ def __init__(self, callable, request):
298+ self.callable = callable
299+ super(TestResource, self).__init__(context=None, request=request)
300+
301+ def do_GET(self):
302+ return self.callable()
303+
304+
305+class ErrorsTestCase(unittest.TestCase):
306+
307+ def test_client_error_view_lookup(self):
308+ # An error decorated with lazr.restful.error.exposed will be adaptable
309+ # to IClientErrorView.
310+ request = FakeRequest(version='trunk')
311+ exception_view = getMultiAdapter(
312+ (expose(ValueError()), request), IClientErrorView)
313+ self.assertEquals(exception_view.__class__, ClientErrorView)
314+
315+ def test_undecorated_exception(self):
316+ # If an undecorated exception (a regular Python exception) is
317+ # generated during a request the exception percolates all the way out
318+ # of the resource (to be handled by the publisher).
319+
320+ def broken():
321+ raise RuntimeError('something broke')
322+
323+ request = FakeRequest(version='trunk')
324+ resource = TestResource(broken, request)
325+ self.assertRaises(RuntimeError, resource)
326+
327+ def test_decorated_exception(self):
328+ # If an undecorated exception is generated during a request the
329+ # SystemErrorView is used to generate the response.
330+
331+ def broken():
332+ raise expose(RuntimeError('something broke'))
333+
334+ request = FakeRequest(version='trunk')
335+ resource = TestResource(broken, request)
336+ result = resource()
337+ # The client error view sets the status code to 400 (client error).
338+ self.assertEqual(request.response.getStatus(), 400)
339+ # The message used to construct the exception is included in the body
340+ # of the response.
341+ self.assert_(result.startswith('something broke'))
342+
343+ def test_passing_bad_things_to_expose(self):
344+ # The expose function only accepts instances of exceptions. It
345+ # generates errors otherwise.
346+ self.assertRaises(ValueError, expose, 1)
347+ self.assertRaises(ValueError, expose, 'x')
348+ self.assertRaises(ValueError, expose, RuntimeError)
349+
350+
351+class FunctionalLayer:
352+ allow_teardown = False
353+ zcml = os.path.abspath(resource_filename('lazr.restful', 'ftesting.zcml'))
354+
355+
356+zcml_layer(FunctionalLayer)
357+
358+
359+def additional_tests():
360+ """See `zope.testing.testrunner`."""
361+ suite = unittest.TestLoader().loadTestsFromName(__name__)
362+ suite.layer = FunctionalLayer
363+ return suite

Subscribers

People subscribed via source and target branches