Merge lp:~leonardr/lazr.restful/include-html-field-representations into lp:lazr.restful

Proposed by Leonard Richardson
Status: Merged
Merged at revision: 173
Proposed branch: lp:~leonardr/lazr.restful/include-html-field-representations
Merge into: lp:lazr.restful
Diff against target: 954 lines (+402/-143)
10 files modified
src/lazr/restful/NEWS.txt (+6/-0)
src/lazr/restful/_resource.py (+73/-80)
src/lazr/restful/example/base/tests/field.txt (+36/-3)
src/lazr/restful/interfaces/_rest.py (+5/-1)
src/lazr/restful/testing/helpers.py (+12/-1)
src/lazr/restful/testing/webservice.py (+12/-0)
src/lazr/restful/tests/test_utils.py (+50/-0)
src/lazr/restful/tests/test_webservice.py (+142/-57)
src/lazr/restful/utils.py (+65/-0)
src/lazr/restful/version.txt (+1/-1)
To merge this branch: bzr merge lp:~leonardr/lazr.restful/include-html-field-representations
Reviewer Review Type Date Requested Status
Curtis Hovey (community) code Approve
j.c.sackett (community) code* Approve
Review via email: mp+50174@code.launchpad.net

Description of the change

This is a pretty interesting branch. It needs a follow-up to fix some edge cases, but I'm already past the 800-line limit so I'll do it afterwards.

The idea behind this branch is that by adding a parameter to the Accept header when you ask for a JSON representation of an entry, you can get a JSON representation that includes the essential portions of an HTML representation.

GET /foo
Accept: application/json;include=lp_html

200 Ok
Content-Type: application/json;include=lp_html
{"foo" : "value", "bar" : "value",
 "lp_html" : { "foo": "<b>value</b>", bar: "<a href=\"/bar\">bar</a>" }
}

That lp_html sub-dictionary contains HTML representations of any of the entry's fields that have an IFieldHTMLRenderer registered for them.

= Why? =

This is for the Launchpad website. Currently we get raw data from the JSON representation of the context, as stored in LP.client.cache['context']. When we change something, we send a PATCH request and ask Launchpad to send us an HTML representation of the new object. We use the HTML representation in an ad hoc way and then throw it away. Since we changed the object, the JSON representation is now in a known bad state, and we have no way of updating it.

We need a single representation that contains both the raw data and the HTML representations. We need a *single* representation because when we make a PATCH request the server only sends back one document describing the new object. My squad discussed several alternatives and settled on extending the application/json media type with a parameter that includes a sub-dictionary of field HTML representations.

We will not be describing the application/json;include=lp_html representation in the WADL, since we only intend to use it in an AJAX environment. If this turns out to be generally useful, we may integrate lp_html into the main application/json representation, and get rid of application/json;include=lp_html.

= How? =

1. The first big chunk of code to look at is parse_accept_style_header, in test_utils.py, at the end of the diff. I moved a private method out of resource.py and generalized the implementation. The basic idea is that an Accept header comes in with a bunch of media types, each of which may have a bunch of parameters including a "q" parameter:

application/json;q=0.7;include=lp_html, application/json;q=0.6, text/html;q=0.1

The goal of parse_accept_style_header is to use that "q" parameter to determine the client's preferred media type. In this case, the client would prefer application/json;include=lp_html (q=0.7), and if that's not available would prefer application/json (q=0.6), and if neither is available will settle for text/html (q=0.1).

Unfortunately, the previous implementation of parse_accept_style_header didn't work if the Accept header mentioned parameters other than 'q'. I made the code more general and added a lot of tests. Now parse_accept_style_header consumes the 'q' parameter, leaving the other parameters alone.

2. I started passing in a 'media_type' argument to simplejson.dumps(). This is passed into the ResourceJSONEncoder constructor. This makes it all the way into EntryResource.toDataStructure, which builds the 'lp_html' dictionary and adds it to the main dictionary if it turns out not to be empty.

3. The rest of this diff is tests. It's mostly unit tests but I added a doctest to demonstrate the feature in a human-readable way.

I did a drive-by cleanup of a number of the tests in test_webservice (HTMLRepresentationTest and UnicodeErrorTestCase), making them subclass EntryTestCase and having them take advantage of the context managers.

To post a comment you must log in.
Revision history for this message
j.c.sackett (jcsackett) wrote :
Download full text (4.0 KiB)

Leonard--

This looks good. Massive, but good. I've got some comments in the diff below, but they're not landing blockers.

> === modified file 'src/lazr/restful/_resource.py'
> --- src/lazr/restful/_resource.py 2011-02-01 16:40:16 +0000
> +++ src/lazr/restful/_resource.py 2011-02-17 15:42:47 +0000
>
> @@ -1165,8 +1140,9 @@
> # fields that don't correspond to some field in the
> # schema. They're all errors.
> for invalid_field in changeset.keys():
> - errors.append(u"%s: You tried to modify a nonexistent "
> - "attribute." % invalid_field)
> + if not invalid_field.endswith('_html'):
> + errors.append(u"%s: You tried to modify a nonexistent "
> + "attribute." % invalid_field)

Just commenting that you already told me you're improving this in the followup, since others may look at this MP. I agree that it's not the right way to do this, and I'm glad you're fixing it in a subsequent branch.

> === modified file 'src/lazr/restful/utils.py'
> --- src/lazr/restful/utils.py 2010-09-28 12:14:12 +0000
> +++ src/lazr/restful/utils.py 2011-02-17 15:42:47 +0000
> return versions.index(total_size_link_version) <= versions.index(version)
>
>
> +def parse_accept_style_header(value):
> + """Parse an HTTP header from the Accept-* family.
> +
> + These headers contain a list of possible values, each with an
> + optional priority.
> +
> + This code is modified from Zope's
> + BrowserLanguages#getPreferredLanguages.
> +
> + If a value includes parameters other than 'q', they will be preserved.
> +
> + :return: All values, in descending order of priority.
> + """
> + if value is None:
> + return []
> +
> + values = value.split(',')
> + # In the original getPreferredLanguages there was some language
> + # code normalization here, which I removed.

