Merge lp:~leonardr/lazr.restful/launchpad-field-put into lp:lazr.restful
- launchpad-field-put
- Merge into trunk
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 |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Francis J. Lacoste (community) | Approve | ||
Review via email: mp+6979@code.launchpad.net |
Commit message
Description of the change
To post a comment you must log in.
Revision history for this message
Leonard Richardson (leonardr) wrote : | # |
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 | <b>Bold description</b> |
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 | <b>Bold description</b> |
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 |
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?