~launchpad-pqm/launchpad/devel

« back to all changes in this revision

Viewing changes to lib/canonical/lazr/rest/resource.py

MergedĀ mainline.

Show diffs side-by-side

added added

removed removed

Lines of Context:
17
17
    ]
18
18
 
19
19
from datetime import datetime
20
 
import pytz
21
20
import simplejson
22
21
 
23
 
from zope.app.datetimeutils import (
24
 
    DateError, DateTimeError, DateTimeParser, SyntaxError)
25
22
from zope.app.pagetemplate.engine import TrustedAppPT
26
23
from zope.component import adapts, getAdapters, getMultiAdapter
27
24
from zope.component.interfaces import ComponentLookupError
30
27
from zope.proxy import isProxy
31
28
from zope.publisher.interfaces import NotFound
32
29
from zope.schema import ValidationError, getFields
33
 
from zope.schema.interfaces import IDatetime, IObject
 
30
from zope.schema.interfaces import ConstraintNotSatisfied, IChoice, IObject
34
31
from zope.security.proxy import removeSecurityProxy
35
 
 
36
32
from canonical.lazr.enum import BaseItem
37
33
 
38
34
# XXX leonardr 2008-01-25 bug=185958:
42
38
from canonical.launchpad.webapp.interfaces import ICanonicalUrlData
43
39
from canonical.lazr.interfaces import (
44
40
    ICollection, ICollectionField, ICollectionResource, IEntry,
45
 
    IEntryResource, IHTTPResource, IJSONPublishable, IResourceGETOperation,
46
 
    IResourcePOSTOperation, IScopedCollection, IServiceRootResource)
 
41
    IEntryResource, IFieldDeserializer, IHTTPResource, IJSONPublishable,
 
42
    IResourceGETOperation, IResourcePOSTOperation, IScopedCollection,
 
43
    IServiceRootResource)
 
44
from canonical.launchpad.webapp.vocabulary import SQLObjectVocabularyBase
47
45
from canonical.lazr.rest.schema import URLDereferencingMixin
48
46
 
49
47
 
68
66
            # We have a security-proxied version of a built-in
69
67
            # type. We create a new version of the type by copying the
70
68
            # proxied version's content. That way the container is not
71
 
            # security proxied (and simplejson will now what do do
 
69
            # security proxied (and simplejson will know what do do
72
70
            # with it), but the content will still be security
73
71
            # wrapped.
74
72
            underlying_object = removeSecurityProxy(obj)
120
118
                               IResourcePOSTOperation)
121
119
        return len(adapters) > 0
122
120
 
 
121
    def toWADL(self, template_name):
 
122
        """Represent this resource as a WADL application.
 
123
 
 
124
        The WADL document describes the capabilities of this resource.
 
125
        """
 
126
        template = LazrPageTemplateFile('../templates/' + template_name)
 
127
        namespace = template.pt_getContext()
 
128
        namespace['context'] = self
 
129
        return template.pt_render(namespace)
 
130
 
 
131
    def getPreferredSupportedContentType(self):
 
132
        """Of the content types we serve, which would the client prefer?
 
133
 
 
134
        The web service supports WADL and JSON representations. The
 
135
        default is JSON. This method determines whether the client
 
136
        would rather have WADL or JSON.
 
137
        """
 
138
        content_types = self.getPreferredContentTypes()
 
139
        try:
 
140
            wadl_pos = content_types.index(self.WADL_TYPE)
 
141
        except ValueError:
 
142
            wadl_pos = float("infinity")
 
143
        try:
 
144
            json_pos = content_types.index(self.JSON_TYPE)
 
145
        except ValueError:
 
146
            json_pos = float("infinity")
 
147
        if wadl_pos < json_pos:
 
148
            return self.WADL_TYPE
 
149
        return self.JSON_TYPE
 
150
 
123
151
    def getPreferredContentTypes(self):
124
152
        """Find which content types the client prefers to receive."""