I don't know that this comment is needed; it doesn't really make sense when looking through it now. It seems like the sort of thing needed in commit history or MPs, but not code.

> + values = [v.strip() for v in values if v != ""]
> +
> + accepts = []
> + for index, value in enumerate(values):
> + value_and_parameters = value.split(';')
> +
> + # If not supplied, quality defaults to 1.
> + quality = 1.0
> +
> + if len(value_and_parameters) > 1:
> + # There's at least one parameter, possibly including
> + # a quality parameter.
> + quality_parameter = None
> + bare_value = value_and_parameters[0]
> + parameters = value_and_parameters[1:]
> + for parameter_index, parameter in enumerate(parameters):
> + if parameter.startswith('q='):
> + # We found the quality parameter
> + quality_parameter = parameter
> + q = quality_parameter.split('=', 2)[1]
> + quality = float(q)
> +
> + if quality_parameter is not None:
> + # Remove the quality parameter from the list of
> + # parameters, and re-create the header value without
> ...

Read more...

review: Approve (code*)
183. By Leonard Richardson

Minor cleanup in response to feedback.

Revision history for this message
Curtis Hovey (sinzui) wrote :
Download full text (3.9 KiB)

Hi Leonard.

I am approving this branch because I think you have a good understanding of the issues here and the code is a good read. I have a few remarks/questions about lines I would have done differently because of issues I have seen while fixing bugs. The issues are trivial to fix if they exist and I am sure you can judge if you need to make them

=== modified file 'src/lazr/restful/_resource.py'
--- src/lazr/restful/_resource.py 2011-02-01 16:40:16 +0000
+++ src/lazr/restful/_resource.py 2011-02-17 19:35:43 +0000
...
> @@ -163,6 +164,21 @@
> return str(value)
>
>
> +def _default_html_renderer(value):
> + """The default technique for rendering a value as an HTML snippet."""
> + return cgi.escape(unicode(value))

Is there any chance that the value could be a translatable message or a
structured string? If so, I think this should be
canonical.launchpad.webapp.menu.escape()
...

> @@ -1359,7 +1336,8 @@
> SUPPORTED_CONTENT_TYPES = [HTTPResource.WADL_TYPE,
> HTTPResource.DEPRECATED_WADL_TYPE,
> HTTPResource.XHTML_TYPE,
> - HTTPResource.JSON_TYPE]
> + HTTPResource.JSON_TYPE,
> + HTTPResource.JSON_PLUS_XHTML_TYPE]

If this list was written as PEP 8 advises, there would be less to change:
                               HTTPResource.JSON_TYPE,
                               HTTPResource.JSON_PLUS_XHTML_TYPE,
                               ]

...
> @@ -1438,9 +1416,21 @@
> data['web_link'] = absoluteURL(self.context, browser_request)
> data['resource_type_link'] = self.type_url
> unmarshalled_field_values = {}
> + html_renderings = {}
> for name, field in getFieldsInOrder(self.entry.schema):
> - if media_type == self.JSON_TYPE:
> - repr_name, repr_value = self._unmarshallField(name, field)
> + if media_type.startswith(self.JSON_TYPE):
> + repr_name, repr_value, html_renderer = (
> + self._field_with_html_renderer(name, field))
> + if media_type == self.JSON_PLUS_XHTML_TYPE and html_renderer != _default_html_renderer:

Can this line be wrapped? I cannot see it without scrolling left?
                if (media_type == self.JSON_PLUS_XHTML_TYPE
                    and html_renderer != _default_html_renderer):

> === modified file 'src/lazr/restful/tests/test_utils.py'
> --- src/lazr/restful/tests/test_utils.py 2010-09-28 12:14:12 +0000
> +++ src/lazr/restful/tests/test_utils.py 2011-02-17 19:35:43 +0000
>
> @@ -90,3 +91,52 @@
>
> # For the sake of convenience, test_get_current_web_service_request()
> # and tag_request_with_version_name() are tested in test_webservice.py.
> +
> +
> +class TestParseAcceptStyleHeader(unittest.TestCase):
> +
> + def test_single_value(self):
> + self.assertEquals(parse_accept_style_header("foo"), ["foo"])

I believe assertEquals() is being deprecated. Our style guide says to use
assertEqual().

> === modified file 'src/lazr/restful/utils.py'
> --- src/lazr/restful/utils.py 2010-09-28 12:14:12 ...

Read more...

