Merge lp:~leonardr/lazr.restful/launchpad-field-put into lp:lazr.restful

Proposed by Leonard Richardson
Status: Merged
Approved by: Francis J. Lacoste
Approved revision: 26
Merge reported by: Francis J. Lacoste
Merged at revision: not available
Proposed branch: lp:~leonardr/lazr.restful/launchpad-field-put
Merge into: lp:lazr.restful
Diff against target: None lines
To merge this branch: bzr merge lp:~leonardr/lazr.restful/launchpad-field-put
Reviewer Review Type Date Requested Status
Francis J. Lacoste (community) Approve
Review via email: mp+6979@code.launchpad.net
To post a comment you must log in.
Revision history for this message
Leonard Richardson (leonardr) wrote :

This is a backport of lp:~leonardr/lazr.restful/380959-field-patch to the lazr.restful branch used by Launchpad.

I had a lot of trouble running the tests due to dependency problems, but once I ran it there was only one real problem. The call to field.bind() in applyChanges() passed in self.entry, when it should have passed in self.entry.context. This didn't break the lazr.restful tests, because changes to a generated entry's fields are automatically passed on to the entry's context. But it did break some launchpad tests, because the ValidPersonOrTeam vocabulary field explicitly checks the type of the thing it's bound to.

I'll forward-port the fix, but is it worth trying to also recreate the ValidPersonOrTeam test case within lazr.restful?

Revision history for this message
Francis J. Lacoste (flacoste) wrote :

I think it's worth adding a test. I don't think re-implementing ValidPersonOrTeam is the right way to do, but there should be a test that asserts that the correct object is passed in by the API.