125
153
        return self._parseAcceptStyleHeader(self.request.get('HTTP_ACCEPT'))
126
154
 
 
155
 
 
156
    def _fieldValueIsObject(self, field):
 
157
        """Does the given field expect a data model object as its value?
 
158
 
 
159
        Obviously an IObject field is expected to have a data model
 
160
        object as its value. But an IChoice field might also have a
 
161
        vocabulary drawn from the set of data model objects.
 
162
        """
 
163
        if IObject.providedBy(field):
 
164
            return True
 
165
        if IChoice.providedBy(field):
 
166
            # Find out whether the field's vocabulary is made of
 
167
            # database objects (which correspond to resources that
 
168
            # need to be linked to) or regular objects (which can
 
169
            # be serialized to JSON).
 
170
            field = field.bind(self.context)
 
171
            return isinstance(field.vocabulary, SQLObjectVocabularyBase)
 
172
        return False
 
173
 
127
174
    def _parseAcceptStyleHeader(self, value):
128
175
        """Parse an HTTP header from the Accept-* family.
129
176
 
351
398
        data['self_link'] = canonical_url(self.context)
352
399
        for name, field in getFields(self.entry.schema).items():
353
400
            value = getattr(self.entry, name)
 
401
 
354
402
            if ICollectionField.providedBy(field):
355
403
                # The field is a collection; include a link to the
356
404
                # collection resource.
357
405
                if value is not None:
358
406
                    key = name + '_collection_link'
359
407
                    data[key] = "%s/%s" % (data['self_link'], name)
360
 
            elif IObject.providedBy(field):
 
408
            elif self._fieldValueIsObject(field):
361
409
                # The field is an entry; include a link to the
362
410
                # entry resource.
363
411
                if value is not None:
411
459
            # No custom operation was specified. Implement a standard
412
460
            # GET, which serves a JSON or WADL representation of the
413
461
            # entry.
414
 
            content_types = self.getPreferredContentTypes()
415
 
            try:
416
 
                wadl_pos = content_types.index(self.WADL_TYPE)
417
 
            except ValueError:
418
 
                wadl_pos = float("infinity")
419
 
            try:
420
 
                json_pos = content_types.index(self.JSON_TYPE)
421
 
            except ValueError:
422
 
                json_pos = float("infinity")
423
 
 
424
 
            # If the client's desire for WADL outranks its desire for
425
 
            # JSON, serve WADL.  Otherwise, serve JSON.
426
 
            if wadl_pos < json_pos:
 
462
            if self.getPreferredSupportedContentType() == self.WADL_TYPE:
427
463
                result = self.toWADL().encode("utf-8")
428
464
                self.request.response.setHeader(
429
465
                    'Content-Type', self.WADL_TYPE)
456
492
                # read-only), or is marked read-only. It's okay for
457
493
                # the client to omit a value for this attribute.
458
494
                continue
459
 
            if IObject.providedBy(field):
 
495
            if self._fieldValueIsObject(field):
460
496
                repr_name = name + '_link'
461
497
            else:
462
498
                repr_name = name
483
519
 
484
520
        The WADL document describes the capabilities of this resource.
485
521
        """
486
 
        template = LazrPageTemplateFile('../templates/wadl-entry.pt')
487
 
        namespace = template.pt_getContext()
488
 
        namespace['context'] = self
489
 
        return template.pt_render(namespace)
 
522
        return super(EntryResource, self).toWADL('wadl-entry.pt')
490
523
 
491
524
    def _applyChanges(self, changeset):
492
525
        """Apply a dictionary of key-value pairs as changes to an entry.
495
528
        representation.
496
529
        """
497
530
        validated_changeset = {}
 
531
        errors = []
498
532
        for repr_name, value in changeset.items():
499
533
            if repr_name == 'self_link':
500
534
                # The self link isn't part of the schema, so it's
501
535
                # handled separately.
502
 
                if value == canonical_url(self.context):
503
 
                    continue