review: Approve (code)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'src/lazr/restful/NEWS.txt'
2--- src/lazr/restful/NEWS.txt 2011-02-16 13:43:38 +0000
3+++ src/lazr/restful/NEWS.txt 2011-02-17 19:35:43 +0000
4@@ -2,6 +2,12 @@
5 NEWS for lazr.restful
6 =====================
7
8+0.17.0 (2011-02-17)
9+===================
10+
11+Added the ability to get a combined JSON/HTML representation of an
12+entry that has custom HTML representations for some of its fields.
13+
14 0.16.1 (2011-02-16)
15 ===================
16
17
18=== modified file 'src/lazr/restful/_resource.py'
19--- src/lazr/restful/_resource.py 2011-02-01 16:40:16 +0000
20+++ src/lazr/restful/_resource.py 2011-02-17 19:35:43 +0000
21@@ -118,6 +118,7 @@
22 extract_write_portion,
23 get_current_browser_request,
24 get_current_web_service_request,
25+ parse_accept_style_header,
26 sorted_named_things,
27 )
28
29@@ -163,6 +164,21 @@
30 return str(value)
31
32
33+def _default_html_renderer(value):
34+ """The default technique for rendering a value as an HTML snippet."""
35+ return cgi.escape(unicode(value))
36+
37+
38+@component.adapter(Interface, IField, IWebServiceClientRequest)
39+@implementer(IFieldHTMLRenderer)
40+def render_field_to_html(object, field, request):
41+ """Turn a field's current value into an XHTML snippet.
42+
43+ This is the default adapter for IFieldHTMLRenderer.
44+ """
45+ return _default_html_renderer
46+
47+
48 def register_versioned_request_utility(interface, version):
49 """Registers a marker interface as a utility for version lookup.
50
51@@ -187,6 +203,10 @@
52 instance, should implement IJSONPublishable.
53 """
54
55+ def __init__(self, *args, **kwargs):
56+ self.media_type = kwargs.pop('media_type', HTTPResource.JSON_TYPE)
57+ super(ResourceJSONEncoder, self).__init__(*args, **kwargs)
58+
59 def default(self, obj):
60 """Convert the given object to a simple data structure."""
61 if isinstance(obj, datetime) or isinstance(obj, date):
62@@ -209,7 +229,7 @@
63 if queryMultiAdapter((obj, request), IEntry):
64 obj = EntryResource(obj, request)
65
66- return IJSONPublishable(obj).toDataForJSON()
67+ return IJSONPublishable(obj).toDataForJSON(self.media_type)
68
69
70 class JSONItem:
71@@ -220,7 +240,7 @@
72 def __init__(self, context):
73 self.context = context
74
75- def toDataForJSON(self):
76+ def toDataForJSON(self, media_type):
77 """See `ISJONPublishable`"""
78 return str(self.context.title)
79
80@@ -246,6 +266,7 @@
81 # Some interesting media types.
82 WADL_TYPE = 'application/vnd.sun.wadl+xml'
83 JSON_TYPE = 'application/json'
84+ JSON_PLUS_XHTML_TYPE = 'application/json;include=lp_html'
85 XHTML_TYPE = 'application/xhtml+xml'
86
87 # This misspelling of the WADL media type was used for a while,
88@@ -257,7 +278,7 @@
89 WADL_TEMPLATE = LazrPageTemplateFile('templates/wadl-resource.pt')
90
91 # All resources serve WADL and JSON representations. Only entry
92- # resources serve XHTML representations.
93+ # resources serve XHTML representations and JSON+XHTML representations.
94 SUPPORTED_CONTENT_TYPES = [WADL_TYPE, DEPRECATED_WADL_TYPE, JSON_TYPE]
95
96 def __init__(self, context, request):
97@@ -472,7 +493,7 @@
98 """Find which content types the client prefers to receive."""
99 accept_header = (self.request.form.pop('ws.accept', None)
100 or self.request.get('HTTP_ACCEPT'))
101- return self._parseAcceptStyleHeader(accept_header)
102+ return parse_accept_style_header(accept_header)
103
104 def _parseETags(self, header_name):
105 """Extract a list of ETags from a header and parse the list.
106@@ -534,52 +555,6 @@
107 params[name] = value
108 return (disposition, params)
109
110- def _parseAcceptStyleHeader(self, value):
111- """Parse an HTTP header from the Accept-* family.
112-
113- These headers contain a list of possible values, each with an
114- optional priority.
115-
116- This code is modified from Zope's
117- BrowserLanguages#getPreferredLanguages.
118-
119- :return: All values, in descending order of priority.
120- """
121- if value is None:
122- return []
123-
124- values = value.split(',')
125- # In the original getPreferredLanguages there was some language
126- # code normalization here, which I removed.
127- values = [v for v in values if v != ""]
128-
129- accepts = []
130- for index, value in enumerate(values):
131- l = value.split(';', 2)
132-
133- # If not supplied, quality defaults to 1...
134- quality = 1.0
135-
136- if len(l) == 2:
137- q = l[1]
138- if q.startswith('q='):
139- q = q.split('=', 2)[1]
140- quality = float(q)
141-
142- if quality == 1.0:
143- # ... but we use 1.9 - 0.001 * position to
144- # keep the ordering between all items with
145- # 1.0 quality, which may include items with no quality
146- # defined, and items with quality defined as 1.
147- quality = 1.9 - (0.001 * index)
148-
149- accepts.append((quality, l[0].strip()))
150-
151- accepts = [acc for acc in accepts if acc[0] > 0]
152- accepts.sort()
153- accepts.reverse()
154- return [value for quality, value in accepts]
155-
156
157 class WebServiceBatchNavigator(BatchNavigator):
158 """A batch navigator that speaks to web service clients.
159@@ -835,13 +810,13 @@
160 self._unmarshalled_field_cache[field_name] = unmarshalled
161 return unmarshalled
162
163- def unmarshallFieldToHTML(self, field_name, field):
164- """See what a field would look like in an HTML representation.
165-
166- This is usually similar to the value of _unmarshallField(),
167- but it might contain some custom HTML weirdness.
168-
169- :return: a 2-tuple (representation_name, representation_value).
170+ def _field_with_html_renderer(self, field_name, field):
171+ """Find an IFieldHTMLRenderer for the given field.
172+
173+ :return: A 3-tuple (name, value, renderer). 'name' and 'value'
174+ are the unmarshalled field name and JSON-ready
175+ value. 'renderer' is an object capable of turning the
176+ JSON-ready value into an HTML value.
177 """
178 name, value = self._unmarshallField(field_name, field)
179 try:
180@@ -856,21 +831,21 @@
181 renderer = getMultiAdapter(
182 (self.entry.context, field, self.request),
183 IFieldHTMLRenderer)
184+ return name, value, renderer
185+
186+ def unmarshallFieldToHTML(self, field_name, field):
187+ """See what a field would look like in an HTML representation.
188+
189+ This is usually similar to the value of _unmarshallField(),
190+ but it might contain some custom HTML weirdness.
191+
192+ :return: a 2-tuple (representation_name, representation_value).
193+ """
194+ name, value, renderer = self._field_with_html_renderer(
195+ field_name, field)
196 return name, renderer(value)
197
198
199-@component.adapter(Interface, IField, IWebServiceClientRequest)
200-@implementer(IFieldHTMLRenderer)
201-def render_field_to_html(object, field, request):
202- """Turn a field's current value into an XHTML snippet.
203-
204- This is the default adapter for IFieldHTMLRenderer.
205- """
206- def unmarshall(value):
207- return cgi.escape(unicode(value))
208- return unmarshall
209-
210-
211 class ReadOnlyResource(HTTPResource):
212 """A resource that serves a string in response to GET."""
213
214@@ -1165,8 +1140,9 @@
215 # fields that don't correspond to some field in the
216 # schema. They're all errors.
217 for invalid_field in changeset.keys():
218- errors.append(u"%s: You tried to modify a nonexistent "
219- "attribute." % invalid_field)
220+ if not invalid_field.endswith('_html'):
221+ errors.append(u"%s: You tried to modify a nonexistent "
222+ "attribute." % invalid_field)
223
224 # If there were errors, display them and send a status of 400.
225 if len(errors) > 0:
226@@ -1239,6 +1215,7 @@
227 """
228 pass
229
230+
231 class EntryFieldResource(FieldUnmarshallerMixin, EntryManipulatingResource):
232 """An individual field of an entry."""
233 implements(IEntryFieldResource, IJSONPublishable)
234@@ -1359,7 +1336,8 @@
235 SUPPORTED_CONTENT_TYPES = [HTTPResource.WADL_TYPE,
236 HTTPResource.DEPRECATED_WADL_TYPE,
237 HTTPResource.XHTML_TYPE,
238- HTTPResource.JSON_TYPE]
239+ HTTPResource.JSON_TYPE,
240+ HTTPResource.JSON_PLUS_XHTML_TYPE]
241
242 def __init__(self, context, request):
243 """Associate this resource with a specific object and request."""
244@@ -1416,9 +1394,9 @@
245 return existing_write_portion in incoming_write_portions
246
247
248- def toDataForJSON(self):
249+ def toDataForJSON(self, media_type=None):
250 """Turn the object into a simple data structure."""
251- return self.toDataStructure(self.JSON_TYPE)
252+ return self.toDataStructure(media_type or self.JSON_TYPE)
253
254 def toDataStructure(self, media_type):
255 """Turn the object into a simple data structure.
256@@ -1438,9 +1416,21 @@
257 data['web_link'] = absoluteURL(self.context, browser_request)
258 data['resource_type_link'] = self.type_url
259 unmarshalled_field_values = {}
260+ html_renderings = {}
261 for name, field in getFieldsInOrder(self.entry.schema):
262- if media_type == self.JSON_TYPE:
263- repr_name, repr_value = self._unmarshallField(name, field)
264+ if media_type.startswith(self.JSON_TYPE):
265+ repr_name, repr_value, html_renderer = (
266+ self._field_with_html_renderer(name, field))
267+ if media_type == self.JSON_PLUS_XHTML_TYPE and html_renderer != _default_html_renderer:
268+ # This field has an unusual HTML renderer.
269+ if repr_value == self.REDACTED_VALUE:
270+ # The data is redacted, so the HTML
271+ # representation of that data should also
272+ # be redacted.
273+ html_value = self.REDACTED_VALUE
274+ else:
275+ html_value = html_renderer(repr_value)
276+ html_renderings[repr_name] = html_value
277 elif media_type == self.XHTML_TYPE:
278 repr_name, repr_value = self.unmarshallFieldToHTML(
279 name, field)
280@@ -1451,6 +1441,8 @@
281 data[repr_name] = repr_value
282 unmarshalled_field_values[name] = repr_value
283
284+ if len(html_renderings) > 0:
285+ data['lp_html'] = html_renderings
286 etag = self.getETag(media_type, unmarshalled_field_values)
287 data['http_etag'] = etag
288 return data
289@@ -1647,19 +1639,20 @@
290
291 if media_type in [self.WADL_TYPE, self.DEPRECATED_WADL_TYPE]:
292 return self.toWADL().encode("utf-8")
293- elif media_type == self.JSON_TYPE:
294+ elif media_type in (self.JSON_TYPE, self.JSON_PLUS_XHTML_TYPE):
295 cache = self._representation_cache
296 if cache is None:
297 representation = None
298 else:
299 representation = cache.get(
300- self.context, self.JSON_TYPE, self.request.version)
301+ self.context, media_type, self.request.version)
302
303 redacted_fields = self.redacted_fields
304 if representation is None:
305 # Either there is no active cache, or the representation
306 # wasn't in the cache.
307- representation = simplejson.dumps(self, cls=ResourceJSONEncoder)
308+ representation = simplejson.dumps(
309+ self, cls=ResourceJSONEncoder, media_type=media_type)
310 # If there's an active cache, and this representation
311 # doesn't contain any redactions, store it in the
312 # cache.
313@@ -1960,7 +1953,7 @@
314 namespace['collections'] = sorted_named_things(collection_classes)
315 return self.WADL_TEMPLATE.pt_render(namespace)
316
317- def toDataForJSON(self):
318+ def toDataForJSON(self, media_type):
319 """Return a map of links to top-level collection resources.
320
321 A top-level resource is one that adapts a utility. Currently
322
323=== modified file 'src/lazr/restful/example/base/tests/field.txt'
324--- src/lazr/restful/example/base/tests/field.txt 2011-02-02 17:48:38 +0000
325+++ src/lazr/restful/example/base/tests/field.txt 2011-02-17 19:35:43 +0000
326@@ -274,13 +274,12 @@
327 ... IFieldHTMLRenderer, IWebServiceClientRequest)
328 >>> from lazr.restful.example.base.interfaces import ICookbook
329
330+ >>> from lazr.restful.testing.webservice import simple_renderer
331 >>> @component.adapter(ICookbook, ITextLine, IWebServiceClientRequest)
332 ... @implementer(IFieldHTMLRenderer)
333 ... def dummy_renderer(context, field, request):
334 ... """Bold the original string and add a snowman."""
335- ... def render(value):
336- ... return u"\N{SNOWMAN} <b>%s</b>" % value
337- ... return render
338+ ... return simple_renderer
339
340 >>> print webservice.get(cookbook_url +'/name', 'application/xhtml+xml')
341 HTTP/1.1 200 Ok
342@@ -328,6 +327,40 @@
343 ...
344 1995-01-01
345
346+Combined JSON/HTML representations
347+----------------------------------
348+
349+You can get a combined JSON/HTML representation of an entry by setting
350+the "include=lp_html" parameter on the application/json media type.
351+
352+ >>> json = webservice.get(
353+ ... cookbook_url, 'application/json;include=lp_html').jsonBody()
354+
355+The cookbook's description is a normal JSON representation...
356+
357+ >>> print json['description']
358+ Description
359+
360+...but the JSON dictionary will include a 'lp_html' sub-dictionary...
361+
362+ >>> html = json['lp_html']
363+
364+...which includes HTML representations of the fields with HTML
365+representations:
366+
367+ >>> sorted(html.keys())
368+ [u'description', u'name']
369+
370+ >>> from lazr.restful.testing.helpers import encode_unicode
371+ >>> print encode_unicode(html['description'])
372+ \u2603 <b>Description</b>
373+
374+ >>> print encode_unicode(html['name'])
375+ \u2603 <b>The Joy of Cooking</b>
376+
377+Cleanup
378+-------
379+
380 Before we continue, here's some cleanup code to remove the custom
381 renderer we just defined.
382
383
384=== modified file 'src/lazr/restful/interfaces/_rest.py'
385--- src/lazr/restful/interfaces/_rest.py 2011-01-20 23:54:37 +0000
386+++ src/lazr/restful/interfaces/_rest.py 2011-02-17 19:35:43 +0000
387@@ -91,11 +91,15 @@
388 class IJSONPublishable(Interface):
389 """An object that can be published as a JSON data structure."""
390
391- def toDataForJSON():
392+ def toDataForJSON(media_type):
393 """Return a representation that can be turned into JSON.
394
395 The representation must consist entirely of simple data
396 structures and IJSONPublishable objects.
397+
398+ :param media_type: The media type that the data will be
399+ converted to. This will be application/json, obviously, but it
400+ may include parameters.
401 """
402
403 class IServiceRootResource(IHTTPResource):
404
405=== modified file 'src/lazr/restful/testing/helpers.py'
406--- src/lazr/restful/testing/helpers.py 2011-02-02 18:00:38 +0000
407+++ src/lazr/restful/testing/helpers.py 2011-02-17 19:35:43 +0000
408@@ -48,7 +48,18 @@
409 :param response: an httplib HTTPResponse object.
410 """
411 response_unicode = str(response).decode("utf-8")
412- return response_unicode.encode("ascii", "backslashreplace")
413+ return encode_unicode(response_unicode)
414+
415+
416+def encode_unicode(unicode_string):
417+ """Encode a Unicode string so it can be used in tests.
418+
419+ The resulting string will (eg.) contain the six characters
420+ "\u2603" instead of the single Unicode character SNOWMAN.
421+
422+ :param unicode_string: A Unicode string.
423+ """
424+ return unicode_string.encode("ascii", "backslashreplace")
425
426
427 class TestWebServiceConfiguration:
428
429=== modified file 'src/lazr/restful/testing/webservice.py'
430--- src/lazr/restful/testing/webservice.py 2011-02-02 13:31:21 +0000
431+++ src/lazr/restful/testing/webservice.py 2011-02-17 19:35:43 +0000
432@@ -14,6 +14,7 @@
433 'IGenericEntry',
434 'IGenericCollection',
435 'pprint_entry',
436+ 'simple_renderer',
437 'WebServiceTestCase',
438 'WebServiceTestConfiguration'
439 'WebServiceTestPublication',
440@@ -565,3 +566,14 @@
441 adapts(DummyRootResource, IWebServiceClientRequest)
442
443 URL = 'http://dummyurl'
444+
445+
446+def simple_renderer(value):
447+ """Bold the original string and add a snowman.
448+
449+ To use this function, define an IHTMLFieldRenderer that returns it.
450+
451+ This is a good HTML field renderer to use in tests, because it
452+ tests Unicode values and embedded HTML.
453+ """
454+ return u"\N{SNOWMAN} <b>%s</b>" % value
455
456=== modified file 'src/lazr/restful/tests/test_utils.py'
457--- src/lazr/restful/tests/test_utils.py 2010-09-28 12:14:12 +0000
458+++ src/lazr/restful/tests/test_utils.py 2011-02-17 19:35:43 +0000
459@@ -15,6 +15,7 @@
460 extract_write_portion,
461 get_current_browser_request,
462 is_total_size_link_active,
463+ parse_accept_style_header,
464 sorted_named_things,
465 )
466
467@@ -90,3 +91,52 @@
468
469 # For the sake of convenience, test_get_current_web_service_request()
470 # and tag_request_with_version_name() are tested in test_webservice.py.
471+
472+
473+class TestParseAcceptStyleHeader(unittest.TestCase):
474+
475+ def test_single_value(self):
476+ self.assertEquals(parse_accept_style_header("foo"), ["foo"])
477+
478+ def test_multiple_unodered_values(self):
479+ self.assertEquals(
480+ parse_accept_style_header("foo, bar"),
481+ ["foo", "bar"])
482+
483+ self.assertEquals(
484+ parse_accept_style_header("foo, bar,baz"),
485+ ["foo", "bar", "baz"])
486+
487+ def test_highest_quality_parameter_wins(self):
488+ self.assertEquals(
489+ parse_accept_style_header("foo;q=0.001, bar;q=0.05, baz;q=0.1"),
490+ ["baz", "bar", "foo"])
491+
492+ def test_quality_zero_is_omitted(self):
493+ self.assertEquals(
494+ parse_accept_style_header("foo;q=0, bar;q=0.5"), ["bar"])
495+
496+ def test_no_quality_parameter_is_implicit_one_point_zero(self):
497+ self.assertEquals(
498+ parse_accept_style_header("foo;q=0.5, bar"),
499+ ["bar", "foo"])
500+
501+ def test_standalone_parameter_is_untouched(self):
502+ self.assertEquals(
503+ parse_accept_style_header("foo;a=0.5"),
504+ ["foo;a=0.5"])
505+
506+ def test_quality_parameter_is_removed_next_parameter_is_untouched(self):
507+ self.assertEquals(
508+ parse_accept_style_header("foo;a=bar;q=0.5"),
509+ ["foo;a=bar"])
510+
511+ def test_quality_parameter_is_removed_earlier_parameter_is_untouched(self):
512+ self.assertEquals(
513+ parse_accept_style_header("foo;q=0.5;a=bar"),
514+ ["foo;a=bar"])
515+
516+ def test_quality_parameter_is_removed_surrounding_parameters_are_untouched(self):
517+ self.assertEquals(
518+ parse_accept_style_header("foo;a=bar;q=0.5;b=baz"),
519+ ["foo;a=bar;b=baz"])
520
521=== modified file 'src/lazr/restful/tests/test_webservice.py'
522--- src/lazr/restful/tests/test_webservice.py 2011-02-02 13:25:47 +0000
523+++ src/lazr/restful/tests/test_webservice.py 2011-02-17 19:35:43 +0000
524@@ -19,6 +19,7 @@
525 from zope.interface.interface import InterfaceClass
526 from zope.publisher.browser import TestRequest
527 from zope.schema import Choice, Date, Datetime, TextLine
528+from zope.schema.interfaces import ITextLine
529 from zope.security.management import (
530 endInteraction,
531 newInteraction,
532@@ -37,6 +38,7 @@
533 from lazr.restful.interfaces import (
534 ICollection,
535 IEntry,
536+ IFieldHTMLRenderer,
537 IResourceGETOperation,
538 IServiceRootResource,
539 IWebBrowserOriginatingRequest,
540@@ -56,6 +58,7 @@
541 DummyAbsoluteURL,
542 IGenericCollection,
543 IGenericEntry,
544+ simple_renderer,
545 WebServiceTestCase,
546 )
547 from lazr.restful.testing.tales import test_tales
548@@ -130,6 +133,8 @@
549
550 WADL_NS = "{http://research.sun.com/wadl/2006/10}"
551
552+ default_media_type = "application/json"
553+
554 class DummyWebsiteRequest:
555 """A request to the website, as opposed to the web service."""
556 implements(IWebBrowserOriginatingRequest)
557@@ -139,9 +144,10 @@
558 URL = 'http://www.website.url/'
559
560 @contextmanager
561- def request(self):
562+ def request(self, media_type=None):
563+ media_type = media_type or self.default_media_type
564 request = getUtility(IWebServiceConfiguration).createRequest(
565- StringIO(""), {})
566+ StringIO(""), {'HTTP_ACCEPT' : media_type})
567 newInteraction(request)
568 yield request
569 endInteraction()
570@@ -153,16 +159,40 @@
571 return request.publication.application.toWADL().encode('utf-8')
572
573 @contextmanager
574- def entry_resource(self, entry_interface, entry_implementation):
575+ def entry_resource(self, entry_interface, entry_implementation,
576+ *implementation_args):
577 """Create a request to an entry resource, and yield the resource."""
578 entry_class = get_resource_factory(entry_interface, IEntry)
579- data_object = entry_implementation("")
580+ data_object = entry_implementation(*implementation_args)
581
582 with self.request() as request:
583 entry = entry_class(data_object, request)
584 resource = EntryResource(data_object, request)
585 yield resource
586
587+ @contextmanager
588+ def entry_field_resource(self, entry_interface, entry_implementation,
589+ field_name, *implementation_args):
590+ entry_class = get_resource_factory(entry_interface, IEntry)
591+ data_object = entry_implementation(*implementation_args)
592+ with self.request() as request:
593+ entry = entry_class(data_object, request)
594+ field = entry.schema.get(field_name)
595+ entry_field = EntryField(entry, field, field_name)
596+ resource = EntryFieldResource(entry_field, request)
597+ yield resource
598+
599+ def register_html_field_renderer(self, entry_interface, field_interface,
600+ render_function, name=''):
601+ """Register an HTML representation for a field or class of field."""
602+ def renderer(context, field, request):
603+ return render_function
604+
605+ getGlobalSiteManager().registerAdapter(
606+ renderer,
607+ (entry_interface, field_interface, IWebServiceClientRequest),
608+ IFieldHTMLRenderer, name=name)
609+
610 def _register_url_adapter(self, entry_interface):
611 """Register an IAbsoluteURL implementation for an interface."""
612 getGlobalSiteManager().registerAdapter(
613@@ -204,7 +234,22 @@
614 self.a_field = value
615
616
617-class TestEntryRead(EntryTestCase):
618+class IHasTwoFields(Interface):
619+ """An entry with two fields."""
620+ export_as_webservice_entry()
621+ a_field = exported(TextLine(title=u"A field."))
622+ another_field = exported(TextLine(title=u"Another field."))
623+
624+
625+class HasTwoFields:
626+ """An implementation of IHasTwoFields."""
627+ implements(IHasTwoFields)
628+ def __init__(self, value1, value2):
629+ self.a_field = value1
630+ self.another_field = value2
631+
632+
633+class TestEntryWebLink(EntryTestCase):
634
635 testmodule_objects = [HasOneField, IHasOneField]
636
637@@ -221,7 +266,7 @@
638
639 # Now a representation of IHasOneField includes a
640 # 'web_link'.
641- with self.entry_resource(IHasOneField, HasOneField) as resource:
642+ with self.entry_resource(IHasOneField, HasOneField, "") as resource:
643 representation = resource.toDataForJSON()
644 self.assertEquals(representation['self_link'], DummyAbsoluteURL.URL)
645 self.assertEquals(
646@@ -253,7 +298,7 @@
647 # entry representations.
648 self._register_url_adapter(IHasOneField)
649
650- with self.entry_resource(IHasOneField, HasOneField) as resource:
651+ with self.entry_resource(IHasOneField, HasOneField, "") as resource:
652 representation = resource.toDataForJSON()
653 self.assertEquals(
654 representation['self_link'], DummyAbsoluteURL.URL)
655@@ -288,7 +333,8 @@
656 def test_entry_omits_web_link_when_suppressed(self):
657 self._register_website_url_space(IHasNoWebLink)
658
659- with self.entry_resource(IHasNoWebLink, HasNoWebLink) as resource:
660+ with self.entry_resource(IHasNoWebLink, HasNoWebLink, "") as (
661+ resource):
662 representation = resource.toDataForJSON()
663 self.assertEquals(
664 representation['self_link'], DummyAbsoluteURL.URL)
665@@ -327,11 +373,10 @@
666
667 testmodule_objects = [IHasOneField, HasOneField]
668
669-
670 def test_applyChanges_rejects_nonexistent_web_link(self):
671 # If web_link is not published, applyChanges rejects a request
672 # that references it.
673- with self.entry_resource(IHasOneField, HasOneField) as resource:
674+ with self.entry_resource(IHasOneField, HasOneField, "") as resource:
675 errors = resource.applyChanges({'web_link': u'some_value'})
676 self.assertEquals(
677 errors,
678@@ -341,7 +386,7 @@
679 """applyChanges rejects an attempt to change web_link ."""
680 self._register_website_url_space(IHasOneField)
681
682- with self.entry_resource(IHasOneField, HasOneField) as resource:
683+ with self.entry_resource(IHasOneField, HasOneField, "") as resource:
684 errors = resource.applyChanges({'web_link': u'some_value'})
685 self.assertEquals(
686 errors,
687@@ -352,7 +397,7 @@
688 # value isn't actually being changed.
689 self._register_website_url_space(IHasOneField)
690
691- with self.entry_resource(IHasOneField, HasOneField) as resource:
692+ with self.entry_resource(IHasOneField, HasOneField, "") as resource:
693 existing_web_link = resource.toDataForJSON()['web_link']
694 representation = simplejson.loads(resource.applyChanges(
695 {'web_link': existing_web_link}))
696@@ -372,8 +417,8 @@
697 expose the right interface, it will raise an exception.
698 """
699 self._register_url_adapter(IHasRestrictedField)
700- with self.entry_resource(IHasRestrictedField, HasRestrictedField) as (
701- resource):
702+ with self.entry_resource(
703+ IHasRestrictedField, HasRestrictedField, "") as resource:
704 entry = resource.entry
705 entry.schema['a_field'].restrict_to_interface = IHasRestrictedField
706 self.assertEquals(entry.a_field, '')
707@@ -390,36 +435,83 @@
708 self.assertEquals(resource.entry.a_field, 'a_value')
709
710
711-class HTMLRepresentationTest(WebServiceTestCase):
712+class HTMLRepresentationTest(EntryTestCase):
713
714- testmodule_objects = [HasRestrictedField, IHasRestrictedField]
715+ testmodule_objects = [HasOneField, IHasOneField]
716+ default_media_type = "application/xhtml+xml"
717
718 def setUp(self):
719 super(HTMLRepresentationTest, self).setUp()
720- getGlobalSiteManager().registerAdapter(
721- DummyAbsoluteURL, [IHasRestrictedField, IWebServiceClientRequest],
722- IAbsoluteURL)
723+ self._register_url_adapter(IHasOneField)
724 self.unicode_message = u"Hello from a \N{SNOWMAN}"
725 self.utf8_message = self.unicode_message.encode('utf-8')
726- self.data_object = HasRestrictedField(self.unicode_message)
727
728 def test_entry_html_representation_is_utf8(self):
729- request = getUtility(IWebServiceConfiguration).createRequest(
730- StringIO(""), {'HTTP_ACCEPT' : 'application/xhtml+xml'})
731- resource = EntryResource(self.data_object, request)
732- html = resource.do_GET()
733- self.assertTrue(self.utf8_message in html)
734+ with self.entry_resource(
735+ IHasOneField, HasOneField, self.unicode_message) as resource:
736+ html = resource.do_GET()
737+ self.assertTrue(self.utf8_message in html)
738
739 def test_field_html_representation_is_utf8(self):
740- request = getUtility(IWebServiceConfiguration).createRequest(
741- StringIO(""), {'HTTP_ACCEPT' : 'application/xhtml+xml'})
742- entry_class = get_resource_factory(IHasRestrictedField, IEntry)
743- entry = entry_class(self.data_object, request)
744- field = entry.schema.get("a_field")
745- entry_field = EntryField(entry, field, "a_field")
746- resource = EntryFieldResource(entry_field, request)
747- html = resource.do_GET()
748- self.assertTrue(html == self.utf8_message)
749+ with self.entry_field_resource(
750+ IHasOneField, HasOneField, "a_field",
751+ self.unicode_message) as resource:
752+ html = resource.do_GET()
753+ self.assertTrue(html == self.utf8_message)
754+
755+
756+class JSONPlusHTMLRepresentationTest(EntryTestCase):
757+
758+ testmodule_objects = [HasTwoFields, IHasTwoFields]
759+
760+ def setUp(self):
761+ super(JSONPlusHTMLRepresentationTest, self).setUp()
762+ self.default_media_type = "application/json;include=lp_html"
763+ self._register_url_adapter(IHasTwoFields)
764+
765+ def register_html_field_renderer(self, name=''):
766+ """Simplify the register_html_field_renderer call."""
767+ super(JSONPlusHTMLRepresentationTest,
768+ self).register_html_field_renderer(
769+ IHasTwoFields, ITextLine, simple_renderer, name)
770+
771+ @contextmanager
772+ def resource(self, value_1="value 1", value_2 = "value 2"):
773+ """Simplify the entry_resource call."""
774+ with self.entry_resource(
775+ IHasTwoFields, HasTwoFields, value_1, value_2) as resource:
776+ yield resource
777+
778+ def test_normal_json_representation_omits_lp_html(self):
779+ self.default_media_type = "application/json"
780+ self.register_html_field_renderer()
781+ with self.resource() as resource:
782+ json = simplejson.loads(resource.do_GET())
783+ self.assertFalse('lp_html' in json)
784+
785+ def test_entry_with_no_html_renderers_omits_lp_html(self):
786+ with self.resource() as resource:
787+ json = simplejson.loads(resource.do_GET())
788+ self.assertFalse('lp_html' in json)
789+
790+ def test_field_specific_html_renderer_shows_up_in_lp_html(self):
791+ self.register_html_field_renderer("a_field")
792+ with self.resource() as resource:
793+ json = simplejson.loads(resource.do_GET())
794+ html = json['lp_html']
795+ self.assertEquals(
796+ html['a_field'], simple_renderer(resource.entry.a_field))
797+
798+ def test_html_renderer_for_class_renders_all_fields_of_that_class(self):
799+ self.register_html_field_renderer()
800+ with self.resource() as resource:
801+ json = simplejson.loads(resource.do_GET())
802+ html = json['lp_html']
803+ self.assertEquals(
804+ html['a_field'], simple_renderer(resource.entry.a_field))
805+ self.assertEquals(
806+ html['another_field'],
807+ simple_renderer(resource.entry.another_field))
808
809
810 class UnicodeChoice(EnumeratedType):
811@@ -444,37 +536,30 @@
812 self.a_field = value
813
814
815-class UnicodeErrorTestCase(WebServiceTestCase):
816+class UnicodeErrorTestCase(EntryTestCase):
817 """Test that Unicode error strings are properly passed through."""
818
819 testmodule_objects = [CanBeSetToUnicodeValue, ICanBeSetToUnicodeValue]
820
821 def setUp(self):
822 super(UnicodeErrorTestCase, self).setUp()
823- getGlobalSiteManager().registerAdapter(
824- DummyAbsoluteURL,
825- [ICanBeSetToUnicodeValue, IWebServiceClientRequest], IAbsoluteURL)
826+ self._register_url_adapter(ICanBeSetToUnicodeValue)
827
828 def test_unicode_error(self):
829- entry_class = get_resource_factory(ICanBeSetToUnicodeValue, IEntry)
830- request = getUtility(IWebServiceConfiguration).createRequest(
831- StringIO(""), {})
832-
833- data_object = CanBeSetToUnicodeValue("")
834- entry = entry_class(data_object, request)
835- resource = EntryResource(data_object, request)
836-
837- # This will raise an exception, which will cause the request
838- # to fail with a 400 error code.
839- error = resource.applyChanges({'a_field': u'No such value'})
840- self.assertEqual(request.response.getStatus(), 400)
841-
842- # The error message is a Unicode string which mentions both
843- # the ASCII value and the Unicode value,
844- expected_error = (
845- u'a_field: Invalid value "No such value". Acceptable values '
846- u'are: Ascii, Uni\u00e7ode')
847- self.assertEquals(error, expected_error)
848+ with self.entry_resource(
849+ ICanBeSetToUnicodeValue, CanBeSetToUnicodeValue, "") as resource:
850+
851+ # This will raise an exception, which will cause the request
852+ # to fail with a 400 error code.
853+ error = resource.applyChanges({'a_field': u'No such value'})
854+ self.assertEqual(resource.request.response.getStatus(), 400)
855+
856+ # The error message is a Unicode string which mentions both
857+ # the ASCII value and the Unicode value,
858+ expected_error = (
859+ u'a_field: Invalid value "No such value". Acceptable values '
860+ u'are: Ascii, Uni\u00e7ode')
861+ self.assertEquals(error, expected_error)
862
863
864 class WadlAPITestCase(WebServiceTestCase):
865
866=== modified file 'src/lazr/restful/utils.py'
867--- src/lazr/restful/utils.py 2010-09-28 12:14:12 +0000
868+++ src/lazr/restful/utils.py 2011-02-17 19:35:43 +0000
869@@ -9,6 +9,7 @@
870 'get_current_web_service_request',
871 'implement_from_dict',
872 'make_identifier_safe',
873+ 'parse_accept_style_header',
874 'safe_js_escape',
875 'safe_hasattr',
876 'smartquote',
877@@ -55,6 +56,70 @@
878 return versions.index(total_size_link_version) <= versions.index(version)
879
880
881+def parse_accept_style_header(value):
882+ """Parse an HTTP header from the Accept-* family.
883+
884+ These headers contain a list of possible values, each with an
885+ optional priority.
886+
887+ This code is modified from Zope's
888+ BrowserLanguages#getPreferredLanguages.
889+
890+ If a value includes parameters other than 'q', they will be preserved.
891+
892+ :return: All values, in descending order of priority.
893+ """
894+ if value is None:
895+ return []
896+
897+ values = value.split(',')
898+ values = [v.strip() for v in values if v != ""]
899+
900+ accepts = []
901+ for index, value in enumerate(values):
902+ value_and_parameters = value.split(';')
903+
904+ # If not supplied, quality defaults to 1.
905+ quality = 1.0
906+
907+ if len(value_and_parameters) > 1:
908+ # There's at least one parameter, possibly including
909+ # a quality parameter.
910+ quality_parameter = None
911+ bare_value = value_and_parameters[0]
912+ parameters = value_and_parameters[1:]
913+ for parameter_index, parameter in enumerate(parameters):
914+ if parameter.startswith('q='):
915+ # We found the quality parameter
916+ quality_parameter = parameter
917+ q = quality_parameter.split('=', 2)[1]
918+ quality = float(q)
919+
920+ if quality_parameter is not None:
921+ # Remove the quality parameter from the list of
922+ # parameters, and re-create the header value without
923+ # it.
924+ parameters.remove(quality_parameter)
925+ if len(parameters) > 0:
926+ value = ';'.join([bare_value] + parameters)
927+ else:
928+ value = bare_value
929+
930+ if quality == 1.0:
931+ # ... but we use 1.9 - 0.001 * position to
932+ # keep the ordering between all items with
933+ # 1.0 quality, which may include items with no quality
934+ # defined, and items with quality defined as 1.
935+ quality = 1.9 - (0.001 * index)
936+
937+ accepts.append((quality, value))
938+
939+ accepts = [acc for acc in accepts if acc[0] > 0]
940+ accepts.sort()
941+ accepts.reverse()
942+ return [value for quality, value in accepts]
943+
944+
945 class VersionedDict(object):
946 """A stack of named dictionaries.
947
948
949=== modified file 'src/lazr/restful/version.txt'
950--- src/lazr/restful/version.txt 2011-02-16 13:43:38 +0000
951+++ src/lazr/restful/version.txt 2011-02-17 19:35:43 +0000
952@@ -1,1 +1,1 @@
953-0.16.1
954+0.17.0

Subscribers

People subscribed via source and target branches