review: Approve

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 2009-04-06 16:57:08 +0000
3+++ src/lazr/restful/_resource.py 2009-06-02 16:04:54 +0000
4@@ -48,16 +48,19 @@
5 from zope.interface import (
6 implementer, implements, implementedBy, providedBy, Interface)
7 from zope.interface.interfaces import IInterface
8+from zope.location.interfaces import ILocation
9 from zope.pagetemplate.pagetemplatefile import PageTemplateFile
10 from zope.proxy import isProxy
11 from zope.publisher.interfaces import NotFound
12+from zope.publisher.interfaces.http import IHTTPRequest
13 from zope.schema import ValidationError, getFieldsInOrder
14 from zope.schema.interfaces import (
15 ConstraintNotSatisfied, IBytes, IField, IObject)
16 from zope.security.interfaces import Unauthorized
17 from zope.security.proxy import removeSecurityProxy
18 from zope.security.management import checkPermission
19-from zope.traversing.browser import absoluteURL
20+from zope.traversing.browser import absoluteURL, AbsoluteURL
21+from zope.traversing.browser.interfaces import IAbsoluteURL
22
23 from lazr.batchnavigator import BatchNavigator
24 from lazr.enum import BaseItem
25@@ -838,6 +841,18 @@
26 self.request.response.setHeader("Allow", allow_string)
27 return self.applyTransferEncoding(result)
28
29+ def processAsJSONDocument(self, media_type, representation):
30+ """Process an incoming representation as a JSON document."""
31+ if not media_type.startswith(self.JSON_TYPE):
32+ self.request.response.setStatus(415)
33+ return None, 'Expected a media type of %s.' % self.JSON_TYPE
34+ try:
35+ h = simplejson.loads(unicode(representation))
36+ except ValueError:
37+ self.request.response.setStatus(400)
38+ return None, "Entity-body was not a well-formed JSON document."
39+ return h, None
40+
41
42 class EntryHTMLView:
43 """An HTML view of an entry."""
44@@ -862,274 +877,29 @@
45 return self.HTML_TEMPLATE.pt_render(namespace)
46
47
48-class EntryFieldResource(ReadOnlyResource, FieldUnmarshallerMixin):
49- """An individual field of an entry."""
50- implements(IEntryFieldResource, IJSONPublishable)
51-
52- SUPPORTED_CONTENT_TYPES = [HTTPResource.JSON_TYPE,
53- HTTPResource.XHTML_TYPE]
54-
55- def __init__(self, context, request):
56- """Initialize with respect to a context and request."""
57- super(EntryFieldResource, self).__init__(context, request)
58- self.entry = self.context.entry
59-
60- def do_GET(self):
61- """Create a representation of a single field."""
62- media_type = self.handleConditionalGET()
63- if media_type is None:
64- # The conditional GET succeeded. Serve nothing.
65- return ""
66- else:
67- self.request.response.setHeader('Content-Type', media_type)
68- return self._representation(media_type)
69-
70- def _getETagCore(self, unmarshalled_field_values=None):
71- """Calculate the ETag for an entry field.
72-
73- The core of the ETag is the field value itself.
74- """
75- name, value = self._unmarshallField(
76- self.context.name, self.context.field)
77- return str(value)
78-
79- def _representation(self, media_type):
80- """Create a representation of the field value."""
81- if media_type == self.JSON_TYPE:
82- name, value = self._unmarshallField(
83- self.context.name, self.context.field, CLOSEUP_DETAIL)
84- return simplejson.dumps(value)
85- elif media_type == self.XHTML_TYPE:
86- name, value = self.unmarshallFieldToHTML(
87- self.context.name, self.context.field)
88- return value
89- else:
90- raise AssertionError(
91- "No representation implementation for media type %s"
92- % media_type)
93-
94-
95-class EntryField:
96- implements(IEntryField)
97-
98- def __init__(self, entry, field, name):
99- """Initialize with respect to a named field of an entry."""
100- self.entry = entry
101- self.field = field.bind(entry)
102- self.name = name
103-
104-
105-class EntryResource(ReadWriteResource, CustomOperationResourceMixin,
106- FieldUnmarshallerMixin):
107- """An individual object, published to the web."""
108- implements(IEntryResource, IJSONPublishable)
109-
110- SUPPORTED_CONTENT_TYPES = [HTTPResource.WADL_TYPE,
111- HTTPResource.XHTML_TYPE,
112- HTTPResource.JSON_TYPE]
113-
114- def __init__(self, context, request):
115- """Associate this resource with a specific object and request."""
116- super(EntryResource, self).__init__(context, request)
117- self.entry = IEntry(context)
118-
119- def _getETagCore(self, unmarshalled_field_values=None):
120- """Calculate the ETag for an entry.
121-
122- :arg unmarshalled_field_values: A dict mapping field names to
123- unmarshalled values, obtained during some other operation such
124- as the construction of a representation.
125- """
126- values = []
127- for name, field in getFieldsInOrder(self.entry.schema):
128- if self.isModifiableField(field, False):
129- if (unmarshalled_field_values is not None
130- and unmarshalled_field_values.get(name)):
131- value = unmarshalled_field_values[name]
132- else:
133- ignored, value = self._unmarshallField(name, field)
134- values.append(unicode(value))
135- return "\0".join(values).encode("utf-8")
136-
137- def toDataForJSON(self):
138- """Turn the object into a simple data structure."""
139- return self.toDataStructure(self.JSON_TYPE)
140-
141- def toDataStructure(self, media_type):
142- """Turn the object into a simple data structure.
143-
144- In this case, a dictionary containing all fields defined by
145- the resource interface.
146-
147- The values in the dictionary may differ depending on the value
148- of media_type.
149- """
150- data = {}
151- data['self_link'] = absoluteURL(self.context, self.request)
152- data['resource_type_link'] = self.type_url
153- unmarshalled_field_values = {}
154- for name, field in getFieldsInOrder(self.entry.schema):
155- if media_type == self.JSON_TYPE:
156- repr_name, repr_value = self._unmarshallField(name, field)
157- elif media_type == self.XHTML_TYPE:
158- repr_name, repr_value = self.unmarshallFieldToHTML(
159- name, field)
160- else:
161- raise AssertionError(
162- "Cannot create data structure for media type %s"
163- % media_type)
164- data[repr_name] = repr_value
165- unmarshalled_field_values[name] = repr_value
166-
167- etag = self.getETag(media_type, unmarshalled_field_values)
168- data['http_etag'] = etag
169- return data
170-
171- def toXHTML(self):
172- """Represent this resource as an XHTML document."""
173- view = getMultiAdapter(
174- (self.context, self.request),
175- name="lazr.restful.EntryResource")
176- return view()
177-
178- def processAsJSONHash(self, media_type, representation):
179- """Process an incoming representation as a JSON hash.
180-
181- :param media_type: The specified media type of the incoming
182- representation.
183-
184- :representation: The incoming representation:
185-
186- :return: A tuple (dictionary, error). 'dictionary' is a Python
187- dictionary corresponding to the incoming JSON hash. 'error' is
188- an error message if the incoming representation could not be
189- processed. If there is an error, this method will set an
190- appropriate HTTP response code.
191- """
192-
193- if not media_type.startswith(self.JSON_TYPE):
194- self.request.response.setStatus(415)
195- return None, 'Expected a media type of %s.' % self.JSON_TYPE
196- try:
197- h = simplejson.loads(unicode(representation))
198- except ValueError:
199- self.request.response.setStatus(400)
200- return None, "Entity-body was not a well-formed JSON document."
201- if not isinstance(h, dict):
202- self.request.response.setStatus(400)
203- return None, 'Expected a JSON hash.'
204- return h, None
205-
206- def do_GET(self):
207- """Render an appropriate representation of the entry."""
208- # Handle a custom operation, probably a search.
209- operation_name = self.request.form.pop('ws.op', None)
210- if operation_name is not None:
211- result = self.handleCustomGET(operation_name)
212- if isinstance(result, basestring):
213- # The custom operation took care of everything and
214- # just needs this string served to the client.
215- return result
216- else:
217- # No custom operation was specified. Implement a standard
218- # GET, which serves a JSON or WADL representation of the
219- # entry.
220- media_type = self.handleConditionalGET()
221- if media_type is None:
222- # The conditional GET succeeded. Serve nothing.
223- return ""
224- else:
225- self.request.response.setHeader('Content-Type', media_type)
226- return self._representation(media_type)
227-
228- def do_PUT(self, media_type, representation):
229- """Modify the entry's state to match the given representation.
230-
231- A PUT is just like a PATCH, except the given representation
232- must be a complete representation of the entry.
233- """
234- changeset, error = self.processAsJSONHash(media_type, representation)
235- if error is not None:
236- return error
237-
238- # Make sure the representation includes values for all
239- # writable attributes.
240- # Get the fields ordered by name so that we always evaluate them in
241- # the same order. This is needed to predict errors when testing.
242- for name, field in getFieldsInOrder(self.entry.schema):
243- if not self.isModifiableField(field, True):
244- continue
245- field = field.bind(self.context)
246- marshaller = getMultiAdapter((field, self.request),
247- IFieldMarshaller)
248- repr_name = marshaller.representation_name
249- if (changeset.get(repr_name) is None
250- and getattr(self.entry, name) is not None):
251- # This entry has a value for the attribute, but the
252- # entity-body of the PUT request didn't make any assertion
253- # about the attribute. The resource's behavior under HTTP
254- # is undefined; we choose to send an error.
255- self.request.response.setStatus(400)
256- return ("You didn't specify a value for the attribute '%s'."
257- % repr_name)
258- return self._applyChanges(changeset)
259-
260- def do_PATCH(self, media_type, representation):
261- """Apply a JSON patch to the entry."""
262- changeset, error = self.processAsJSONHash(media_type, representation)
263- if error is not None:
264- return error
265- return self._applyChanges(changeset)
266-
267- @property
268- def type_url(self):
269- "The URL to the resource type for this resource."
270- adapter = EntryAdapterUtility(self.entry.__class__)
271-
272- return "%s#%s" % (
273- absoluteURL(self.request.publication.getApplication(
274- self.request), self.request),
275- adapter.singular_type)
276-
277- def isModifiableField(self, field, is_external_client):
278- """Returns true if this field's value can be changed.
279-
280- Collection fields, and fields that are not part of the web
281- service interface, are never modifiable. Read-only fields are
282- not modifiable by external clients.
283-
284- :param is_external_client: Whether the code trying to modify
285- the field is an external client. Read-only fields cannot be
286- directly modified from external clients, but they might change
287- as side effects of other changes.
288- """
289- if (ICollectionField.providedBy(field)
290- or field.__name__.startswith('_')):
291- return False
292- if field.readonly:
293- return not is_external_client
294- return True
295-
296- def _representation(self, media_type):
297- """Return a representation of this entry, of the given media type."""
298- if media_type == self.WADL_TYPE:
299- return self.toWADL().encode("utf-8")
300- elif media_type == self.JSON_TYPE:
301- return simplejson.dumps(self, cls=ResourceJSONEncoder)
302- elif media_type == self.XHTML_TYPE:
303- return self.toXHTML().encode("utf-8")
304- else:
305- raise AssertionError(
306- "No representation implementation for media type %s"
307- % media_type)
308-
309- def _applyChanges(self, changeset):
310+class UpdatesEntryMixin:
311+ """A resource that updates some aspect of an IEntry.
312+
313+ A class that inherits from this mixin is expected to define an
314+ attribute 'entry' containing the IEntry to be updated.
315+ """
316+
317+ def __init__(self, context, request):
318+ """A basic constructor."""
319+ # Like all mixin classes, this class is designed to be used
320+ # with multiple inheritance. That requires defining __init__
321+ # to call the next constructor in the chain, which means using
322+ # super() even though this class itself has no superclass.
323+ super(UpdatesEntryMixin, self).__init__(context, request)
324+
325+ def applyChanges(self, changeset):
326 """Apply a dictionary of key-value pairs as changes to an entry.
327
328 :param changeset: A dictionary. Should come from an incoming
329 representation.
330
331- :return: An error message to be propagated to the client.
332+ :return: If there was an error, a string error message to be
333+ propagated to the client.
334 """
335 changeset = copy.copy(changeset)
336 validated_changeset = {}
337@@ -1140,7 +910,7 @@
338 modified_read_only_attribute = ("%s: You tried to modify a "
339 "read-only attribute.")
340 if 'self_link' in changeset:
341- if changeset['self_link'] != absoluteURL(self.context,
342+ if changeset['self_link'] != absoluteURL(self.entry.context,
343 self.request):
344 errors.append(modified_read_only_attribute % 'self_link')
345 del changeset['self_link']
346@@ -1165,7 +935,7 @@
347 if name.startswith('_'):
348 # This field is not part of the web service interface.
349 continue
350- field = field.bind(self.context)
351+ field = field.bind(self.entry.context)
352 marshaller = getMultiAdapter((field, self.request),
353 IFieldMarshaller)
354 repr_name = marshaller.representation_name
355@@ -1306,7 +1076,7 @@
356 self.entry.context, providing=providedBy(self.entry.context))
357
358 # Store the entry's current URL so we can see if it changes.
359- original_url = absoluteURL(self.context, self.request)
360+ original_url = absoluteURL(self.entry.context, self.request)
361 # Make the changes.
362 for field, (name, value) in validated_changeset.items():
363 field.set(self.entry, value)
364@@ -1326,22 +1096,300 @@
365 edited_fields=validated_changeset.keys())
366 notify(event)
367
368- # If the modification caused the entry's URL to change, tell
369- # the client about the new URL.
370- new_url = absoluteURL(self.context, self.request)
371- if new_url != original_url:
372+ new_url = absoluteURL(self.entry.context, self.request)
373+ if new_url == original_url:
374+ # The resource did not move. Serve a new representation of
375+ # it. This might not necessarily be a representation of
376+ # the whole entry!
377+ self.request.response.setStatus(209)
378+ media_type = self.getPreferredSupportedContentType()
379+ self.request.response.setHeader('Content-type', media_type)
380+ return self._representation(media_type)
381+ else:
382+ # The object moved. Serve a redirect to its new location.
383+ # This might not necessarily be the location of the entry!
384 self.request.response.setStatus(301)
385- self.request.response.setHeader('Location', new_url)
386+ self.request.response.setHeader(
387+ 'Location', absoluteURL(self.context, self.request))
388 # RFC 2616 says the body of a 301 response, if present,
389 # SHOULD be a note linking to the new object.
390 return ''
391
392- # If the object didn't move, serve up its representation.
393- self.request.response.setStatus(209)
394-
395- media_type = self.getPreferredSupportedContentType()
396- self.request.response.setHeader('Content-type', media_type)
397- return self._representation(media_type)
398+
399+class EntryFieldResource(FieldUnmarshallerMixin, UpdatesEntryMixin,
400+ ReadWriteResource):
401+ """An individual field of an entry."""
402+ implements(IEntryFieldResource, IJSONPublishable)
403+
404+ SUPPORTED_CONTENT_TYPES = [HTTPResource.JSON_TYPE,
405+ HTTPResource.XHTML_TYPE]
406+
407+ def __init__(self, context, request):
408+ """Initialize with respect to a context and request."""
409+ super(EntryFieldResource, self).__init__(context, request)
410+ self.entry = self.context.entry
411+
412+ def do_GET(self):
413+ """Create a representation of a single field."""
414+ media_type = self.handleConditionalGET()
415+ if media_type is None:
416+ # The conditional GET succeeded. Serve nothing.
417+ return ""
418+ else:
419+ self.request.response.setHeader('Content-Type', media_type)
420+ return self._representation(media_type)
421+
422+ def do_PUT(self, media_type, representation):
423+ """Overwrite the field's existing value with a new value."""
424+ value, error = self.processAsJSONDocument(media_type, representation)
425+ if value is None:
426+ return value, error
427+ changeset = {self.context.field.__name__ : value}
428+ return self.applyChanges(changeset)
429+
430+ do_PATCH = do_PUT
431+
432+ def _getETagCore(self, unmarshalled_field_values=None):
433+ """Calculate the ETag for an entry field.
434+
435+ The core of the ETag is the field value itself.
436+ """
437+ name, value = self._unmarshallField(
438+ self.context.name, self.context.field)
439+ return str(value)
440+
441+ def _representation(self, media_type):
442+ """Create a representation of the field value."""
443+ if media_type == self.JSON_TYPE:
444+ name, value = self._unmarshallField(
445+ self.context.name, self.context.field, CLOSEUP_DETAIL)
446+ return simplejson.dumps(value)
447+ elif media_type == self.XHTML_TYPE:
448+ name, value = self.unmarshallFieldToHTML(
449+ self.context.name, self.context.field)
450+ return value
451+ else:
452+ raise AssertionError(
453+ "No representation implementation for media type %s"
454+ % media_type)
455+
456+
457+class EntryField:
458+ """A schema field bound by name to a particular entry."""
459+ implements(IEntryField, ILocation)
460+
461+ def __init__(self, entry, field, name):
462+ """Initialize with respect to a named field of an entry."""
463+ self.entry = entry
464+ self.field = field.bind(entry)
465+ self.name = name
466+
467+ # ILocation implementation
468+ self.__parent__ = self.entry.context
469+ self.__name__ = self.name
470+
471+
472+class EntryResource(CustomOperationResourceMixin,
473+ FieldUnmarshallerMixin, UpdatesEntryMixin,
474+ ReadWriteResource):
475+ """An individual object, published to the web."""
476+ implements(IEntryResource, IJSONPublishable)
477+
478+ SUPPORTED_CONTENT_TYPES = [HTTPResource.WADL_TYPE,
479+ HTTPResource.XHTML_TYPE,
480+ HTTPResource.JSON_TYPE]
481+
482+ def __init__(self, context, request):
483+ """Associate this resource with a specific object and request."""
484+ super(EntryResource, self).__init__(context, request)
485+ self.entry = IEntry(context)
486+
487+ def _getETagCore(self, unmarshalled_field_values=None):
488+ """Calculate the ETag for an entry.
489+
490+ :arg unmarshalled_field_values: A dict mapping field names to
491+ unmarshalled values, obtained during some other operation such
492+ as the construction of a representation.
493+ """
494+ values = []
495+ for name, field in getFieldsInOrder(self.entry.schema):
496+ if self.isModifiableField(field, False):
497+ if (unmarshalled_field_values is not None
498+ and unmarshalled_field_values.get(name)):
499+ value = unmarshalled_field_values[name]
500+ else:
501+ ignored, value = self._unmarshallField(name, field)
502+ values.append(unicode(value))
503+ return "\0".join(values).encode("utf-8")
504+
505+ def toDataForJSON(self):
506+ """Turn the object into a simple data structure."""
507+ return self.toDataStructure(self.JSON_TYPE)
508+
509+ def toDataStructure(self, media_type):
510+ """Turn the object into a simple data structure.
511+
512+ In this case, a dictionary containing all fields defined by
513+ the resource interface.
514+
515+ The values in the dictionary may differ depending on the value
516+ of media_type.
517+ """
518+ data = {}
519+ data['self_link'] = absoluteURL(self.context, self.request)
520+ data['resource_type_link'] = self.type_url
521+ unmarshalled_field_values = {}
522+ for name, field in getFieldsInOrder(self.entry.schema):
523+ if media_type == self.JSON_TYPE:
524+ repr_name, repr_value = self._unmarshallField(name, field)
525+ elif media_type == self.XHTML_TYPE:
526+ repr_name, repr_value = self.unmarshallFieldToHTML(
527+ name, field)
528+ else:
529+ raise AssertionError(
530+ "Cannot create data structure for media type %s"
531+ % media_type)
532+ data[repr_name] = repr_value
533+ unmarshalled_field_values[name] = repr_value
534+
535+ etag = self.getETag(media_type, unmarshalled_field_values)
536+ data['http_etag'] = etag
537+ return data
538+
539+ def toXHTML(self):
540+ """Represent this resource as an XHTML document."""
541+ view = getMultiAdapter(
542+ (self.context, self.request),
543+ name="lazr.restful.EntryResource")
544+ return view()
545+
546+ def processAsJSONHash(self, media_type, representation):
547+ """Process an incoming representation as a JSON hash.
548+
549+ :param media_type: The specified media type of the incoming
550+ representation.
551+
552+ :representation: The incoming representation:
553+
554+ :return: A tuple (dictionary, error). 'dictionary' is a Python
555+ dictionary corresponding to the incoming JSON hash. 'error' is
556+ an error message if the incoming representation could not be
557+ processed. If there is an error, this method will set an
558+ appropriate HTTP response code.
559+ """
560+
561+ value, error = self.processAsJSONDocument(media_type, representation)
562+ if value is None:
563+ return value, error
564+ if not isinstance(value, dict):
565+ self.request.response.setStatus(400)
566+ return None, 'Expected a JSON hash.'
567+ return value, None
568+
569+ def do_GET(self):
570+ """Render an appropriate representation of the entry."""
571+ # Handle a custom operation, probably a search.
572+ operation_name = self.request.form.pop('ws.op', None)
573+ if operation_name is not None:
574+ result = self.handleCustomGET(operation_name)
575+ if isinstance(result, basestring):
576+ # The custom operation took care of everything and
577+ # just needs this string served to the client.
578+ return result
579+ else:
580+ # No custom operation was specified. Implement a standard
581+ # GET, which serves a JSON or WADL representation of the
582+ # entry.
583+ media_type = self.handleConditionalGET()
584+ if media_type is None:
585+ # The conditional GET succeeded. Serve nothing.
586+ return ""
587+ else:
588+ self.request.response.setHeader('Content-Type', media_type)
589+ return self._representation(media_type)
590+
591+ def do_PUT(self, media_type, representation):
592+ """Modify the entry's state to match the given representation.
593+
594+ A PUT is just like a PATCH, except the given representation
595+ must be a complete representation of the entry.
596+ """
597+ changeset, error = self.processAsJSONHash(media_type, representation)
598+ if error is not None:
599+ return error
600+
601+ # Make sure the representation includes values for all
602+ # writable attributes.
603+ # Get the fields ordered by name so that we always evaluate them in
604+ # the same order. This is needed to predict errors when testing.
605+ for name, field in getFieldsInOrder(self.entry.schema):
606+ if not self.isModifiableField(field, True):
607+ continue
608+ field = field.bind(self.context)
609+ marshaller = getMultiAdapter((field, self.request),
610+ IFieldMarshaller)
611+ repr_name = marshaller.representation_name
612+ if (changeset.get(repr_name) is None
613+ and getattr(self.entry, name) is not None):
614+ # This entry has a value for the attribute, but the
615+ # entity-body of the PUT request didn't make any assertion
616+ # about the attribute. The resource's behavior under HTTP
617+ # is undefined; we choose to send an error.
618+ self.request.response.setStatus(400)
619+ return ("You didn't specify a value for the attribute '%s'."
620+ % repr_name)
621+
622+ return self.applyChanges(changeset)
623+
624+
625+ def do_PATCH(self, media_type, representation):
626+ """Apply a JSON patch to the entry."""
627+ changeset, error = self.processAsJSONHash(media_type, representation)
628+ if error is not None:
629+ return error
630+ return self.applyChanges(changeset)
631+
632+ @property
633+ def type_url(self):
634+ "The URL to the resource type for this resource."
635+ adapter = EntryAdapterUtility(self.entry.__class__)
636+
637+ return "%s#%s" % (
638+ absoluteURL(self.request.publication.getApplication(
639+ self.request), self.request),
640+ adapter.singular_type)
641+
642+ def isModifiableField(self, field, is_external_client):
643+ """Returns true if this field's value can be changed.
644+
645+ Collection fields, and fields that are not part of the web
646+ service interface, are never modifiable. Read-only fields are
647+ not modifiable by external clients.
648+
649+ :param is_external_client: Whether the code trying to modify
650+ the field is an external client. Read-only fields cannot be
651+ directly modified from external clients, but they might change
652+ as side effects of other changes.
653+ """
654+ if (ICollectionField.providedBy(field)
655+ or field.__name__.startswith('_')):
656+ return False
657+ if field.readonly:
658+ return not is_external_client
659+ return True
660+
661+ def _representation(self, media_type):
662+ """Return a representation of this entry, of the given media type."""
663+ if media_type == self.WADL_TYPE:
664+ return self.toWADL().encode("utf-8")
665+ elif media_type == self.JSON_TYPE:
666+ return simplejson.dumps(self, cls=ResourceJSONEncoder)
667+ elif media_type == self.XHTML_TYPE:
668+ return self.toXHTML().encode("utf-8")
669+ else:
670+ raise AssertionError(
671+ "No representation implementation for media type %s"
672+ % media_type)
673
674
675 class CollectionResource(ReadOnlyResource, BatchingResourceMixin,
676
677=== modified file 'src/lazr/restful/configure.zcml'
678--- src/lazr/restful/configure.zcml 2009-03-26 17:25:22 +0000
679+++ src/lazr/restful/configure.zcml 2009-06-02 16:04:54 +0000
680@@ -133,6 +133,14 @@
681
682 <adapter factory="lazr.restful.JSONItem" />
683
684+ <!-- Adapter for URL generation -->
685+ <adapter
686+ for="lazr.restful.interfaces.IEntryField
687+ zope.publisher.interfaces.http.IHTTPApplicationRequest"
688+ provides="zope.traversing.browser.interfaces.IAbsoluteURL"
689+ factory="zope.traversing.browser.AbsoluteURL"
690+ />
691+
692 <!-- Field resources -->
693 <adapter
694 for="lazr.restful.interfaces.IEntryField
695
696=== modified file 'src/lazr/restful/example/tests/field.txt'
697--- src/lazr/restful/example/tests/field.txt 2009-04-06 16:57:08 +0000
698+++ src/lazr/restful/example/tests/field.txt 2009-06-02 16:04:54 +0000
699@@ -1,7 +1,8 @@
700 = Field resources =
701
702-It's possible to get the value of one particular field. You can get a
703-JSON or XHTML-fragment representation.
704+Each of an entry's fields has its own HTTP resource. If you only need
705+to change one of an entry's fields, you can send PUT or PATCH to the
706+field resource itself, rather than PUT/PATCH to the entry.
707
708 >>> from lazr.restful.testing.webservice import CookbookWebServiceCaller
709 >>> webservice = CookbookWebServiceCaller()
710@@ -13,17 +14,38 @@
711 >>> import simplejson
712 >>> def set_description(description):
713 ... """Sets the description for "The Joy of Cooking"."""
714- ... representation = {'description': description}
715- ... ignore = webservice(cookbook_url, 'PATCH',
716- ... simplejson.dumps(representation))
717+ ... return webservice(field_url, 'PATCH',
718+ ... simplejson.dumps(description)).jsonBody()
719
720- >>> set_description("<b>Bold description</b>")
721+ >>> print set_description("New description")
722+ New description
723
724 >>> print webservice.get(field_url)
725 HTTP/1.1 200 Ok
726 ...
727 Content-Type: application/json
728 ...
729+ "New description"
730+
731+PATCH on a field resource works identically to PUT.
732+
733+ >>> representation = simplejson.dumps('<b>Bold description</b>')
734+ >>> print webservice.put(field_url, 'application/json',
735+ ... representation).jsonBody()
736+ <b>Bold description</b>
737+
738+The same rules for modifying a field apply whether you're modifying
739+the entry as a whole or just modifying a single field.
740+
741+ >>> date_field_url = cookbook_url + "/copyright_date"
742+ >>> print webservice.put(date_field_url, 'application/json',
743+ ... simplejson.dumps("string"))
744+ HTTP/1.1 400 Bad Request
745+ ...
746+ copyright_date: Value doesn't look like a date.
747+
748+Field resources also support GET, for when you only need part of an
749+entry. You can get either a JSON or XHTML-fragment representation.
750
751 >>> print webservice.get(field_url).jsonBody()
752 <b>Bold description</b>
753@@ -35,7 +57,47 @@
754 ...
755 &lt;b&gt;Bold description&lt;/b&gt;
756
757- >>> set_description("Description")
758+Cleanup.
759+
760+ >>> ignored = set_description("Description")
761+
762+Changing a field resource can move the entry
763+--------------------------------------------
764+
765+If you modify a field that the entry uses as part of its URL (such as
766+a cookbook's name), the field's URL will change. You'll be redirected
767+to the new field URL.
768+
769+ >>> name_url = cookbook_url + "/name"
770+ >>> representation = simplejson.dumps("The Joy of Cooking Extreme")
771+ >>> print webservice.put(name_url, 'application/json',
772+ ... representation)
773+ HTTP/1.1 301 Moved Permanently
774+ ...
775+ Location: http://.../cookbooks/The%20Joy%20of%20Cooking%20Extreme/name
776+ <BLANKLINE>
777+
778+Note that the entry's URL has also changed.
779+
780+ >>> print webservice.get(cookbook_url)
781+ HTTP/1.1 404 Not Found
782+ ...
783+
784+ >>> new_cookbook_url = quote("/cookbooks/The Joy of Cooking Extreme")
785+ >>> print webservice.get(new_cookbook_url)
786+ HTTP/1.1 200 Ok
787+ ...
788+
789+Cleanup.
790+
791+ >>> representation = simplejson.dumps("The Joy of Cooking")
792+ >>> new_name_url = new_cookbook_url + "/name"
793+ >>> print webservice.put(new_name_url, 'application/json',
794+ ... representation)
795+ HTTP/1.1 301 Moved Permanently
796+ ...
797+ Location: http://.../cookbooks/The%20Joy%20of%20Cooking/name
798+ <BLANKLINE>
799
800
801 = Field resources can give more detail than entry resources =
802@@ -80,24 +142,21 @@
803
804 = Supported methods =
805
806-Field resources are read-only.
807+Field resources support GET, PUT, and PATCH.
808
809- >>> for method in ['HEAD', 'POST', 'PUT', 'DELETE', 'OPTIONS']:
810+ >>> for method in ['HEAD', 'POST', 'DELETE', 'OPTIONS']:
811 ... print webservice(field_url, method)
812- HTTP/1.1 405 Method Not Allowed
813- Allow: GET
814- ...
815- HTTP/1.1 405 Method Not Allowed
816- Allow: GET
817- ...
818- HTTP/1.1 405 Method Not Allowed
819- Allow: GET
820- ...
821- HTTP/1.1 405 Method Not Allowed
822- Allow: GET
823- ...
824- HTTP/1.1 405 Method Not Allowed
825- Allow: GET
826+ HTTP/1.1 405 Method Not Allowed...
827+ Allow: GET PUT PATCH
828+ ...
829+ HTTP/1.1 405 Method Not Allowed...
830+ Allow: GET PUT PATCH
831+ ...
832+ HTTP/1.1 405 Method Not Allowed...
833+ Allow: GET PUT PATCH
834+ ...
835+ HTTP/1.1 405 Method Not Allowed...
836+ Allow: GET PUT PATCH
837 ...
838
839
840@@ -109,6 +168,8 @@
841 >>> response = webservice.get(cookbook_url)
842 >>> cookbook_etag = response._response.getHeader('ETag')
843
844+<bound method BrowserResponse.getHeader of <zope.publisher.browser.BrowserResponse object at 0xa441c6c>>
845+
846 >>> response = webservice.get(field_url)
847 >>> etag = response._response.getHeader('ETag')
848
849@@ -119,13 +180,42 @@
850 HTTP/1.1 304 Not Modified
851 ...
852
853- >>> set_description("new description")
854+ >>> ignored = set_description("new description")
855 >>> print webservice.get(field_url,
856 ... headers={'If-None-Match': etag})
857 HTTP/1.1 200 Ok
858 ...
859
860- >>> set_description("Description")
861+=================
862+Conditional write
863+=================
864+
865+Every field supports conditional PUT and PATCH, just like the entries
866+do.
867+
868+ >>> response = webservice.get(field_url)
869+ >>> cookbook_etag = response._response.getHeader('ETag')
870+
871+The first attempt to modify the field succeeds, because the ETag
872+provided in If-Match is the one we just got from a GET request.
873+
874+ >>> representation = simplejson.dumps("New description")
875+ >>> print webservice.put(field_url, 'application/json',
876+ ... representation,
877+ ... headers={'If-Match': cookbook_etag})
878+ HTTP/1.1 209 Content Returned
879+ ...
880+
881+But when the field is modified, the ETag changes. Any subsequent
882+requests that use that ETag in If-Match will fail.
883+
884+ >>> print webservice.put(field_url, 'application/json',
885+ ... representation,
886+ ... headers={'If-Match': cookbook_etag})
887+ HTTP/1.1 412 Precondition Failed
888+ ...
889+
890+ >>> ignored = set_description("Description")
891
892
893 = Custom XHTML representations =
894@@ -209,14 +299,14 @@
895 Compare the HTML generated by the custom renderer, to the XHTML
896 generated now that the default adapter is back in place.
897
898- >>> set_description("<b>Bold description</b>")
899+ >>> ignored = set_description("<b>Bold description</b>")
900
901 >>> print webservice.get(field_url, 'application/xhtml+xml')
902 HTTP/1.1 200 Ok
903 ...
904 &lt;b&gt;Bold description&lt;/b&gt;
905
906- >>> set_description("Description")
907+ >>> ignored = set_description("Description")
908
909 The default renderer escapes HTML tags because it thinks they might
910 contain XSS attacks. If you define a custom adapter, you can generate

Subscribers

People subscribed via source and target branches