504
 
                else:
505
 
                    self.request.response.setStatus(400)
506
 
                    return ("You tried to modify the read-only attribute "
507
 
                            "'self_link'.")
 
536
                if value != canonical_url(self.context):
 
537
                    errors.append("self_link: You tried to modify "
 
538
                                  "a read-only attribute.")
 
539
                continue
508
540
 
509
541
            change_this_field = True
510
542
 
532
564
                # (Of course, you also can't change
533
565
                # 'foo_collection_link', but that's taken care of
534
566
                # below.)
535
 
                self.request.response.setStatus(400)
536
 
                return ("You tried to modify the nonexistent attribute '%s'"
537
 
                        % repr_name)
 
567
                errors.append("%s: You tried to modify a nonexistent "
 
568
                              "attribute." % repr_name)
 
569
                continue
538
570
 
539
571
            # Around this point the specific value provided by the client
540
 
            # becomes relevant, so we pre-process it if necessary.
 
572
            # becomes relevant, so we deserialize it.
 
573
            element = element.bind(self.context)
 
574
            deserializer = getMultiAdapter((element, self.request),
 
575
                                           IFieldDeserializer)
 
576
            try:
 
577
                value = deserializer.deserialize(value)
 
578
            except (ValueError, ValidationError), e:
 
579
                errors.append("%s: %s" % (repr_name, e))
 
580
                continue
 
581
 
541
582
            if (IObject.providedBy(element)
542
583
                and not ICollectionField.providedBy(element)):
543
 
                # 'value' is the URL to an object. Dereference the URL
544
 
                # to find the actual object.
545
 
                try:
546
 
                    value = self.dereference_url(value)
547
 
                except NotFound:
548
 
                    self.request.response.setStatus(400)
549
 
                    return ("Your value for the attribute '%s' wasn't "
550
 
                            "the URL to any object published by this web "
551
 
                            "service." % repr_name)
552
 
                underlying_object = removeSecurityProxy(value)
553
 
                value = underlying_object.context
554
 
                # The URL points to an object, but is it an object of the
555
 
                # right type?
 
584
                # TODO leonardr 2008-15-04
 
585
                # blueprint=api-wadl-description: This should be moved
 
586
                # into the ObjectLookupFieldDeserializer, once we make
 
587
                # it possible for Vocabulary fields to specify a
 
588
                # schema class the way IObject fields can.
556
589
                if not element.schema.providedBy(value):
557
 
                    self.request.response.setStatus(400)
558
 
                    return ("Your value for the attribute '%s' doesn't "
559
 
                            "point to the right kind of object." % repr_name)
560
 
            elif IDatetime.providedBy(element):
561
 
                try:
562
 
                    value = DateTimeParser().parse(value)
563
 
                    (year, month, day, hours, minutes, secondsAndMicroseconds,
564
 
                     timezone) = value
565
 
                    seconds = int(secondsAndMicroseconds)
566
 
                    microseconds = int(
567
 
                        round((secondsAndMicroseconds - seconds) * 1000000))
568
 
                    if timezone not in ['Z', '+0000', '-0000']:
569
 
                        self.request.response.setStatus(400)
570
 
                        return ("You set the attribute '%s' to a time "
571
 
                                "that's not UTC."
572
 
                                % repr_name)
573
 
                    value = datetime(year, month, day, hours, minutes,
574
 
                                     seconds, microseconds, pytz.utc)
575
 
                except (DateError, DateTimeError, SyntaxError):
576
 
                    self.request.response.setStatus(400)
577
 
                    return ("You set the attribute '%s' to a value "
578
 
                            "that doesn't look like a date." % repr_name)
 
590
                    errors.append("%s: Your value points to the "
 
591
                                  "wrong kind of object" % repr_name)
 
592
                    continue
579
593
 
580
594
            # The current value of the attribute also becomes
581
595
            # relevant, so we obtain that. If the attribute designates
597
611
            if ICollectionField.providedBy(element):
598
612
                change_this_field = False
599
613
                if value != current_value:
600
 
                    self.request.response.setStatus(400)
601
 
                    return ("You tried to modify the collection link '%s'"
602
 
                            % repr_name)
 
614
                    errors.append("%s: You tried to modify a collection "
 
615
                                  "attribute." % repr_name)
 
616
                    continue
603
617
 
604
618
            if element.readonly:
605
619
                change_this_field = False
606
620
                if value != current_value:
607
 
                    self.request.response.setStatus(400)
608
 
                    return ("You tried to modify the read-only attribute '%s'"
609
 
                            % repr_name)
 
621
                    errors.append("%s: You tried to modify a read-only "
 
622
                                  "attribute." % repr_name)
 
623
                    continue
610
624
 
611
625
            if change_this_field is True and value != current_value:
612
626
                if not IObject.providedBy(element):
613
627
                    try:
614
628
                        # Do any field-specific validation.
615
 
                        field = element.bind(self.context)
616
 
                        field.validate(value)
617
 
                    except ValidationError, e:
618
 
                        self.request.response.setStatus(400)
 
629
                        element.validate(value)
 
630
                    except ConstraintNotSatisfied, e:
 
631
                        # Try to get a string error message out of
 
632
                        # the exception; otherwise use a generic message
 
633
                        # instead of whatever object the raise site
 
634
                        # thought would be a good idea.
 
635
                        if (len(e.args) > 0 and
 
636
                            isinstance(e.args[0], basestring)):
 
637
                            error = e.args[0]
 
638
                        else:
 
639
                            error = "Constraint not satisfied."
 
640
                        errors.append("%s: %s" % (repr_name, error))
 
641
                        continue
 
642
                    except (ValueError, ValidationError), e:
619
643
                        error = str(e)
620
644
                        if error == "":
621
645
                            error = "Validation error"
622
 
                        return error
 
646
                        errors.append("%s: %s" % (repr_name, error))
 
647
                        continue
623
648
                validated_changeset[name] = value
624
649
 
 
650
        # If there were errors, display them and send a status of 400.
 
651
        if len(errors) > 0:
 
652
            self.request.response.setStatus(400)
 
653
            self.request.response.setHeader('Content-type', 'text/plain')
 
654
            return "\n".join(errors)
 
655
 
625
656
        # Store the entry's current URL so we can see if it changes.
626
657
        original_url = canonical_url(self.context)
627
658
        # Make the changes.
657
688
                # just needs this string served to the client.
658
689
                return result
659
690
        else:
660
 
            # No custom operation was specified. Implement a standard GET,
661
 
            # which retrieves the items in the collection.
 
691
            # No custom operation was specified. Implement a standard
 
692
            # GET, which serves a JSON or WADL representation of the
 
693
            # collection.
662
694
            entries = self.collection.find()
663
695
            if entries is None:
664
696
                raise NotFound(self, self.collection_name)
 
697
 
 
698
            if self.getPreferredSupportedContentType() == self.WADL_TYPE:
 
699
                result = self.toWADL().encode("utf-8")
 
700
                self.request.response.setHeader(
 
701
                    'Content-Type', self.WADL_TYPE)
 
702
                return result
665
703
            result = self.batch(entries, self.request)
666
704
 
667
705
        self.request.response.setHeader('Content-type', self.JSON_TYPE)
668
706
        return simplejson.dumps(result, cls=ResourceJSONEncoder)
669
707
 
 
708
    def toWADL(self):
 
709
        """Represent this resource as a WADL application.
 
710
 
 
711
        The WADL document describes the capabilities of this resource.
 
712
        """
 
713
        return super(CollectionResource, self).toWADL('wadl-collection.pt')
 
714
 
670
715
 
671
716
class ServiceRootResource:
672
717
    """A resource that responds to GET by describing the service."""
721
766
    def find(self):
722
767
        """See `ICollection`."""
723
768
        return self.collection
724