lazr.restful-0.19.3/0000755000175000017500000000000011636155340014443 5ustar benjibenji00000000000000lazr.restful-0.19.3/src/0000755000175000017500000000000011636155340015232 5ustar benjibenji00000000000000lazr.restful-0.19.3/src/lazr/0000755000175000017500000000000011636155340016202 5ustar benjibenji00000000000000lazr.restful-0.19.3/src/lazr/__init__.py0000644000175000017500000000160711631755356020327 0ustar benjibenji00000000000000# Copyright 2009 Canonical Ltd. All rights reserved. # # This file is part of lazr.restful. # # lazr.restful is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by # the Free Software Foundation, version 3 of the License. # # lazr.restful is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or # FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public # License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with lazr.restful. If not, see . # this is a namespace package try: import pkg_resources pkg_resources.declare_namespace(__name__) except ImportError: import pkgutil __path__ = pkgutil.extend_path(__path__, __name__) lazr.restful-0.19.3/src/lazr/restful/0000755000175000017500000000000011636155340017666 5ustar benjibenji00000000000000lazr.restful-0.19.3/src/lazr/restful/simple.py0000644000175000017500000004271411631755356021551 0ustar benjibenji00000000000000"""Simple implementations of various Zope and lazr.restful interfaces.""" __metaclass__ = type __all__ = [ 'BaseRepresentationCache', 'BaseWebServiceConfiguration', 'DictionaryBasedRepresentationCache', 'IMultiplePathPartLocation', 'MultiplePathPartAbsoluteURL', 'Publication', 'PublicationMixin', 'Request', 'RootResource', 'RootResourceAbsoluteURL', 'SimulatedWebsiteRequest', 'TraverseWithGet', ] import traceback import urllib from zope.component import ( adapts, getMultiAdapter, getUtility, queryMultiAdapter) from zope.interface import Attribute, Interface, implements from zope.publisher.browser import BrowserRequest from zope.publisher.interfaces import IPublication, IPublishTraverse, NotFound from zope.publisher.publish import mapply from zope.proxy import sameProxiedObjects from zope.security.management import endInteraction, newInteraction from zope.traversing.browser import ( absoluteURL, AbsoluteURL as ZopeAbsoluteURL) from zope.traversing.browser.interfaces import IAbsoluteURL from zope.traversing.browser.absoluteurl import _insufficientContext, _safe import grokcore.component from lazr.restful import ( EntryAdapterUtility, HTTPResource, ServiceRootResource) from lazr.restful.interfaces import ( IRepresentationCache, IServiceRootResource, ITopLevelEntryLink, ITraverseWithGet, IWebBrowserOriginatingRequest, IWebServiceConfiguration, IWebServiceLayer, ) from lazr.restful.publisher import ( browser_request_to_web_service_request, WebServicePublicationMixin, WebServiceRequestTraversal) from lazr.restful.utils import ( get_current_browser_request, implement_from_dict, tag_request_with_version_name) class PublicationMixin(object): """A very simple implementation of `IPublication`. The object passed to the constructor is returned by getApplication(). """ implements(IPublication) def __init__(self, application): """Create the test publication. The object at which traversal should start is passed as parameter. """ self.application = application def beforeTraversal(self, request): """Sets the request as the current interaction. (It also ends any previous interaction, that's convenient when tests don't go through the whole request.) """ endInteraction() newInteraction(request) def getApplication(self, request): """Returns the application passed to the constructor.""" return self.application def callTraversalHooks(self, request, ob): """Does nothing.""" def traverseName(self, request, ob, name): """Traverse by looking for an `IPublishTraverse` adapter.""" # XXX flacoste 2009/03/06 bug=338831. This is copied from # zope.app.publication.publicationtraverse.PublicationTraverse. # This should really live in zope.publisher, we are copying because # we don't want to depend on zope.app stuff. # Namespace support was dropped. if name == '.': return ob if IPublishTraverse.providedBy(ob): ob2 = ob.publishTraverse(request, name) else: # self is marker. adapter = queryMultiAdapter( (ob, request), IPublishTraverse, default=self) if adapter is not self: ob2 = adapter.publishTraverse(request, name) else: raise NotFound(ob, name, request) return self.wrapTraversedObject(ob2) def wrapTraversedObject(self, ob): """Wrap the traversed object, for instance in a security proxy. By default, does nothing.""" return ob def afterTraversal(self, request, ob): """Does nothing.""" def callObject(self, request, ob): """Call the object, returning the result.""" return mapply(ob, request.getPositionalArguments(), request) def afterCall(self, request, ob): """Does nothing.""" def handleException(self, object, request, exc_info, retry_allowed=1): """Prints the exception.""" # Reproduce the behavior of ZopePublication by looking up a view # for this exception. exception = exc_info[1] view = queryMultiAdapter((exception, request), name='index.html') if view is not None: request.response.reset() request.response.setResult(view()) else: traceback.print_exception(*exc_info) def endRequest(self, request, ob): """Ends the interaction.""" endInteraction() class Publication(WebServicePublicationMixin, PublicationMixin): """A simple publication. Combines the IPublication implementation of PublicationMixin with the web service implementation of WebServicePublicationMixin, """ pass class TraverseWithGet(object): """An implementation of `IPublishTraverse` that uses the get() method. This is a simple traversal technique that works with any object that defines a lookup method called get(). This class should not be confused with WebServiceRequestTraversal. This class (or any other class that implements IPublishTraverse) controls traversal in the web application towards an object that implements IEntry. Once an IEntry has been found, further traversal (eg. to scoped collections or fields) always happens with WebServiceRequestTraversal. """ implements(ITraverseWithGet) def publishTraverse(self, request, name): """See `IPublishTraverse`.""" name = urllib.unquote(name) value = self.get(request, name) if value is None: raise NotFound(self, name) return value def get(self, request, name): """See `ITraverseWithGet`.""" raise NotImplementedError class Request(WebServiceRequestTraversal, BrowserRequest): """A web service request with no special features.""" implements(IWebServiceLayer) class SimulatedWebsiteRequest(BrowserRequest): """A (simulated) request to a website as opposed to a web service. If you can adapt a web service request to this class (or some other class that implements IWebBrowserOriginatingRequest), you can take advantage of the web_url feature. """ implements(IWebBrowserOriginatingRequest) class RootResource(ServiceRootResource, TraverseWithGet): """A service root that expects top-level objects to be defined in code. ServiceRootResource expects top-level objects to be registered as Zope interfaces. """ grokcore.component.provides(IServiceRootResource) @property def top_level_names(self): return self.top_level_objects.keys() def get(self, request, name): """Traverse to a top-level object.""" return self.top_level_objects.get(name) def getTopLevelPublications(self): """Return a mapping of top-level link names to published objects.""" top_level_resources = {} # First collect the top-level collections. for name, (schema_interface, obj) in ( self.top_level_collections.items()): adapter = EntryAdapterUtility.forSchemaInterface( schema_interface, self.request) link_name = ("%s_collection_link" % adapter.plural_type) top_level_resources[link_name] = obj # Then collect the top-level entries. for name, entry_link in self.top_level_entry_links.items(): link_name = ("%s_link" % ITopLevelEntryLink(entry_link).link_name) top_level_resources[link_name] = entry_link return top_level_resources @property def top_level_objects(self): """Return this web service's top-level objects.""" objects = {} for name, (schema_interface, obj) in ( self.top_level_collections.items()): objects[name] = obj objects.update(self.top_level_entry_links) return objects @property def top_level_collections(self): """Return this web service's top-level collections. :return: A hash mapping a name to a 2-tuple (interface, collection). The interface is the kind of thing contained in the collection. """ if not hasattr(self, '_top_level_collections'): self._top_level_collections, self._top_level_entry_links = ( self._build_top_level_objects()) return self._top_level_collections @property def top_level_entry_links(self): """Return this web service's top-level entry links.""" if not hasattr(self, '_top_level_entry_links'): self._top_level_collections, self._top_level_entry_links = ( self._build_top_level_objects()) return self._top_level_entry_links def _build_top_level_objects(self): """Create the list of top-level objects. :return: A 2-tuple of hashes (collections, entry_links). The 'collections' hash maps a name to a 2-tuple (interface, object). The interface is the kind of thing contained in the collection. For instance, 'users': (IUser, UserSet()) The 'entry_links' hash maps a name to an object. """ return ({}, {}) class RootResourceAbsoluteURL: """A basic implementation of `IAbsoluteURL` for a root resource.""" implements(IAbsoluteURL) adapts(ServiceRootResource, WebServiceRequestTraversal) def __init__(self, context, request): """Initialize with respect to a context and request.""" config = getUtility(IWebServiceConfiguration) self.version = request.annotations[request.VERSION_ANNOTATION] if config.use_https: self.schema = 'https' else: self.schema = 'http' self.hostname = config.hostname self.port = config.port self.prefix = config.service_root_uri_prefix def __str__(self): """Return the part of the URL that contains the hostname. This is called when constructing the URL for some resource. This string should not end with a slash; Zope's AbsoluteURL will provide the slash when joining this string with the __name__ of the subordinate resource. """ use_default_port = ( self.port is None or self.port == 0 or (self.schema == 'https' and self.port == 443) or (self.schema == 'http' and self.port == 80)) if use_default_port: return "%s://%s/%s%s" % ( self.schema, self.hostname, self.prefix, self.version) else: return "%s://%s:%s/%s%s" % ( self.schema, self.hostname, self.port, self.prefix, self.version) def __call__(self): """Return the URL to the service root resource. This value is called when finding the URL to the service root resource, and it needs to end with a slash so that clients will be able to do relative URL resolution correctly with respect to the service root. """ return str(self) + '/' class IMultiplePathPartLocation(Interface): """An ILocation-like interface for objects with multiple path parts. Zope's AbsoluteURL class assumes that each object in an object tree contributes one path part to the URL. If an object's __name__ is 'foo' then its URL will be its __parent__'s url plus "/foo". But some objects have multiple path parts. Setting an object's __name__ to 'foo/bar' will result in a URL path like"/foo%2Fbar" -- not what you want. So, implement this interface instead of ILocation. """ __parent__ = Attribute("This object's parent.") __path_parts__ = Attribute( 'The path parts of this object\'s URL. For instance, ["foo", "bar"]' 'corresponds to the URL path "foo/bar"') class MultiplePathPartAbsoluteURL: """Generate a URL for an IMultiplePathPartLocation. Unlike Zope's AbsoluteURL, this class understands when an object contributes multiple path parts to the URL. If you use basic-site.zcml, this class is automatically registered as the IAbsoluteURL adapter for IMultiplePathPartLocation objects and web service requests. """ implements(IAbsoluteURL) adapts(IMultiplePathPartLocation, WebServiceRequestTraversal) def __init__(self, context, request): """Initialize with respect to a context and request.""" self.context = context self.request = request def __str__(self): parent = getattr(self.context, '__parent__', None) if parent is None: raise TypeError(_insufficientContext) start_url = str( getMultiAdapter((parent, self.request), IAbsoluteURL)) parts = getattr(self.context, '__path_parts__', None) if parts is None: raise TypeError(_insufficientContext) if not hasattr(parts, '__iter__'): raise TypeError("Expected an iterable for __path_parts__.") escaped_parts = [urllib.quote(part.encode('utf-8'), _safe) for part in parts] return start_url + "/" + "/".join(escaped_parts) __call__ = __str__ class BaseRepresentationCache(object): """A useful base class for representation caches. When an object is invalidated, all of its representations must be removed from the cache. This means representations of every media type for every version of the web service. Subclass this class and you won't have to worry about removing everything. You can focus on implementing key_for() and delete_by_key(), which takes the return value of key_for() instead of a raw object. You can also implement set_by_key() and get_by_key(), which also take the return value of key_for(), instead of set() and get(). """ implements(IRepresentationCache) DO_NOT_CACHE = object() def get(self, obj, media_type, version, default=None): """See `IRepresentationCache`.""" key = self.key_for(obj, media_type, version) if key is self.DO_NOT_CACHE: return default return self.get_by_key(key, default) def set(self, obj, media_type, version, representation): """See `IRepresentationCache`.""" key = self.key_for(obj, media_type, version) if key is self.DO_NOT_CACHE: return return self.set_by_key(key, representation) def delete(self, object): """See `IRepresentationCache`.""" config = getUtility(IWebServiceConfiguration) for version in config.active_versions: key = self.key_for(object, HTTPResource.JSON_TYPE, version) if key is not self.DO_NOT_CACHE: self.delete_by_key(key) def key_for(self, object, media_type, version): """Generate a unique key for an object/media type/version. :param object: An IEntry--the object whose representation you want. :param media_type: The media type of the representation to get. :param version: The version of the web service for which to fetch a representation. """ raise NotImplementedError() def get_by_key(self, key, default=None): """Delete a representation from the cache, given a key. :key: The cache key. """ raise NotImplementedError() def set_by_key(self, key): """Delete a representation from the cache, given a key. :key: The cache key. """ raise NotImplementedError() def delete_by_key(self, key): """Delete a representation from the cache, given a key. :key: The cache key. """ raise NotImplementedError() class DictionaryBasedRepresentationCache(BaseRepresentationCache): """A representation cache that uses an in-memory dict. This cache transforms IRepresentationCache operations into operations on a dictionary. Don't use a Python dict object in a production installation! It can easily grow to take up all available memory. If you implement a dict-like object that maintains a maximum size with an LRU algorithm or something similar, you can use that. But this class was written for testing. """ def __init__(self, use_dict): """Constructor. :param use_dict: A dictionary to keep representations in. As noted in the class docstring, in a production installation it's a very bad idea to use a standard Python dict object. """ self.dict = use_dict def key_for(self, obj, media_type, version): """See `BaseRepresentationCache`.""" # Create a fake web service request for the appropriate version. config = getUtility(IWebServiceConfiguration) web_service_request = config.createRequest("", {}) web_service_request.setVirtualHostRoot( names=[config.path_override, version]) tag_request_with_version_name(web_service_request, version) # Use that request to create a versioned URL for the object. value = absoluteURL(obj, web_service_request) + ',' + media_type return value def get_by_key(self, key, default=None): """See `IRepresentationCache`.""" return self.dict.get(key, default) def set_by_key(self, key, representation): """See `IRepresentationCache`.""" self.dict[key] = representation def delete_by_key(self, key): """Implementation of a `BaseRepresentationCache` method.""" self.dict.pop(key, None) BaseWebServiceConfiguration = implement_from_dict( "BaseWebServiceConfiguration", IWebServiceConfiguration, {'first_version_with_total_size_link': None}, object) lazr.restful-0.19.3/src/lazr/restful/directives/0000755000175000017500000000000011636155340022027 5ustar benjibenji00000000000000lazr.restful-0.19.3/src/lazr/restful/directives/__init__.py0000644000175000017500000001300111631755356024143 0ustar benjibenji00000000000000# Copyright 2009 Canonical Ltd. All rights reserved. """Martian directives used in lazr.restful.""" __metaclass__ = type __all__ = ['request_class', 'publication_class', 'settings',] import martian from zope.component import getSiteManager, getUtility from zope.location.interfaces import ILocation from zope.interface import alsoProvides from zope.interface.interface import InterfaceClass from zope.traversing.browser import AbsoluteURL from zope.traversing.browser.interfaces import IAbsoluteURL from lazr.restful.interfaces import ( IServiceRootResource, IWebServiceClientRequest, IWebServiceConfiguration, IWebServiceLayer, IWebServiceVersion) from lazr.restful._resource import ( register_versioned_request_utility, ServiceRootResource) from lazr.restful.simple import ( BaseWebServiceConfiguration, Publication, Request, RootResourceAbsoluteURL) from lazr.restful.utils import make_identifier_safe class request_class(martian.Directive): """Directive used to specify a service's request class. The default is lazr.restful.simple.Request. """ scope = martian.CLASS store = martian.ONCE default = Request class publication_class(martian.Directive): """Directive used to specify a service's publication class. The default is lazr.restful.simple.Publication. """ scope = martian.CLASS store = martian.ONCE default = Publication class ConfigurationGrokker(martian.ClassGrokker): """Subclass BaseWebServiceConfiguration for an easy configuration class. Subclass BaseWebServiceConfiguration and override any of the configuration settings specific to your web service. This grokker creates default implementations of createRequest() and get_request_user, if those methods are not implemented. The default implementations use the request and publication classes you specify with the directives lazr.restful.directives.request_class() and lazr.restful.directives.publication_class(). This grokker then registers an instance of your subclass as the singleton configuration object. This grokker also creates marker interfaces for every web service version defined in the configuration, and registers each as an IWebServiceVersion utility. """ martian.component(BaseWebServiceConfiguration) martian.directive(request_class) martian.directive(publication_class) def execute(self, cls, config, request_class, publication_class, *kw): # If implementations of the IWebServiceConfiguration methods are # missing, create them from the declarations. if not getattr(cls, 'createRequest', None): def createRequest(self, body_instream, environ): """See `IWebServiceConfiguration`.""" request = request_class(body_instream, environ) # Once we traverse the URL a bit and find the # requested web service version, we'll be able to set # the application to the appropriate service root # resource. This happens in # WebServiceRequestTraversal._removeVirtualHostTraversals(). request.setPublication(publication_class(None)) return request cls.createRequest = createRequest if not getattr(cls, 'get_request_user', None): def get_request_user(self): return None cls.get_request_user = get_request_user # Register as utility. utility = cls() sm = getSiteManager() sm.registerUtility(utility, IWebServiceConfiguration) # Create and register marker interfaces for request objects. superclass = IWebServiceClientRequest for version in (utility.active_versions): name_part = make_identifier_safe(version) if not name_part.startswith('_'): name_part = '_' + name_part classname = "IWebServiceClientRequestVersion" + name_part marker_interface = InterfaceClass(classname, (superclass,), {}) register_versioned_request_utility(marker_interface, version) superclass = marker_interface return True class ServiceRootGrokker(martian.ClassGrokker): """Registers your service root as a singleton object.""" martian.component(ServiceRootResource) def execute(self, cls, config, *kw): getSiteManager().registerUtility(cls(), IServiceRootResource) return True class RootResourceAbsoluteURLGrokker(martian.ClassGrokker): """Registers a strategy for generating your service's base URL. In most cases you can simply create an empty subclass of RootResourceAbsoluteURL. """ martian.component(RootResourceAbsoluteURL) def execute(self, cls, config, *kw): getSiteManager().registerAdapter(cls) return True class location_interface(martian.Directive): """Directive to specify the location interface. The default is zope.location.interfaces.ILocation """ scope = martian.CLASS store = martian.ONCE default = ILocation class AbsoluteURLGrokker(martian.ClassGrokker): """Registers a strategy for generating an object's URL. In most cases you can simply create an empty subclass of AbsoluteURL. """ martian.component(AbsoluteURL) martian.directive(location_interface) def execute(self, cls, config, location_interface, *kw): getSiteManager().registerAdapter( cls, (location_interface, IWebServiceLayer), IAbsoluteURL) return True lazr.restful-0.19.3/src/lazr/restful/debug.py0000644000175000017500000000455111631755356021343 0ustar benjibenji00000000000000# Copyright 2008 Canonical Ltd. All rights reserved. """Module docstring goes here.""" __metaclass__ = type __all__ = [ "debug_proxy", "typename" ] from cStringIO import StringIO import zope.proxy from zope.security.checker import getChecker, Checker, CheckerPublic, Proxy def typename(obj): """Return the typename of an object.""" t = type(obj) if t.__module__ == '__builtin__': return t.__name__ else: return "%s.%s" % (t.__module__, t.__name__) def default_proxy_formatter(proxy): """Formatter that simply returns the proxy's type name.""" return typename(proxy) def get_permission_mapping(checker): """Return a list of (permission, list of protected names) for the checker. Permission used to check for attribute setting have (set) appended. """ permission_to_names = {} for name, permission in checker.get_permissions.items(): if permission is CheckerPublic: permission = 'public' permission_to_names.setdefault(permission, []).append(name) for name, permission in checker.set_permissions.items(): if permission is CheckerPublic: permission = 'public' set_permission = "%s (set)" % permission permission_to_names.setdefault(set_permission, []).append(name) return sorted((permission, sorted(names)) for permission, names in permission_to_names.items()) def security_proxy_formatter(proxy): """Return informative text about the checker used by the proxy.""" checker = getChecker(proxy) output = ["%s (using %s)" % (typename(proxy), typename(checker))] if type(checker) is Checker: for permission, names in get_permission_mapping(checker): output.append('%s: %s' % (permission, ", ".join(sorted(names)))) return "\n ".join(output) proxy_formatters = {Proxy: security_proxy_formatter} def debug_proxy(obj): """Return informative text about the proxies wrapping obj. Usually used like print debug_proxy(obj). """ if not zope.proxy.isProxy(obj): return "%r doesn't have any proxies." % obj buf = StringIO() for proxy in zope.proxy.ProxyIterator(obj): if not zope.proxy.isProxy(proxy): break printer = proxy_formatters.get(type(proxy), default_proxy_formatter) print >>buf, printer(proxy) return buf.getvalue() lazr.restful-0.19.3/src/lazr/restful/interfaces/0000755000175000017500000000000011636155340022011 5ustar benjibenji00000000000000lazr.restful-0.19.3/src/lazr/restful/interfaces/_rest.py0000644000175000017500000006164011631755356023516 0ustar benjibenji00000000000000# Copyright 2008-2009 Canonical Ltd. All rights reserved. # # This file is part of lazr.restful # # lazr.restful is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by # the Free Software Foundation, version 3 of the License. # # lazr.restful is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or # FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public # License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with lazr.restful. If not, see . """Interfaces for different kinds of HTTP resources.""" # Pylint doesn't grok zope interfaces. # pylint: disable-msg=E0211,E0213 __metaclass__ = type __all__ = [ 'IByteStorage', 'IByteStorageResource', 'ICollection', 'ICollectionResource', 'IEntry', 'IEntryField', 'IEntryFieldResource', 'IEntryResource', 'IFieldHTMLRenderer', 'IFieldMarshaller', 'IFileLibrarian', 'IHTTPResource', 'INotificationsProvider', 'IJSONPublishable', 'IJSONRequestCache', 'IRepresentationCache', 'IResourceOperation', 'IResourceGETOperation', 'IResourceDELETEOperation', 'IResourcePOSTOperation', 'IScopedCollection', 'IServiceRootResource', 'ITopLevelEntryLink', 'ITraverseWithGet', 'IUnmarshallingDoesntNeedValue', 'IWebServiceConfiguration', 'IWebBrowserInitiatedRequest', 'LAZR_WEBSERVICE_NAME', 'LAZR_WEBSERVICE_NS', 'IWebBrowserOriginatingRequest', 'IWebServiceClientRequest', 'IWebServiceExceptionView', 'IWebServiceLayer', 'IWebServiceVersion', ] from textwrap import dedent from zope.schema import ( Bool, Dict, Int, List, Text, TextLine, ) from zope.interface import ( Attribute, Interface, ) # These two should really be imported from zope.interface, but # the import fascist complains because they are not in __all__ there. from zope.interface.interface import invariant from zope.interface.exceptions import Invalid from zope.publisher.interfaces import IPublishTraverse from zope.publisher.interfaces.browser import ( IBrowserRequest, IDefaultBrowserLayer, ) from lazr.batchnavigator.interfaces import InvalidBatchSizeError # Constants for periods of time HOUR = 3600 # seconds # The namespace prefix for LAZR web service-related tags. LAZR_WEBSERVICE_NS = 'lazr.restful' # The namespace for LAZR web service tags having to do with the names # of things. LAZR_WEBSERVICE_NAME = '%s.name' % LAZR_WEBSERVICE_NS class IHTTPResource(Interface): """An object published through HTTP.""" def __call__(): """Publish the object.""" def getETag(media_type): "An ETag for this resource's current state." class IJSONPublishable(Interface): """An object that can be published as a JSON data structure.""" def toDataForJSON(media_type): """Return a representation that can be turned into JSON. The representation must consist entirely of simple data structures and IJSONPublishable objects. :param media_type: The media type that the data will be converted to. This will be application/json, obviously, but it may include parameters. """ class IServiceRootResource(IHTTPResource): """A service root object that also acts as a resource.""" def getTopLevelPublications(request): """Return a mapping of top-level link names to published objects.""" class IEntryResource(IHTTPResource): """A resource that represents an individual object.""" def do_GET(): """Retrieve this entry. :return: A string representation. """ def do_PATCH(representation): """Update this entry. Try to update the entry to the field and values sent by the client. :param representation: A JSON representation of the field and values that should be modified. :return: None or an error message describing validation errors. The HTTP status code should be set appropriately. """ def getContext(): """Return the underlying entry for this resource.""" class IEntryFieldResource(IHTTPResource): """A resource that represents one of an entry's fields.""" def do_GET(): """Retrieve the value of the field. :return: A string representation. """ class ICollectionResource(IHTTPResource): """A resource that represents a collection of entry resources.""" def do_GET(): """Retrieve this collection. :return: A string representation. """ class IResourceOperation(Interface): """A one-off operation invokable on a resource.""" def __call__(): """Invoke the operation and create the HTTP response. :returns: If the result is a string, it's assumed that the Content-Type was set appropriately, and the result is returned as is. Otherwise, the result is serialized to JSON and served as application/json. """ send_modification_event = Attribute( "Whether or not to send out an event when this operation completes.") class IResourceGETOperation(IResourceOperation): """A one-off operation invoked through GET. This might be a search or lookup operation. """ return_type = Attribute( "The type of the resource returned by this operation, if any.") class IResourceDELETEOperation(IResourceOperation): """A destructor operation invoked through DELETE. This should be an operation that deletes an entry. """ class IResourcePOSTOperation(IResourceOperation): """A one-off operation invoked through POST. This should be an operation that modifies the data set. """ class IEntry(Interface): """An entry, exposed as a resource by an IEntryResource.""" schema = Attribute( 'The schema describing the data fields on this entry.') @invariant def schemaIsProvided(value): """Make sure that the entry also provides its schema.""" if not value.schema.providedBy(value): raise Invalid( "%s doesn't provide its %s schema." % ( type(value).__name__, value.schema.__name__)) class ICollection(Interface): """A collection, driven by an ICollectionResource.""" entry_schema = Attribute("The schema for this collection's entries.") def find(): """Retrieve all entries in the collection under the given scope. :return: A list of IEntry objects. """ class IScopedCollection(ICollection): relationship = Attribute("The relationship between an entry and a " "collection.") collection = Attribute("The collection scoped to an entry.") class IFieldHTMLRenderer(Interface): """An interface that renders generic strings as HTML representations. This can be a callable class, or a function that returns another function. """ def __call__(value): """Render the given string as HTML.""" class IWebServiceExceptionView(Interface): """The view that defines how errors are related to API clients.""" status = Attribute("The HTTP status code to return.") class IEntryField(Interface): """An individual field of an entry.""" entry = Attribute("The entry whose field this is.") field = Attribute("The field, bound to the entry.") class ITopLevelEntryLink(Interface): """A link to a special entry. For instance, an alias for the currently logged-in user. The link will be present in the representation of the service root resource. """ link_name = Attribute("The name of the link to this entry in the " "representation of the service root resource. " "'_link' will be automatically appended.") entry_type = Attribute("The interface defined by the entry on the " "other end of the link.") class IWebBrowserOriginatingRequest(Interface): """A browser request to an object also published on the web service. A web framework may define an adapter for this interface, allowing the web service to include, as part of an entry's representation, a link to that same entry as found on the website. """ class INotificationsProvider(Interface): """A response object which contains notifications. A web framework may define an adapter for this interface, allowing the web service to provide notifications to be sent to the client. """ notifications = Attribute(dedent("""\ A list of namedtuples (level, message) which can be used by the caller to display extra information about the completed request. 'level' matches the standard logging levels: DEBUG = logging.DEBUG # a debugging message INFO = logging.INFO # simple confirmation of a change WARNING = logging.WARNING # action will not be successful unless... ERROR = logging.ERROR # the previous action did not succeed """)) class IWebServiceClientRequest(IBrowserRequest): """Interface for requests to the web service.""" version = Attribute("The version of the web service that the client " "requested.") class IWebServiceLayer(IWebServiceClientRequest, IDefaultBrowserLayer): """Marker interface for registering views on the web service.""" class IWebServiceVersion(Interface): """Used to register IWebServiceClientRequest subclasses as utilities. Every version of a web service must register a subclass of IWebServiceClientRequest as an IWebServiceVersion utility, with a name that's the web service version name. For instance: registerUtility(IWebServiceClientRequestBeta, IWebServiceVersion, name="beta") """ pass class IJSONRequestCache(Interface): """A cache of objects exposed as URLs or JSON representations.""" links = Attribute("Objects whose links need to be exposed.") objects = Attribute("Objects whose JSON representations need " "to be exposed.") class IByteStorage(Interface): """A sequence of bytes stored on the server. The bytestream is expected to have a URL other than the one used by the web service. """ alias_url = Attribute("The external URL to the byte stream.") filename = Attribute("Filename for the byte stream.") is_stored = Attribute("Whether or not there's a previously created " "external byte stream here.") def createStored(mediaType, representation, filename=None): """Create a new stored bytestream. :param filename: The name of the file being stored. If None, the name of the storage field is used instead. """ def deleteStored(): """Delete an existing stored bytestream.""" class IByteStorageResource(IHTTPResource): """A resource that represents an external binary file.""" def do_GET(): """Redirect the client to the externally hosted file.""" def do_PUT(media_type, representation): """Update the stored bytestream. :param media_type: The media type of the proposed new bytesteram. :param representation: The proposed new bytesteram. :return: None or an error message describing validation errors. The HTTP status code should be set appropriately. """ def do_DELETE(): """Delete the stored bytestream.""" class IFileLibrarian(Interface): """A class for managing uploaded files.""" def get(key): """Retrieve a file by key.""" def put(representation, filename): """Store a file in the librarian.""" def delete(key): """Delete a file from the librarian.""" class IFieldMarshaller(Interface): """A mapper between schema fields and their representation on the wire.""" representation_name = Attribute( 'The name to use for this field within the representation.') def marshall_from_json_data(value): """Transform the given data value into an object. This is used in PATCH/PUT requests when modifying the field, to get the actual value to use from the data submitted via JSON. :param value: A value obtained by deserializing a string into a JSON data structure. :return: The value that should be used to update the field. """ def marshall_from_request(value): """Return the value to use based on the request submitted value. This is used by operation where the data comes from either the query string or the form-encoded POST data. :param value: The value submitted as part of the request. :return: The value that should be used to update the field. """ def unmarshall(entry, value): """Transform an object value into a value suitable for JSON. :param entry: The entry whose field this is. :value: The object value of the field. :return: A value that can be serialized as part of a JSON hash. """ def unmarshall_to_closeup(entry, value): """Transform an object value into a detailed JSON-ready value. :param entry: The entry whose field this is. :value: The object value of the field. :return: A value that can be serialized as the representation of a field resource. :rtype: Any object that can be serialized to JSON. Usually a string. """ class IWebServiceConfiguration(Interface): """A group of configuration settings for a web service. These are miscellaneous strings that may differ in different web services. """ caching_policy = List( value_type=Int(), default = [7 * 24 * HOUR, 1 * HOUR], title=u"The web service caching policy.", description = u"""A list of two numbers, each to be used in the 'max-age' field of the Cache-Control header. The first number is used when serving the service root for any web service version except the latest one. The second number is used when serving the service root for the latest version (which probably changes more often).""") enable_server_side_representation_cache = Bool( title=u"Enable the server-side representation cache.", default=True, description=u"""If this is false, the server-side representation cache will not be used, even if one is registered.""" ) service_description = TextLine( title=u"Service description", description=u"""A human-readable description of the web service. The description may contain HTML, but if it does, it must be a valid XHTML fragment. """, default=u"", ) view_permission = TextLine( title=u"View permission", default=u"zope.View", description=u"The permission to use when checking object visibility.") path_override = TextLine( title=u"Web service path override", default=u"api", description=u"The path component for Ajax clients to use when making " "HTTP requests to the web service from their current virtual host. " "The use of this path component (/api/foo instead of /foo) will " "ensure that the request is processed as a web service request " "instead of a website request.") use_https = Bool( title=u"Web service is secured", default=True, description=u"Whether or not requests to the web service are secured " "through SSL.") hostname = TextLine( title=u"The hostname to be used in generated URLs.", description=u"You only need to specify this if you're using the " "RootResourceAbsoluteURL class from lazr.restful.simple. This is " "the hostname of the lazr.restful application.") port = Int( title=u"The TCP port on which the web service is running.", description=u"Used in generated URLs.", default=0) service_root_uri_prefix = TextLine( title=u"Any URL prefix necessary for the service root.", default=u"", description=u"If your web service is not located at the root of " "its domain (for instance, it's rooted at " "http://foo.com/web-service/ instead of http://api.foo/com/, " "put the URL prefix here. (In the example case, the URL prefix " "is 'web-service/').") active_versions = List( value_type=TextLine(), default = [], title=u"The active versions of the web service.", description = u"""A list of names of active versions of this web service. They might be version numbers, names such as "beta", or the date a particular version was finalized. Newer versions should show up later in the list than earlier versions. It's recommended that the last version, located at the end of the list, be a floating development version called something like 'trunk' or 'devel': effectively an alias for "the most up-to-date code". This list must contain at least one version name.""") version_descriptions = Dict( key_type = TextLine(), value_type = Text(), title = u"Human-readable descriptions of the web service versions.", description = u"""A dictionary mapping version names to human-readable descriptions. The descriptions should describe what distinguishes this version from other versions, and mention if/when the version will be removed. The descriptions may contain HTML, but if they do, they must be valid XHTML fragments. """, default = {} ) require_explicit_versions = Bool( default=False, description=u"""If true, each exported field and named operation must explicitly declare the first version in which it appears. If false, fields and operations are published in all versions.""") last_version_with_mutator_named_operations = TextLine( default=None, description=u"""In earlier versions of lazr.restful, mutator methods were also published as named operations. This redundant behavior is no longer enabled by default, but this setting allows for backwards compatibility. Mutator methods will also be published as named operations in the version you specify here, and in any previous versions. In all subsequent versions, they will not be published as named operations.""") first_version_with_total_size_link = TextLine( default=None, description=u"""In earlier versions of lazr.restful collections included a total_size field, now they include a total_size_link instead. Setting this value determines in which version the new behavior takes effect.""") code_revision = TextLine( default=u"", description=u"""A string designating the current revision number of the code running the webservice. This may be a revision number from version control, or a hand-chosen version number.""") show_tracebacks = Bool( title=u"Show tracebacks to end-users", default=True, description=u"Whether or not to show tracebacks in an HTTP response " "for a request that raised an exception.") default_batch_size = Int( title=u"The default batch size to use when serving a collection", default=50, description=u"When the client requests a collection and doesn't " "specify how many entries they want, this many entries will be " "served them in the first page.") max_batch_size = Int( title=u"The maximum batch size", default=300, description=u"When the client requests a batch of entries from " "a collection, they will not be allowed to request more entries " "in the batch than this.") compensate_for_mod_compress_etag_modification = Bool( title=u"Accept incoming ETags that appear to have been modified " "in transit by Apache's mod_compress.", default=False, description=u"""When mod_compress compresses an outgoing representation, it (correctly) modifies the ETag. But when that ETag comes back in on a conditional GET or PUT request, mod_compress does _not_ transparently remove the modification, because it can't be sure which HTTP intermediary was responsible for modifying the ETag on the way out. Setting this value to True will tell lazr.restful that mod_compress is in use. lazr.restful knows what mod_compress does to outgoing ETags, and if this value is set, it will strip the modification from incoming ETags that appear to have been modified by mod_compress. Specifically: if lazr.restful generates an etag '"foo"', mod_compress will change it to '"foo"-gzip' or '"foo-gzip"', depending on the version. If this value is set to False, lazr.restful will not recognize '"foo"-gzip' or '"foo-gzip"' as being equivalent to '"foo"'. If this value is set to True, lazr.restful will treat all three strings the same way. This is a hacky solution, but until Apache issue 39727 is resolved, it's the best way to get the performance improvements of content-encoding in a real setup with multiple layers of HTTP intermediary. (An earlier attempt to use transfer-encoding instead of content-encoding failed because HTTP intermediaries strip the TE header.) """) def createRequest(body_instream, environ): """A factory method that creates a request for the web service. It should have the correct publication set for the application. :param body_instream: A file-like object containing the request input stream. :param environ: A dict containing the request environment. """ def get_request_user(): """The user who made the current web service request. 'User' here has whatever meaning it has in your application. This value will be fed back into your code. """ class IUnmarshallingDoesntNeedValue(Interface): """A marker interface for unmarshallers that work without values. Most marshallers transform the value they're given, but some work entirely on the field name. If they use this marker interface we'll save time because we won't have to calculate the value. """ class IWebBrowserInitiatedRequest(Interface): """A marker interface for requests initiated by a web browser. Web browsers are broken in subtle ways that interact in complex ways with the parts of HTTP used in web services. It's useful to know when a request was initiated by a web browser so that responses can be tweaked for their benefit. """ class ITraverseWithGet(IPublishTraverse): """A marker interface for a class that uses get() for traversal. This is a simple way to handle traversal. """ def get(request, name): """Traverse to a sub-object.""" class IRepresentationCache(Interface): """A cache for resource representations. Register an object as the utility for this interface and lazr.restful will use that object to cache resource representations. If no object is registered as the utility, representations will not be cached. This is designed to be used with memcached, but you can plug in other key-value stores. Note that this cache is intended to store string representations, not deserialized JSON objects or anything else. """ def get(object, media_Type, version, default=None): """Retrieve a representation from the cache. :param object: An IEntry--the object whose representation you want. :param media_type: The media type of the representation to get. :param version: The version of the web service for which to fetch a representation. :param default: The object to return if no representation is cached for this object. :return: A string representation, or `default`. """ pass def set(object, media_type, version, representation): """Add a representation to the cache. :param object: An IEntry--the object whose representation this is. :param media_type: The media type of the representation. :param version: The version of the web service in which this representation should be stored. :param representation: The string representation to store. """ pass def delete(object): """Remove *all* of an object's representations from the cache. This means representations for every (supported) media type and every version of the web service. Currently the only supported media type is 'application/json'. :param object: An IEntry--the object being represented. """ pass InvalidBatchSizeError.__lazr_webservice_error__ = 400 lazr.restful-0.19.3/src/lazr/restful/interfaces/__init__.py0000644000175000017500000000265411631755356024141 0ustar benjibenji00000000000000# Copyright 2008-2009 Canonical Ltd. All rights reserved. # # This file is part of lazr.restful # # lazr.restful is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by # the Free Software Foundation, version 3 of the License. # # lazr.restful is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or # FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public # License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with lazr.restful. If not, see . """Interfaces for lazr.restful.""" # pylint: disable-msg=W0401 __version__ = 1.0 # Re-export in such a way that __version__ can still be imported if # dependencies are not yet available. try: # While we generally frown on "*" imports, this approach, combined with # the fact we only test code from this module, means that we can verify # what has been exported in the local files (DRY). from lazr.restful.interfaces._fields import * from lazr.restful.interfaces._fields import __all__ as _fields_all from lazr.restful.interfaces._rest import * from lazr.restful.interfaces._rest import __all__ as _rest_all __all__ = [] __all__.extend(_fields_all) __all__.extend(_rest_all) except ImportError: pass lazr.restful-0.19.3/src/lazr/restful/interfaces/_fields.py0000644000175000017500000000276411631755356024011 0ustar benjibenji00000000000000# Copyright 2008-2009 Canonical Ltd. All rights reserved. # # This file is part of lazr.restful # # lazr.restful is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by # the Free Software Foundation, version 3 of the License. # # lazr.restful is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or # FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public # License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with lazr.restful. If not, see . """Interfaces for LAZR zope.schema fields.""" __metaclass__ = type __all__ = [ 'ICollectionField', 'IReference', 'IReferenceChoice', ] from zope.interface import Attribute from zope.schema.interfaces import IChoice, IObject, ISequence class ICollectionField(ISequence): """A field representing a sequence. All iterables satisfy this collection field. """ class IReference(IObject): """A reference to an object providing a particular schema. Validation only enforce that the object provides the interface, not that all its attributes matches the schema constraints. """ class IReferenceChoice(IReference, IChoice): """Interface for a choice among objects.""" schema = Attribute( "The interface provided by all elements of the choice vocabulary.") lazr.restful-0.19.3/src/lazr/restful/marshallers.py0000644000175000017500000004532611631755356022577 0ustar benjibenji00000000000000# Copyright 2008 Canonical Ltd. All rights reserved. """Marshallers for fields used in HTTP resources.""" __metaclass__ = type __all__ = [ 'AbstractCollectionFieldMarshaller', 'BoolFieldMarshaller', 'BytesFieldMarshaller', 'CollectionFieldMarshaller', 'DateTimeFieldMarshaller', 'FloatFieldMarshaller', 'IntFieldMarshaller', 'ObjectLookupFieldMarshaller', 'SetFieldMarshaller', 'SimpleFieldMarshaller', 'SimpleVocabularyLookupFieldMarshaller', 'TextFieldMarshaller', 'TokenizedVocabularyFieldMarshaller', 'URLDereferencingMixin', 'VocabularyLookupFieldMarshaller', ] from datetime import datetime import pytz from StringIO import StringIO import urllib import simplejson from zope.datetime import ( DateTimeError, DateTimeParser, ) from zope.component import ( getMultiAdapter, getUtility, ) from zope.interface import implements from zope.publisher.interfaces import NotFound from zope.security.proxy import removeSecurityProxy from zope.traversing.browser import absoluteURL from lazr.uri import ( URI, InvalidURIError, ) from lazr.restful.interfaces import ( IFieldMarshaller, IUnmarshallingDoesntNeedValue, IServiceRootResource, IWebServiceConfiguration, ) from lazr.restful.utils import safe_hasattr class URLDereferencingMixin: """A mixin for any class that dereferences URLs into objects.""" def dereference_url(self, url): """Look up a resource in the web service by URL. Representations and custom operations use URLs to refer to resources in the web service. When processing an incoming representation or custom operation it's often necessary to see which object a URL refers to. This method calls the URL traversal code to dereference a URL into a published object. :param url: The URL to a resource. :raise NotFound: If the URL does not designate a published object. """ config = getUtility(IWebServiceConfiguration) if config.use_https: site_protocol = 'https' default_port = '443' else: site_protocol = 'http' default_port = '80' request_host = self.request.get('HTTP_HOST', 'localhost') if ':' in request_host: request_host, request_port = request_host.split(':', 2) else: request_port = default_port if not isinstance(url, basestring): raise ValueError(u"got '%s', expected string: %r" % ( type(url).__name__, url)) if url.startswith('/'): # It's a relative URI. Resolve it relative to the root of this # version of the web service. service_root = getUtility(IServiceRootResource) root_uri = absoluteURL(service_root, self.request) uri = URI(root_uri).append(url[1:]) else: uri = URI(url) protocol = uri.scheme host = uri.host port = uri.port or default_port path = uri.path query = uri.query fragment = uri.fragment url_host_and_http_host_are_identical = ( host == request_host and port == request_port) if (not url_host_and_http_host_are_identical or protocol != site_protocol or query is not None or fragment is not None): raise NotFound(self, url, self.request) path_parts = [urllib.unquote(part) for part in path.split('/')] path_parts.pop(0) path_parts.reverse() request = config.createRequest(StringIO(), {'PATH_INFO' : path}) request.setTraversalStack(path_parts) root = request.publication.getApplication(self.request) return request.traverse(root) class SimpleFieldMarshaller: """A marshaller that returns the same value it's served. This implementation is meant to be subclassed. """ implements(IFieldMarshaller) # Set this to type or tuple of types that the JSON value must be of. _type = None def __init__(self, field, request): self.field = field self.request = request def marshall_from_json_data(self, value): """See `IFieldMarshaller`. When value is None, return None, otherwise call _marshall_from_json_data(). """ if value is None: return None return self._marshall_from_json_data(value) def marshall_from_request(self, value): """See `IFieldMarshaller`. Try to decode value as a JSON-encoded string and pass it on to _marshall_from_request() if it's not None. If value isn't a JSON-encoded string, interpret it as string literal. """ if value != '': try: v = value if isinstance (v, str): v = v.decode('utf8') # assume utf8 # XXX gary 2009-03-28 # The use of the enclosing brackets is a hack to work around # simplejson bug 43: # http://code.google.com/p/simplejson/issues/detail?id=43 v = simplejson.loads(u'[%s]' % (v,)) except (ValueError, TypeError): # Pass the value as is. This saves client from having to encode # strings. pass else: # see comment about simplejson bug above value = v[0] if value is None: return None return self._marshall_from_request(value) def _marshall_from_request(self, value): """Hook method to marshall a non-null JSON value. Default is to just call _marshall_from_json_data() with the value. """ return self._marshall_from_json_data(value) def _marshall_from_json_data(self, value): """Hook method to marshall a no-null value. Default is to return the value unchanged. """ if self._type is not None: if not isinstance(value, self._type): if isinstance(self._type, (tuple, list)): expected_name = ", ".join( a_type.__name__ for a_type in self._type) else: expected_name = self._type.__name__ raise ValueError( u"got '%s', expected %s: %r" % ( type(value).__name__, expected_name, value)) return value @property def representation_name(self): """See `IFieldMarshaller`. Return the field name as is. """ return self.field.__name__ def unmarshall(self, entry, value): """See `IFieldMarshaller`. Return the value as is. """ return value def unmarshall_to_closeup(self, entry, value): """See `IFieldMarshaller`. Return the value as is. """ return self.unmarshall(entry, value) class BoolFieldMarshaller(SimpleFieldMarshaller): """A marshaller that transforms its value into an integer.""" _type = bool class IntFieldMarshaller(SimpleFieldMarshaller): """A marshaller that transforms its value into an integer.""" _type = int class FloatFieldMarshaller(SimpleFieldMarshaller): """A marshaller that transforms its value into an integer.""" _type = (float, int) def _marshall_from_json_data(self, value): """See `SimpleFieldMarshaller`. Converts the value to a float. """ return float( super(FloatFieldMarshaller, self)._marshall_from_json_data(value)) class BytesFieldMarshaller(SimpleFieldMarshaller): """FieldMarshaller for IBytes field.""" _type = str _type_error_message = 'not a string: %r' @property def representation_name(self): """See `IFieldMarshaller`. Represent as a link to another resource. """ return "%s_link" % self.field.__name__ def unmarshall(self, entry, bytestorage): """See `IFieldMarshaller`. Marshall as a link to the byte storage resource. """ return "%s/%s" % (absoluteURL(entry.context, self.request), self.field.__name__) def _marshall_from_request(self, value): """See `SimpleFieldMarshaller`. Reads the data from file-like object, and converts non-strings into one. """ if safe_hasattr(value, 'seek'): value.seek(0) value = value.read() elif not isinstance(value, basestring): value = str(value) else: # Leave string conversion to _marshall_from_json_data. pass return super(BytesFieldMarshaller, self)._marshall_from_request(value) def _marshall_from_json_data(self, value): """See `SimpleFieldMarshaller`. Convert all strings to byte strings. """ if isinstance(value, unicode): value = value.encode('utf-8') return super( BytesFieldMarshaller, self)._marshall_from_json_data(value) class TextFieldMarshaller(SimpleFieldMarshaller): """FieldMarshaller for IText fields.""" _type = unicode _type_error_message = 'not a unicode string: %r' def _marshall_from_request(self, value): """See `SimpleFieldMarshaller`. Converts the value to unicode. """ value = unicode(value) return super(TextFieldMarshaller, self)._marshall_from_request(value) class FixedVocabularyFieldMarshaller(SimpleFieldMarshaller): """A marshaller for vocabulary fields whose vocabularies are fixed.""" def __init__(self, field, request, vocabulary): """Initialize with respect to a field. :vocabulary: This argument is ignored; field.vocabulary is the same object. This argument is passed because the VocabularyLookupFieldMarshaller uses the vocabulary as part of a multiadapter lookup of the appropriate marshaller. """ super(FixedVocabularyFieldMarshaller, self).__init__( field, request) def unmarshall_to_closeup(self, entry, value): """Describe all values, not just the selected value.""" unmarshalled = [] for item in self.field.vocabulary: item_dict = {'token' : item.token, 'title' : item.title} if value.title == item.title: item_dict['selected'] = True unmarshalled.append(item_dict) return unmarshalled class TokenizedVocabularyFieldMarshaller(FixedVocabularyFieldMarshaller): """A marshaller that looks up value using a token in a vocabulary.""" def __init__(self, field, request, vocabulary): super(TokenizedVocabularyFieldMarshaller, self).__init__( field, request, vocabulary) def _marshall_from_json_data(self, value): """See `SimpleFieldMarshaller`. Looks up the value as a token in the vocabulary. """ try: return self.field.vocabulary.getTermByToken(str(value)).value except LookupError: raise ValueError(u"%r isn't a valid token" % value) class DateTimeFieldMarshaller(SimpleFieldMarshaller): """A marshaller that transforms its value into a datetime object.""" def _marshall_from_json_data(self, value): """Parse the value as a datetime object.""" try: value = DateTimeParser().parse(value) (year, month, day, hours, minutes, secondsAndMicroseconds, timezone) = value seconds = int(secondsAndMicroseconds) microseconds = int( round((secondsAndMicroseconds - seconds) * 1000000)) if timezone not in ['Z', '+0000', '-0000']: raise ValueError("Time not in UTC.") return datetime(year, month, day, hours, minutes, seconds, microseconds, pytz.utc) except DateTimeError: raise ValueError("Value doesn't look like a date.") except TypeError: # JSON will serialize '20090131' as a number raise ValueError("Value doesn't look like a date.") class DateFieldMarshaller(DateTimeFieldMarshaller): """A marshaller that transforms its value into a date object.""" def _marshall_from_json_data(self, value): """Parse the value as a datetime.date object.""" super_class = super(DateFieldMarshaller, self) date_time = super_class._marshall_from_json_data(value) return date_time.date() class AbstractCollectionFieldMarshaller(SimpleFieldMarshaller): """A marshaller for AbstractCollections. It looks up the marshaller for its value-type, to handle its contained elements. """ # The only valid JSON representation is a list. _type = list _type_error_message = 'not a list: %r' def __init__(self, field, request): """See `SimpleFieldMarshaller`. This also looks for the appropriate marshaller for value_type. """ super(AbstractCollectionFieldMarshaller, self).__init__( field, request) self.value_marshaller = getMultiAdapter( (field.value_type, request), IFieldMarshaller) def _marshall_from_json_data(self, value): """See `SimpleFieldMarshaller`. Marshall every elements of the list using the appropriate marshaller. """ value = super( AbstractCollectionFieldMarshaller, self)._marshall_from_json_data(value) # In AbstractCollection subclasses, _type contains the type object, # which can be used as a factory. return self._python_collection_factory( self.value_marshaller.marshall_from_json_data(item) for item in value) def _marshall_from_request(self, value): """See `SimpleFieldMarshaller`. If the value isn't a list, transform it into a one-element list. That allows web client to submit one-element list of strings without having to JSON-encode it. Additionally, all items in the list are marshalled using the appropriate `IFieldMarshaller` for the value_type. """ if not isinstance(value, list): value = [value] return self._python_collection_factory( self.value_marshaller.marshall_from_request(item) for item in value) @property def _python_collection_factory(self): """Create the appropriate python collection from a list.""" # In AbstractCollection subclasses, _type contains the type object, # which can be used as a factory. return self.field._type def unmarshall(self, entry, value): """See `SimpleFieldMarshaller`. The collection is unmarshalled into a list and all its items are unmarshalled using the appropriate FieldMarshaller. """ return [self.value_marshaller.unmarshall(entry, item) for item in value] class SetFieldMarshaller(AbstractCollectionFieldMarshaller): """Marshaller for sets.""" @property def _python_collection_factory(self): return set class CollectionFieldMarshaller(SimpleFieldMarshaller): """A marshaller for collection fields.""" implements(IUnmarshallingDoesntNeedValue) @property def representation_name(self): """See `IFieldMarshaller`. Make it clear that the value is a link to a collection. """ return "%s_collection_link" % self.field.__name__ def unmarshall(self, entry, value): """See `IFieldMarshaller`. This returns a link to the scoped collection. """ return "%s/%s" % (absoluteURL(entry.context, self.request), self.field.__name__) def VocabularyLookupFieldMarshaller(field, request): """A marshaller that uses the underlying vocabulary. This is just a factory function that does another adapter lookup for a marshaller, one that can take into account the vocabulary in addition to the field type (presumably Choice) and the request. """ return getMultiAdapter((field, request, field.vocabulary), IFieldMarshaller) class SimpleVocabularyLookupFieldMarshaller(FixedVocabularyFieldMarshaller): """A marshaller for vocabulary lookup by title.""" def __init__(self, field, request, vocabulary): """Initialize the marshaller with the vocabulary it'll use.""" super(SimpleVocabularyLookupFieldMarshaller, self).__init__( field, request, vocabulary) self.vocabulary = vocabulary def _marshall_from_json_data(self, value): """Find an item in the vocabulary by title.""" valid_titles = [] for item in self.field.vocabulary.items: if item.title == value: return item valid_titles.append(item.title) raise ValueError( u'Invalid value "%s". Acceptable values are: %s' % (value, ', '.join(valid_titles))) def unmarshall(self, entry, value): if value is None: return None return value.title class ObjectLookupFieldMarshaller(SimpleFieldMarshaller, URLDereferencingMixin): """A marshaller that turns URLs into data model objects. This marshaller can be used with a IChoice field (initialized with a vocabulary) or with an IReference field (no vocabulary). """ def __init__(self, field, request, vocabulary=None): super(ObjectLookupFieldMarshaller, self).__init__(field, request) self.vocabulary = vocabulary @property def representation_name(self): """See `IFieldMarshaller`. Make it clear that the value is a link to an object, not an object. """ return "%s_link" % self.field.__name__ def unmarshall(self, entry, value): """See `IFieldMarshaller`. Represent an object as the URL to that object """ repr_value = None if value is not None: repr_value = absoluteURL(value, self.request) return repr_value def _marshall_from_json_data(self, value): """See `IFieldMarshaller`. Look up the data model object by URL. """ try: resource = self.dereference_url(value) except NotFound: # The URL doesn't correspond to any real object. raise ValueError(u'No such object "%s".' % value) except InvalidURIError: raise ValueError(u'"%s" is not a valid URI.' % value) # We looked up the URL and got the thing at the other end of # the URL: a resource. But internally, a resource isn't a # valid value for any schema field. Instead we want the object # that serves as a resource's context. Any time we want to get # to the object underlying a resource, we need to strip its # security proxy. return removeSecurityProxy(resource).context lazr.restful-0.19.3/src/lazr/restful/error.py0000644000175000017500000000775011631755356021412 0ustar benjibenji00000000000000# Copyright 2008 Canonical Ltd. All rights reserved. """Error handling on the webservice.""" __metaclass__ = type __all__ = [ 'ClientErrorView', 'expose', 'NotFoundView', 'RequestExpiredView', 'SystemErrorView', 'UnauthorizedView', 'WebServiceExceptionView', ] import traceback from zope.component import getUtility from zope.interface import implements try: # This import brings some big ugly dependencies, and is not strictly # necessary. from zope.app.exception.interfaces import ISystemErrorView except ImportError: from zope.interface import Interface class ISystemErrorView(Interface): """Error views that can classify their contexts as system errors """ def isSystemError(): """Return a boolean indicating whether the error is a system error""" from lazr.restful.interfaces import ( IWebServiceConfiguration, IWebServiceExceptionView, ) class WebServiceExceptionView: """Generic view handling exceptions on the web service.""" implements(IWebServiceExceptionView) def __init__(self, context, request): self.context = context self.request = request @property def status(self): """The HTTP status to use for the response. By default, use the __lazr_webservice_error__ attribute on the exception. """ return getattr(self.context, '__lazr_webservice_error__', 500) def isSystemError(self): """See `ISystemErrorView`.""" return self.status / 100 == 5 def __call__(self): """Generate the HTTP response describing the exception.""" response = self.request.response response.setStatus(self.status) response.setHeader('Content-Type', 'text/plain') if getattr(self.request, 'oopsid', None) is not None: response.setHeader('X-Lazr-OopsId', self.request.oopsid) show_tracebacks = getUtility( IWebServiceConfiguration).show_tracebacks server_error = self.status / 100 == 5 if (not show_tracebacks and server_error): # Don't show the exception message; it might contain # private information. result = [self.context.__class__.__name__] else: # It's okay to show the exception message result = [str(self.context)] if show_tracebacks and server_error: result.append('\n\n') result.append(traceback.format_exc()) return ''.join(result) # lazr/restful/configure.zcml registers these classes as adapter # factories for common Zope exceptions. class ClientErrorView(WebServiceExceptionView): """Client-induced error.""" implements(ISystemErrorView) status = 400 class SystemErrorView(WebServiceExceptionView): """Server error.""" implements(ISystemErrorView) status = 500 class NotFoundView(WebServiceExceptionView): """View for NotFound.""" status = 404 class UnauthorizedView(WebServiceExceptionView): """View for Unauthorized exception.""" status = 401 # This is currently only configured/tested in Launchpad. class RequestExpiredView(WebServiceExceptionView): """View for RequestExpired exception.""" status = 503 def expose(exception, status=400): """Tell lazr.restful to deliver details about the exception to the client. """ # Since Python lets us raise exception types without instantiating them # (like "raise RuntimeError" instead of "raise RuntimeError()", we want to # make sure the caller doesn't get confused and try that with us. if not isinstance(exception, BaseException): raise ValueError('This function only accepts exception instances. ' 'Use the @error_status decorator to publish an ' 'exception class as a web service error.') exception.__lazr_webservice_error__ = status # Mark the exception to indicate that its details should be sent to the # web service client. return exception lazr.restful-0.19.3/src/lazr/restful/jsoncache.py0000644000175000017500000000201111631755356022177 0ustar benjibenji00000000000000# Copyright 2008 Canonical Ltd. All rights reserved. # """A class for storing resources where they can be seen by a template.""" __metaclass__ = type __all__ = [ 'JSONRequestCache' ] from lazr.restful.interfaces import ( IJSONRequestCache, LAZR_WEBSERVICE_NS) from zope.component import adapts from zope.interface import implements from zope.publisher.interfaces import IApplicationRequest class JSONRequestCache: """Default implementation for `IJSONRequestCache`.""" implements(IJSONRequestCache) adapts(IApplicationRequest) LAZR_OBJECT_JSON_CACHE = ("%s.object-json-cache" % LAZR_WEBSERVICE_NS) LAZR_LINK_JSON_CACHE = ("%s.link-json-cache" % LAZR_WEBSERVICE_NS) def __init__(self, request): """Initialize with a request.""" self.objects = request.annotations.setdefault( self.LAZR_OBJECT_JSON_CACHE, {}) self.links = request.annotations.setdefault( self.LAZR_LINK_JSON_CACHE, {}) lazr.restful-0.19.3/src/lazr/restful/NEWS.txt0000644000175000017500000003741211636132351021207 0ustar benjibenji00000000000000===================== NEWS for lazr.restful ===================== 0.19.3 (2011-09-20) =================== Fixed bug 854695: exceptions with no __traceback__ attribute would cause an AttributeError 0.19.2 (2011-09-08) =================== Fixed bug 842917: multiple values for ws.op in a request would generate a TypeError 0.19.1 (2011-09-08) =================== Fixed bug 832136: original tracebacks were being obscured when exceptions are rereaised. 0.19.0 (2011-07-27) =================== A new decorator, @accessor_for, has been added to lazr.restful.declarations. This makes it possible to export a method with bound variables as an accessor for an attribute. 0.18.1 (2011-04-01) =================== Fixed minor test failures. The object modification event will not be fired if a client sends an empty changeset via PATCH. The webservice may define an adapter which is used, after an operation on a resource, to provide notifications consisting of namedtuples (level, message). Any notifications are json encoded and inserted into the response header using the 'X-Lazr-Notification' key. They may then be used by the caller to provide extra information to the user about the completed request. The webservice:json TALES function now returns JSON that will survive HTML escaping. 0.18.0 (2011-03-23) =================== If the configuration variable `require_explicit_versions` is set, lazr.restful will not load up a web service unless every field, entry, and named operation explicitly states which version of the web service it first appears in. 0.17.5 (2011-03-15) =================== When a view is registered for an exception, but the view contains no information that's useful to lazr.restful, re-raise the exception instead of trying to render the view. 0.17.4 (2011-03-08) =================== Reverted the client cache representations to JSON-only. Call sites need to escape the JSON_PLUS_XHTML_TYPE representation which may require JSONEncoderForHTML or declaring the the script as CDATA. 0.17.3 (2011-03-08) =================== Fixed a bug in exception handling when the associated response code is in the 4xx series. 0.17.2 (2011-03-03) =================== Several of the techniques for associating an exception with an HTTP response code were not working at all. Fixed them. 0.17.1 (2011-02-23) =================== Add a new test to the testsuite. 0.17.0 (2011-02-17) =================== Added the ability to get a combined JSON/HTML representation of an entry that has custom HTML representations for some of its fields. 0.16.1 (2011-02-16) =================== Fixed a bug that prevented a write operation from being promoted to a mutator operation. 0.16.0 (No official release) ============================ If each entry in the web service corresponds to some object on a website, and there's a way of converting a web service request into a website request, the web service will now provide website links for each entry. You can suppress the website link for a particular entry class by passing publish_web_link=False into export_as_webservice_entry(). Validation errors for named operations will be properly sent to the client even if they contain Unicode characters. (Launchpad bug 619180.) 0.15.4 (2011-01-26) =================== Fixed inconsistent handling of custom HTML field renderings. An IFieldHTMLRenderer can now return either Unicode or UTF-8. 0.15.3 (2011-01-21) =================== lazr.restful will now complain if you try to export an IObject, as this causes infinite recursion during field validation. We had code that worked around the infinite recursion, but it wasn't reliable and we've now removed it to simplify. Use IReference whenever you would use IObject. 0.15.2 (2011-01-20) =================== lazr.restful gives a more helpful error message when a published interface includes a reference to an unpublished interface. (Launchpad bug 539070) lazr.restful's tests now pass in Python 2.7. (Launchpad bug 691841) 0.15.1 (2011-01-19) =================== Fixed a redirect bug when a web browser requests a representation other than JSON. Removed overzealous error checking that was causing problems for browsers such as Chromium. (Launchpad bug 423149.) 0.15.0 (2010-11-30) =================== Added an optimization to the WADL docstring handling that results in a 30% decrease in WADL generation time for large files. 0.14.1 (2010-10-24) =================== Fixed a unicode encoding bug that precluded reporting exceptions with non-ASCII characters. 0.14.0 (2010-10-05) =================== Rework ETag generation to be less conservative (an optimization). 0.13.3 (2010-09-29) =================== Named operations that take URLs as arguments will now accept URLs relative to the versioned service root. Previously they would only accept absolute URLs. PUT and PATCH requests will also accept relative URLs. This fixes bug 497602. 0.13.2 (2010-09-27) =================== Avoided an error when looking at a Location header that contains characters not valid in URIs. (An error will probably still happen, but having it happen in lazr.restful was confusing people.) 0.13.1 (2010-09-23) =================== Removed a Python 2.6-ism to restore compatibility with Python 2.5. 0.13.0 (2010-09-06) =================== Add the ability to annotate an exception so the client will be given the exception message as the HTTP body of the response. 0.12.1 (2010-09-02) =================== Make WADL generation more deterministic. 0.12.0 (2010-08-26) =================== Added the ability to take a read-write field and publish it as read-only through the web service. 0.11.2 (2010-08-23) =================== Optimized lazr.restful to send 'total_size' instead of 'total_size_link' when 'total_size' is easy to calculate, possibly saving the client from sending another HTTP request. 0.11.1 (2010-08-13) =================== Fixed a bug that prevented first_version_with_total_size_link from working properly in a multi-version environment. 0.11.0 (2010-08-10) =================== Added an optimization to total_size so that it is fetched via a link when possible. The new configuration option first_version_with_total_size_link specifies what version should be the first to expose the behavior. The default is for it to be enabled for all versions so set this option to preserve the earlier behavior for previously released web services. 0.10.0 (2010-08-05) =================== Added the ability to mark interface A as a contributor to interface B so that instead of publishing A separately we will add all of A's fields and operations to the published version of B. Objects implementing B must be adaptable into A for this to work, but lazr.restful will take care of doing the actual adaptation before accessing fields/operations that are not directly provided by an object. 0.9.29 (2010-06-14) =================== Added invalidation code for the representation cache on events generated by lazr.restful itself. Made the cache more robust and fixed a bug where it would totally redact a forbidden representation rather than simply refuse to serve it. Made it possible for a cache to refuse to cache an object for any reason. 0.9.28 (2010-06-03) =================== Special note: This version adds a new configuration element, 'enable_server_side_representation_cache'. This lets you turn the representation cache on and off at runtime without unregistering the cache utility. Fixed some test failures. 0.9.27 (2010-06-01) ==================== Added the ability to define a representation cache used to store the JSON representations of entry resources, rather than building them from scratch every time. Although the cache has hooks for invalidation, lazr.restful will never invalidate any part of the cache on its own. You need to hook lazr.restful's invalidation code into your ORM or other data store. 0.9.26 (2010-05-18) =================== Special note: This version adds a new configuration element, 'compensate_for_mod_compress_etag_modification'. If you are running lazr.restful behind an Apache server, setting this configuration element will make mod_compress work properly with lazr.restful. This is not a permanent solution: a better solution will be available when Apache bug 39727 is fixed. Special note: This version removes the configuration element 'set_hop_to_hop_headers'. You can still define this element in your configuration, but it will have no effect. Removed code that handles compression through hop-to-hop headers. We've never encountered a real situation in which these headers were useful. Compression can and should be handled by intermediaries such as mod_compress. (Unfortunately, mod_compress has its own problems, which this release tries to work around.) 0.9.25 (2010-04-14) =================== Special note: This version introduces a new configuration element, 'caching_policy'. This element starts out simple but may become more complex in future versions. See the IWebServiceConfiguration interface for more details. Service root resources are now client-side cacheable for an amount of time that depends on the server configuration and the version of the web service requested. To get the full benefit, clients will need to upgrade to lazr.restfulclient 0.9.14. When a PATCH or PUT request changes multiple fields at once, the changes are applied in a deterministic order designed to minimize possible conflicts. 0.9.24 (2010-03-17) ==================== Entry resources will now accept conditional PATCH requests even if one of the resource's read-only fields has changed behind the scenes recently. 0.9.23 (2010-03-11) =================== There are two new attributes of the web service configuration, "service_description" and "version_descriptions". Both are optional, but they're useful for giving your users an overview of your web service and of the differences between versions. 0.9.22 (2010-03-05) =================== Special note: this version will break backwards compatibility in your web service unless you take a special step. See "last_version_with_named_mutator_operations" below. Refactored the code that tags request objects with version information, so that tagging would happen consistently. By default, mutator methods are no longer separately published as named operations. To maintain backwards compatibility (or if you just want this feature back), put the name of the most recent version of your web service in the "last_version_with_mutator_named_operations" field of your IWebServiceConfiguration implementation. 0.9.21 (2010-02-23) =================== Fixed a family of bugs that were treating a request originated by a web browser as though it had been originated by a web service client. 0.9.20 (2010-02-16) =================== Fixed a bug that broke multi-versioned named operations that take the request user as a fixed argument. 0.9.19 (2010-02-15) =================== A few minor bugfixes to help with Launchpad integration. 0.9.18 (2010-02-11) =================== Special note: this version contains backwards-incompatible changes. You *must* change your configuration object to get your code to work in this version! See "active_versions" below. Added a versioning system for web services. Clients can now request any number of distinct versions as well as a floating "trunk" which is always the most recent version. By using version-aware annotations, developers can publish the same data model differently over time. See the example web service in example/multiversion/ to see how the annotations work. This release _replaces_ one of the fields in IWebServiceConfiguration. The string 'service_version_uri'_prefix has become the list 'active_versions'. The simplest way to deal with this is to just put your 'service_version_uri_prefix' into a list and call it 'active_versions'. We recommend you also add a floating "development" version to the end of 'active_versions', calling it something like "devel" or "trunk". This will give your users a permanent alias to "the most recent version of the web service". 0.9.17 (2009-11-10) =================== Fixed a bug that raised an unhandled exception when a client tried to set a URL field to a non-string value. 0.9.16 (2009-10-28) =================== Fixed a bug rendering the XHTML representation of exproted objects when they contain non-ascii characters. 0.9.15 (2009-10-21) =================== Corrected a misspelling of the WADL media type. 0.9.14 (2009-10-20) =================== lazr.restful now runs without deprecation warnings on Python 2.6. 0.9.13 (2009-10-19) =================== Fixed WADL template: HostedFile DELETE method should have an id of HostedFile-delete, not HostedFile-put. 0.9.12 (2009-10-14) =================== Transparent compression using Transfer-Encoding is now optional and disabled by default for WSGI applications. (Real WSGI servers don't allow applications to set hop-by-hop headers like Transfer-Encoding.) This release introduces a new field to IWebServiceConfiguration: set_hop_by_hop_headers. If you are rolling your own IWebServiceConfiguration implementation, rather than subclassing from BaseWebServiceConfiguration or one of its subclasses, you'll need to set a value for this. Basically: set it to False if your application is running in a WSGI server, and set it to True otherwise. 0.9.11 (2009-10-12) =================== Fixed a minor import problem. 0.9.10 (2009-10-07) =================== lazr.restful runs under Python 2.4 once again. 0.9.9 (2009-10-07) ================== The authentication-related WSGI middleware classes have been split into a separate project, lazr.authentication. Fixed a bug that prevented some incoming strings from being loaded by simplejson. 0.9.8 (2009-10-06) ================== Added WSGI middleware classes for protecting resources with HTTP Basic Auth or OAuth. 0.9.7 (2009-09-24) ================== Fixed a bug that made it impossible to navigate to a field resource if the field was a link to another object. 0.9.6 (2009-09-16) ================== Simplified most web service configuration with grok directives. 0.9.5 (2009-08-26) ================== Added a function that generates a basic WSGI application, given a service root class, a publication class, and a response class. Added an AbsoluteURL implementation for the simple ServiceRootResource. Added an adapter from Django's Manager class to IFiniteSequence, so that services that use Django can serve database objects as collections without special code. Added an AbsoluteURL implementation for objects that provide more than one URL path for the generated URL. For services that use Django, added an adapter from Django's ObjectDoesNotExist to lazr.restful's NotFoundView. Fixed some testing infrastructure in lazr.restful.testing.webservice. Fix some critical packaging problems. 0.9.4 (2009-08-17) ================== Fixed an import error in simple.py. Removed a Python 2.6ism from example/wsgi/root.py. 0.9.3 (2009-08-17) ================== Added a lazr.restful.frameworks.django module to help with publishing Django model objects through lazr.restful web services. TraverseWithGet implementations now pass the request object into get(). Create a simplified IServiceRootResource implementation for web services that don't register their top-level collections as Zope utilities. Make traversal work for entries whose canonical location is beneath another entry. Raise a ValueError when numberic dates are passed to the DatetimeFieldMarshaller. 0.9.2 (2009-08-05) ================== Added a second example webservice that works as a standalone WSGI application. Bug 400170; Stop hacking sys.path in setup.py. Bug 387487; Allow a subordinate entry resource under a resource where there would normally be a field. Navigation to support subordinate IObjects is added to the publisher. 0.9.1 (2009-07-13) ================== Declare multipart/form-data as the incoming media type for named operations that include binary fields. 0.9 (2009-04-29) ================ - Initial public release lazr.restful-0.19.3/src/lazr/restful/meta.zcml0000644000175000017500000000043611631755356021516 0ustar benjibenji00000000000000 lazr.restful-0.19.3/src/lazr/restful/version.txt0000644000175000017500000000000711636132357022114 0ustar benjibenji000000000000000.19.3 lazr.restful-0.19.3/src/lazr/restful/metazcml.py0000644000175000017500000007050111631755356022067 0ustar benjibenji00000000000000# Copyright 2008 Canonical Ltd. All rights reserved. """ZCML registration directives for the LAZR webservice framework.""" __metaclass__ = type __all__ = [] import inspect import itertools from zope.component import ( adapter, getGlobalSiteManager, getUtility, ) from zope.component.zcml import handler from zope.configuration.fields import GlobalObject from zope.interface import Interface from zope.interface.interfaces import IInterface from zope.processlifetime import IProcessStarting from lazr.restful.declarations import ( COLLECTION_TYPE, ENTRY_TYPE, LAZR_WEBSERVICE_EXPORTED, OPERATION_TYPES, REMOVED_OPERATION_TYPE, generate_collection_adapter, generate_entry_adapters, generate_entry_interfaces, generate_operation_adapter, ) from lazr.restful.error import WebServiceExceptionView from lazr.restful.interfaces import ( ICollection, ICollectionField, IEntry, IReference, IResourceDELETEOperation, IResourceGETOperation, IResourcePOSTOperation, IWebServiceClientRequest, IWebServiceConfiguration, IWebServiceVersion, ) from lazr.restful.utils import VersionedObject # Keep track of entry and operation registrations so we can # sanity-check them later. REGISTERED_ENTRIES = [] REGISTERED_OPERATIONS = [] class IRegisterDirective(Interface): """Directive to hook up webservice based on the declarations in a module. """ module = GlobalObject( title=u'Module which will be inspected for webservice declarations') def generate_and_register_entry_adapters(interface, info, contributors, repository=None): """Generate an entry adapter for every version of the web service. This code generates an IEntry subinterface for every version, each subinterface based on the annotations in `interface` for that version. It then generates a set of factory classes for each subinterface, and registers each as an adapter for the appropriate version and version-specific interface. """ # Get the list of versions. config = getUtility(IWebServiceConfiguration) versions = list(config.active_versions) # Generate an interface and an adapter for every version. web_interfaces = generate_entry_interfaces( interface, contributors, *versions) web_factories = generate_entry_adapters( interface, contributors, web_interfaces) for index, (interface_version, web_interface) in ( enumerate(web_interfaces)): factory_version, factory = web_factories[index] assert factory_version==interface_version, ( "Generated interface and factory versions don't match up! " '%s vs. %s' % (factory_version, interface_version)) # If this is the earliest version, register against a generic # request interface rather than a version-specific one. This # will make certain tests require less setup. if interface_version == versions[0]: interface_version = None register_adapter_for_version(factory, interface, interface_version, IEntry, '', info) # If we were given a repository, add the interface and version # to it. if repository is not None: repository.append(VersionedObject(interface_version, web_interface)) def ensure_correct_version_ordering(name, version_list): """Make sure that a list mentions versions from earliest to latest. If an earlier version shows up after a later version, this is a sign that a developer was confused about version names when annotating the web service. :param name: The name of the object annotated with the possibly mis-ordered versions. :param version_list: The list of versions found in the interface. :raise AssertionError: If the given version list is not a earlier-to-later ordering of a subset of the web service's versions. """ configuration = getUtility(IWebServiceConfiguration) actual_versions = configuration.active_versions # Replace None with the actual version number of the earliest # version. try: earliest_version_pos = version_list.index(None) version_list = list(version_list) version_list[earliest_version_pos] = actual_versions[0] except ValueError: # The earliest version was not mentioned in the version list. # Do nothing. pass # Sort version_list according to the list of actual versions. # If the sorted list is different from the unsorted list, at # least one version is out of place. def compare(x, y): return cmp (actual_versions.index(x), actual_versions.index(y)) sorted_version_list = sorted(version_list, cmp=compare) if sorted_version_list != version_list: bad_versions = '", "'.join(version_list) good_versions = '", "'.join(sorted_version_list) msg = ('Annotations on "%s" put an earlier version on top of a ' 'later version: "%s". The correct order is: "%s".') raise AssertionError, msg % (name, bad_versions, good_versions) def register_adapter_for_version(factory, interface, version_name, provides, name, info): """A version-aware wrapper for the registerAdapter operation. During web service generation we often need to register an adapter for a particular version of the web service. The way to do this is to register a multi-adapter using the interface being adapted to, plus the marker interface for the web service version. These marker interfaces are not available when the web service is being generated, but the version strings are available. So methods like register_webservice_operations use this function as a handler for the second stage of ZCML processing. This function simply looks up the appropriate marker interface and calls Zope's handler('registerAdapter'). """ if version_name is None: # This adapter is for the earliest supported version. Register # it against the generic IWebServiceClientRequest interface, # which is the superclass of the marker interfaces for every # specific version. marker = IWebServiceClientRequest else: # Look up the marker interface for the given version. This # will also ensure the given version string has an # IWebServiceVersion utility registered for it, and is not # just a random string. marker = getUtility(IWebServiceVersion, name=version_name) handler('registerAdapter', factory, (interface, marker), provides, name, info) def _is_exported_interface(member): """Helper for find_exported_interfaces; a predicate to inspect.getmembers. Returns True if member is a webservice-aware Exception or interface. """ if (inspect.isclass(member) and issubclass(member, Exception) and getattr(member, '__lazr_webservice_error__', None) is not None): # This is a webservice-aware Exception. return True if IInterface.providedBy(member): tag = member.queryTaggedValue(LAZR_WEBSERVICE_EXPORTED) if tag is not None and tag['type'] in [ENTRY_TYPE, COLLECTION_TYPE]: # This is a webservice-aware interface. return True return False def find_exported_interfaces(module): """Find all the interfaces in a module marked for export. It also includes exceptions that represents errors on the webservice. :return: iterator of interfaces. """ return (interface for name, interface in inspect.getmembers(module, _is_exported_interface)) def find_interfaces_and_contributors(module): """Find the interfaces and its contributors marked for export. Return a dictionary with interfaces as keys and their contributors as values. """ interfaces_with_contributors = {} for interface in find_exported_interfaces(module): if issubclass(interface, Exception): # Exceptions can't have interfaces, so just store it in # interfaces_with_contributors and move on. interfaces_with_contributors.setdefault(interface, []) continue tag = interface.getTaggedValue(LAZR_WEBSERVICE_EXPORTED) if tag.get('contributes_to'): # Append this interface (which is a contributing interface) to the # list of contributors of every interface it contributes to. for iface in tag['contributes_to']: if iface.queryTaggedValue(LAZR_WEBSERVICE_EXPORTED) is None: raise AttemptToContributeToNonExportedInterface( "Interface %s contributes to %s, which is not " "exported." % (interface.__name__, iface.__name__)) raise AssertionError('foo') contributors = interfaces_with_contributors.setdefault( iface, []) contributors.append(interface) else: # This is a regular interface, but one of its contributing # interfaces may have been processed previously and in that case a # key for it would already exist in interfaces_with_contributors; # that's why we use setdefault. interfaces_with_contributors.setdefault(interface, []) # For every exported interface, check that none of its names are exported # in more than one contributing interface. for interface, contributors in interfaces_with_contributors.items(): if len(contributors) == 0: continue names = {} for iface in itertools.chain([interface], contributors): for name, f in iface.namesAndDescriptions(all=True): if f.queryTaggedValue(LAZR_WEBSERVICE_EXPORTED) is not None: L = names.setdefault(name, []) L.append(iface) for name, interfaces in names.items(): if len(interfaces) > 1: raise ConflictInContributingInterfaces(name, interfaces) return interfaces_with_contributors class ConflictInContributingInterfaces(Exception): """More than one interface tried to contribute a given attribute/method to another interface. """ def __init__(self, name, interfaces): self.msg = ( "'%s' is exported in more than one contributing interface: %s" % (name, ", ".join(i.__name__ for i in interfaces))) def __str__(self): return self.msg class AttemptToContributeToNonExportedInterface(Exception): """An interface contributes to another one which is not exported.""" def register_webservice(context, module): """Generate and register web service adapters. All interfaces in the module are inspected, and appropriate interfaces and adapters are generated and registered for the ones marked for export on the web service. """ if not inspect.ismodule(module): raise TypeError("module attribute must be a module: %s, %s" % module, type(module)) interfaces_with_contributors = find_interfaces_and_contributors(module) for interface, contributors in interfaces_with_contributors.items(): if issubclass(interface, Exception): register_exception_view(context, interface) continue tag = interface.getTaggedValue(LAZR_WEBSERVICE_EXPORTED) if tag['type'] == ENTRY_TYPE: context.action( discriminator=('webservice entry interface', interface), callable=generate_and_register_entry_adapters, args=(interface, context.info, contributors, REGISTERED_ENTRIES), ) elif tag['type'] == COLLECTION_TYPE: for version in tag['collection_default_content'].keys(): factory = generate_collection_adapter(interface, version) provides = ICollection context.action( discriminator=( 'webservice versioned adapter', interface, provides, '', version), callable=register_adapter_for_version, args=(factory, interface, version, provides, '', context.info), ) else: raise AssertionError('Unknown export type: %s' % tag['type']) context.action( discriminator=('webservice versioned operations', interface), args=(context, interface, contributors, REGISTERED_OPERATIONS), callable=generate_and_register_webservice_operations) @adapter(IProcessStarting) def webservice_sanity_checks(registration): """Ensure the web service contains no references to unpublished objects. We are worried about fields that link to unpublished objects, and operations that have arguments or return values that are unpublished. An unpublished object may not be published at all, or it may not be published in the same version in which it's referenced. """ global REGISTERED_ENTRIES global REGISTERED_OPERATIONS # Create a mapping of marker interfaces to version names. versions = getUtility(IWebServiceConfiguration).active_versions version_for_marker = { IWebServiceClientRequest: versions[0] } for version in versions: marker_interface = getUtility(IWebServiceVersion, name=version) version_for_marker[marker_interface] = version # For each version, build a list of all of the IEntries published # in that version's web service. This works because every IEntry # is explicitly registered for every version, even if there's been # no change since the last version. For the sake of performance, # the list is stored as a set of VersionedObject 2-tuples. available_registrations = set() registrations = getGlobalSiteManager().registeredAdapters() for registration in registrations: if (not IInterface.providedBy(registration.provided) or not registration.provided.isOrExtends(IEntry)): continue interface, version_marker = registration.required available_registrations.add(VersionedObject( version_for_marker[version_marker], interface)) # Check every Reference and CollectionField field in every IEntry # interface, making sure that they only reference entries that are # published in that version. for version, interface in REGISTERED_ENTRIES: for name, field in interface.namesAndDescriptions(): referenced_interface, what = _extract_reference_type(field) if referenced_interface is not None: _assert_interface_registered_for_version( version, referenced_interface, available_registrations, ("%(interface)s.%(field)s is %(what)s" % dict(interface=interface.__name__, field=field.__name__, what=what))) # For every version, check the return value and arguments to every # named operation published in that version, making sure that # there are no references to IEntries not published in that # version. for version, method in REGISTERED_OPERATIONS: tags = method.getTaggedValue(LAZR_WEBSERVICE_EXPORTED) return_type = tags.get('return_type') referenced_interface, what = _extract_reference_type(return_type) if referenced_interface is not None: _assert_interface_registered_for_version( version, referenced_interface, available_registrations, "named operation %s returns %s" % (tags['as'], what)) if not 'params' in tags: continue for name, field in tags['params'].items(): referenced_interface, what = _extract_reference_type(field) if referenced_interface is not None: _assert_interface_registered_for_version( version, referenced_interface, available_registrations, "named operation %s accepts %s" % (tags['as'], what)) del REGISTERED_OPERATIONS[:] del REGISTERED_ENTRIES[:] def _extract_reference_type(field): """Determine what kind of object the given field is a reference to. This is a helper method used by the sanity checker. :return: A 2-tuple (reference_type, human_readable): If `field` is a reference to a scalar entry, human_readable is the name of the interface. If `field` is a collection of entries, human_readable is the string "a collection of [interface name]". If `field` is not a reference to an entry, both values are None. """ if IReference.providedBy(field): schema = field.schema return (schema, schema.__name__) elif ICollectionField.providedBy(field): schema = field.value_type.schema return (schema, "a collection of %s" % schema.__name__) else: return (None, None) def _assert_interface_registered_for_version( version, interface, available_registrations, error_message_insert): """A helper method for the sanity checker. See if the given entry interface is published in the given version (as determined by the contents of `available_registrations`), and if it's not, raise an exception. :param version: The version in which `interface` should be published. :param interface: The interface that ought to be published. :param available_registrations: A set of (version, interface) pairs to check. :param error_message_insert: A snippet of text explaining the significance if `interface`, eg. "IMyInterface.myfield is IOughtToBePublished" or "named operation my_named_operation returns IOughtToBePublished". """ if version is None: version = getUtility(IWebServiceConfiguration).active_versions[0] to_check = VersionedObject(version, interface) if to_check not in available_registrations: raise ValueError( "In version %(version)s, %(problem)s, but version %(version)s " "of the web service does not publish %(reference)s as " "an entry. (It may not be published at all.)" % dict( version=version, problem=error_message_insert, reference=interface.__name__)) def generate_and_register_webservice_operations( context, interface, contributors, repository=None): """Create and register adapters for all exported methods. Different versions of the web service may publish the same operation differently or under different names. """ # First of all, figure out when to stop publishing field mutators # as named operations. config = getUtility(IWebServiceConfiguration) if config.last_version_with_mutator_named_operations is None: no_mutator_operations_after = None block_mutator_operations_as_of_version = None else: no_mutator_operations_after = config.active_versions.index( config.last_version_with_mutator_named_operations) if len(config.active_versions) > no_mutator_operations_after+1: block_mutator_operations_as_of_version = config.active_versions[ no_mutator_operations_after+1] else: block_mutator_operations_as_of_version = None methods = interface.namesAndDescriptions(True) for iface in contributors: methods.extend(iface.namesAndDescriptions(True)) for name, method in methods: tag = method.queryTaggedValue(LAZR_WEBSERVICE_EXPORTED) if tag is None or tag['type'] not in OPERATION_TYPES: # This method is not published as a named operation. continue human_readable_name = interface.__name__ + '.' + method.__name__ if (config.require_explicit_versions and tag.stack[0][0] is None): # We have annotations for an implicitly defined version, which # is prohibited. But don't panic--these might just be the # default annotations set by the system. We only need to raise # an exception if the method is actually *published*. version, annotations = tag.stack[0] if annotations.get('type') != REMOVED_OPERATION_TYPE: version = config.active_versions[0] raise ValueError( ("%s: Implicitly tagged for export to web service " 'version "%s", but the service configuration requires ' "all version declarations to be explicit. You should " 'add @operation_for_version("%s") to the bottom of the ' 'annotation stack.') % ( human_readable_name, version, version)) # Make sure that this method was annotated with the # versions in the right order. ensure_correct_version_ordering( human_readable_name, tag.dict_names) operation_name = None # If an operation's name does not change between version n and # version n+1, we want lookups for requests that come in for # version n+1 to find the registered adapter for version n. This # happens automatically. But if the operation name changes # (because the operation is now published under a new name, or # because the operation has been removed), we need to register a # masking adapter: something that will stop version n+1's lookups # from finding the adapter registered for version n. To this end # we keep track of what the operation looked like in the previous # version. previous_operation_name = None previous_operation_provides = None mutator_operation_needs_to_be_blocked = False master_stack = tag for version, tag in tag.stack: if version is None: this_version_index = 0 else: this_version_index = config.active_versions.index( version) if tag['type'] == REMOVED_OPERATION_TYPE: # This operation is not present in this version. # We'll represent this by setting the operation_name # to None. If the operation was not present in the # previous version either (or there is no previous # version), previous_operation_name will also be None # and nothing will happen. If the operation was # present in the previous version, # previous_operation_name will not be None, and the # code that handles name changes will install a # masking adapter. operation_name = None operation_provides = None factory = None # If there are any other tags besides 'type', it means # that the developer tried to annotate a method for a # version where the method isn't published. Let's warn # them about it. # # (We can't make this check when the annotation # happens, because it will reject a method that uses # an annotation like @export_operation_as before using # an annotation like @export_read_operation. That's a # little sloppy, but it's not bad enough to warrant an # exception.) tag_names = list(tag.keys()) if tag_names != ['type']: tag_names.remove('type') raise AssertionError( 'Method "%s" contains annotations for version "%s", ' 'even though it\'s not published in that version. ' 'The bad annotations are: "%s".' % ( method.__name__, version, '", "'.join(tag_names))) else: if tag['type'] == 'read_operation': operation_provides = IResourceGETOperation elif tag['type']in ['factory', 'write_operation']: operation_provides = IResourcePOSTOperation elif tag['type'] in ['destructor']: operation_provides = IResourceDELETEOperation else: # We know it's not REMOVED_OPERATION_TYPE, because # that case is handled above. raise AssertionError( 'Unknown operation type: %s' % tag['type']) operation_name = tag.get('as') if tag['type'] in ['destructor']: operation_name = '' if (tag.get('is_mutator', False) and (no_mutator_operations_after is None or no_mutator_operations_after < this_version_index)): # This is a mutator method, and in this version, # mutator methods are not published as named # operations at all. Block any lookup of the named # operation from succeeding. # # This will save us from having to do another # de-registration later. factory = _mask_adapter_registration mutator_operation_needs_to_be_blocked = False else: factory = generate_operation_adapter(method, version) # Operations are looked up by name. If the operation's # name has changed from the previous version to this # version, or if the operation was removed in this # version, we need to block lookups of the previous name # from working. if (operation_name != previous_operation_name and previous_operation_name is not None): register_adapter_for_version( _mask_adapter_registration, interface, version, previous_operation_provides, previous_operation_name, context.info) # If the operation exists in this version (ie. its name is # not None), register it using this version's name. if operation_name is not None: register_adapter_for_version( factory, interface, version, operation_provides, operation_name, context.info) if (tag.get('is_mutator') and block_mutator_operations_as_of_version != None): defined_in = this_version_index blocked_as_of = config.active_versions.index( block_mutator_operations_as_of_version) if defined_in < blocked_as_of: # In this version, this mutator is also a # named operation. But in a later version # mutators stop being named operations, and # the operation registration for this mutator # will need to be blocked. mutator_operation_needs_to_be_blocked = True if repository is not None: repository.append(VersionedObject(version, method)) previous_operation_name = operation_name previous_operation_provides = operation_provides if mutator_operation_needs_to_be_blocked: # The operation was registered as a mutator, back in the # days when mutator operations were also named operations, # and it never got de-registered. De-register it now. register_adapter_for_version( _mask_adapter_registration, interface, block_mutator_operations_as_of_version, previous_operation_provides, previous_operation_name, context.info) def _mask_adapter_registration(*args): """A factory function that stops an adapter lookup from succeeding. This function is registered when it's necessary to explicitly stop some part of web service version n from being visible to version n+1. """ return None def register_exception_view(context, exception): """Register WebServiceExceptionView to handle exception on the webservice. """ context.action( discriminator=( 'view', exception, 'index.html', IWebServiceClientRequest, IWebServiceClientRequest), callable=handler, args=('registerAdapter', WebServiceExceptionView, (exception, IWebServiceClientRequest), Interface, 'index.html', context.info), ) lazr.restful-0.19.3/src/lazr/restful/example/0000755000175000017500000000000011636155340021321 5ustar benjibenji00000000000000lazr.restful-0.19.3/src/lazr/restful/example/base_extended/0000755000175000017500000000000011636155340024113 5ustar benjibenji00000000000000lazr.restful-0.19.3/src/lazr/restful/example/base_extended/tests/0000755000175000017500000000000011636155340025255 5ustar benjibenji00000000000000lazr.restful-0.19.3/src/lazr/restful/example/base_extended/tests/test_integration.py0000644000175000017500000000201411631755356031216 0ustar benjibenji00000000000000# Copyright 20010 Canonical Ltd. All rights reserved. """Test harness for LAZR doctests.""" __metaclass__ = type __all__ = [] import os import doctest from pkg_resources import resource_filename from van.testing.layer import zcml_layer, wsgi_intercept_layer from lazr.restful.example.base.tests.test_integration import ( CookbookWebServiceTestPublication, DOCTEST_FLAGS) from lazr.restful.testing.webservice import WebServiceApplication class FunctionalLayer: allow_teardown = False zcml = os.path.abspath(resource_filename( 'lazr.restful.example.base_extended', 'site.zcml')) zcml_layer(FunctionalLayer) class WSGILayer(FunctionalLayer): @classmethod def make_application(self): return WebServiceApplication({}, CookbookWebServiceTestPublication) wsgi_intercept_layer(WSGILayer) def additional_tests(): """See `zope.testing.testrunner`.""" tests = ['../README.txt'] suite = doctest.DocFileSuite(optionflags=DOCTEST_FLAGS, *tests) suite.layer = WSGILayer return suite lazr.restful-0.19.3/src/lazr/restful/example/base_extended/tests/__init__.py0000644000175000017500000000000011631755356027364 0ustar benjibenji00000000000000lazr.restful-0.19.3/src/lazr/restful/example/base_extended/comments.py0000644000175000017500000000142711631755356026326 0ustar benjibenji00000000000000 from zope.component import adapts from zope.interface import implements, Interface from zope.schema import List, Text from lazr.restful.example.base.interfaces import IRecipe from lazr.restful.declarations import ( export_as_webservice_entry, exported) class IHasComments(Interface): export_as_webservice_entry(contributes_to=[IRecipe]) comments = exported( List(title=u'Comments made by users', value_type=Text())) class RecipeToHasCommentsAdapter: implements(IHasComments) adapts(IRecipe) def __init__(self, recipe): self.recipe = recipe @property def comments(self): return comments_db.get(self.recipe.id, []) # A fake database for storing comments. Monkey-patch this to test the # IHasComments adapter. comments_db = {} lazr.restful-0.19.3/src/lazr/restful/example/base_extended/__init__.py0000644000175000017500000000013311631755356026231 0ustar benjibenji00000000000000"""A simple webservice which uses contributing interfaces to extend the 'base' one. """ lazr.restful-0.19.3/src/lazr/restful/example/base_extended/README.txt0000644000175000017500000000151011631755356025616 0ustar benjibenji00000000000000This is a very simple webservice that demonstrates how to use contributing interfaces to add fields to an existing webservice using a plugin-like pattern. Here we've just added a 'comments' field to the IRecipe entry. >>> from lazr.restful.testing.webservice import WebServiceCaller >>> webservice = WebServiceCaller(domain='cookbooks.dev') # The comments DB for this webservice is empty so we'll add some comments # to the recipe with ID=1 >>> from lazr.restful.example.base_extended.comments import comments_db >>> comments_db[1] = ['Comment 1', 'Comment 2'] And as we can see below, a recipe's representation now include its comments. >>> print "\n".join(webservice.get('/recipes/1').jsonBody()['comments']) Comment 1 Comment 2 >>> webservice.get('/recipes/2').jsonBody()['comments'] [] lazr.restful-0.19.3/src/lazr/restful/example/base_extended/site.zcml0000644000175000017500000000116511631755356025761 0ustar benjibenji00000000000000 lazr.restful-0.19.3/src/lazr/restful/example/base/0000755000175000017500000000000011636155340022233 5ustar benjibenji00000000000000lazr.restful-0.19.3/src/lazr/restful/example/base/configure.zcml0000644000175000017500000000640311631755356025116 0ustar benjibenji00000000000000 lazr.restful-0.19.3/src/lazr/restful/example/base/tests/0000755000175000017500000000000011636155340023375 5ustar benjibenji00000000000000lazr.restful-0.19.3/src/lazr/restful/example/base/tests/service.txt0000644000175000017500000000476711631755356025624 0ustar benjibenji00000000000000Introduction ************ Some standard behavior is defined by the web service itself, not by the individual resources. >>> from lazr.restful.testing.webservice import WebServiceCaller >>> webservice = WebServiceCaller(domain='cookbooks.dev') Versioning ========== lazr.restful allows you to publish multiple named versions of a web service, as well as a "development" version which includes changes you have yet to batch together into a named version. The default name of the development version is "devel". >>> top_level_response = webservice.get( ... "/", api_version="devel").jsonBody() >>> print top_level_response['resource_type_link'] http://cookbooks.dev/devel/#service-root The web service in examples/multiversion is devoted solely to testing the versioning code. Nonexistent resources ===================== An attempt to access a nonexistent resource yields a 404 error. >>> print webservice.get("/no-such-resource") HTTP/1.1 404 Not Found ... An attempt to access an existing resource without versioning the URL yields a 404 error. >>> print webservice.get("/cookbooks", api_version="/") HTTP/1.1 404 Not Found ... >>> print webservice.get("/", api_version="/") HTTP/1.1 404 Not Found ... Nonexistent methods =================== An attempt to use an unsupported or nonexistent HTTP method on a resource yields a 405 error. >>> print webservice("/", method="COPY") HTTP/1.1 405 Method Not Allowed... Allow: GET ... >>> print webservice.delete("/cookbooks") HTTP/1.1 405 Method Not Allowed... Allow: GET POST ... >>> from urllib import quote >>> print webservice.delete(quote("/dishes/Roast chicken")) HTTP/1.1 405 Method Not Allowed... Allow: GET PUT PATCH ... >>> print webservice.delete(quote("/cookbooks/The Joy of Cooking")) HTTP/1.1 405 Method Not Allowed... Allow: GET PUT PATCH POST ... Inappropriate media types ========================= An attempt to PATCH a document with unsupported media type to a resource yields a 415 error. >>> print webservice.patch("/recipes/1", 'text/plain', "Foo") HTTP/1.1 415 Unsupported Media Type ... The only supported media types are ones that begin with 'application/json'. >>> print webservice.patch("/recipes/1", 'application/json', "{}") HTTP/1.1 209 Content Returned ... >>> print webservice.patch( ... "/recipes/1", 'application/json; charset=UTF-8', "{}") HTTP/1.1 209 Content Returned ... lazr.restful-0.19.3/src/lazr/restful/example/base/tests/collection.txt0000644000175000017500000002473511631755356026314 0ustar benjibenji00000000000000Introduction ************ All collections published by a lazr.restful web service work pretty much the same way. This document illustrates the general features of collections, using the cookbook service's collections of cookbooks and authors as examples. >>> from lazr.restful.testing.webservice import WebServiceCaller >>> webservice = WebServiceCaller(domain='cookbooks.dev') ========================== Collections and pagination ========================== A collection responds to GET by serving one page of the objects in the collection. >>> cookbooks_collection = webservice.get("/cookbooks").jsonBody() >>> cookbooks_collection['resource_type_link'] u'http://...#cookbooks' >>> cookbooks_collection['total_size'] 7 >>> cookbooks_collection['next_collection_link'] u'http://.../cookbooks?ws.start=5&ws.size=5' >>> cookbooks_collection.get('prev_collection_link') is None True >>> from operator import itemgetter >>> cookbooks_entries = sorted( ... cookbooks_collection['entries'], key=itemgetter('name')) >>> len(cookbooks_entries) 5 >>> cookbooks_entries[0]['name'] u'Everyday Greens' >>> cookbooks_entries[0]['self_link'] u'http://.../cookbooks/Everyday%20Greens' >>> cookbooks_entries[-1]['name'] u'The Joy of Cooking' There are no XHTML representations available for collections. >>> print webservice.get('/cookbooks', 'application/xhtml+xml') HTTP/1.1 200 Ok ... Content-Type: application/json ... You can get other pages of the collection by following links: >>> result = webservice.get("/cookbooks?ws.start=5&ws.size=5") >>> second_batch = result.jsonBody() >>> 'next_collection_link' in second_batch False >>> cookbooks_entries = sorted( ... second_batch['entries'], key=itemgetter('name')) >>> cookbooks_entries[0]['name'] u'Construsions un repas' You can also get a larger or smaller batch than the default: >>> bigger_batch = webservice.get("/cookbooks?ws.size=20").jsonBody() >>> len(bigger_batch['entries']) 7 >>> 'next_collection_link' in bigger_batch False >>> smaller_batch = webservice.get("/cookbooks?ws.size=2").jsonBody() >>> len(smaller_batch['entries']) 2 >>> smaller_batch['next_collection_link'] u'http://.../cookbooks?ws.start=2&ws.size=2' But requesting a batch size higher than the maximum configured value results in a 400 error. >>> print webservice.get("/cookbooks?ws.start=0&ws.size=1000") HTTP/1.1 400 Bad Request ... Content-Type: text/plain... Maximum for "ws.size" parameter is ... A collection may be empty. >>> from urllib import quote >>> url = quote("/cookbooks/Cooking Without Recipes/recipes") >>> result = webservice.get(url) >>> list(result.jsonBody()['entries']) [] ========== Visibility ========== There are two recipes in "James Beard's American Cookery", but one of them has been marked private. The private one is hidden from view in collections. >>> from urllib import quote >>> url = quote("/cookbooks/James Beard's American Cookery/recipes") >>> output = webservice.get(url).jsonBody() >>> output['total_size'] 2 >>> len(output['entries']) 1 Why does total_size differ from the number of entries? The actual bugs are filtered against the security policy at a fairly high level, but the number of visible bugs comes from lower-level code that just looks at the underlying list. This is not an ideal solution--the numbers are off, and a batch may contain fewer than 'ws.size' entries--but it keeps unauthorized clients from seeing private data. ============== Element lookup ============== The elements of a collection can be looked up by unique identifier: >>> from lazr.restful.testing.webservice import pprint_entry >>> url = quote("/cookbooks/The Joy of Cooking") >>> cookbook = webservice.get(url).jsonBody() >>> pprint_entry(cookbook) confirmed: u'tag:launchpad.net:2008:redacted' copyright_date: u'1995-01-01' cover_link: u'http://.../cookbooks/The%20Joy%20of%20Cooking/cover' cuisine: u'General' description: u'' last_printing: None name: u'The Joy of Cooking' price: 20 recipes_collection_link: u'http://.../cookbooks/The%20Joy%20of%20Cooking/recipes' resource_type_link: u'http://...#cookbook' revision_number: 0 self_link: u'http://.../cookbooks/The%20Joy%20of%20Cooking' web_link: u'http://dummyurl/' A collection may be scoped to an element: >>> url = quote("/dishes/Roast chicken/recipes") >>> result = webservice.get(url).jsonBody() >>> print result['resource_type_link'] http://...#recipe-page-resource >>> cookbooks_with_recipe = sorted( ... [r['cookbook_link'] for r in result['entries']]) >>> len(cookbooks_with_recipe) 3 >>> print cookbooks_with_recipe[0] http://.../cookbooks/James%20Beard%27s%20American%20Cookery >>> print cookbooks_with_recipe[-1] http://.../cookbooks/The%20Joy%20of%20Cooking ================ Named operations ================ A collection may expose custom named operations in response to GET requests. A named operation may do anything consistent with the nature of a GET request, but it's usually used to serve search results. The custom operation to be invoked is named in the query string's 'ws.op' argument. Here's a custom operation on the collection of cookbooks, called 'find_recipes'. >>> import simplejson >>> def search_recipes(text, vegetarian=False, start=0, size=2): ... args = ("&search=%s&vegetarian=%s&ws.start=%s&ws.size=%s" % ... (quote(text), simplejson.dumps(vegetarian), start, size)) ... return webservice.get( ... "/cookbooks?ws.op=find_recipes&%s" % args).jsonBody() >>> s_recipes = search_recipes("chicken") >>> sorted(r['instructions'] for r in s_recipes['entries']) [u'Draw, singe, stuff, and truss...', u'You can always judge...'] >>> veg_recipes = search_recipes("chicken", True) >>> veg_recipes['entries'] [] A custom operation that returns a list of objects is paginated, just like a collection. >>> s_recipes['next_collection_link'] u'http://.../cookbooks?search=chicken&vegetarian=false&ws.op=find_recipes&ws.start=2&ws.size=2' >>> s_recipes_batch_2 = search_recipes("chicken", start=2) >>> sorted(r['instructions'] for r in s_recipes_batch_2['entries']) [u'A perfectly roasted chicken is...'] Just as a collection may be empty, a custom operation may return an empty list of results: >>> empty_collection = search_recipes("nosuchrecipe") >>> [r['instructions'] for r in empty_collection['entries']] [] When an operation yields a collection of objects, the representation includes a link that yields the total size of the collection. >>> print s_recipes['total_size_link'] http://.../cookbooks?search=chicken&vegetarian=false&ws.op=find_recipes&ws.show=total_size Sending a GET request to that link yields a JSON representation of the total size. >>> print webservice.get(s_recipes['total_size_link']).jsonBody() 3 If the entire collection fits in a single 'page' of results, the 'total_size_link' is not present; instead, lazr.restful provides the total size as a convenience to the client. >>> full_list = search_recipes("chicken", size=100) >>> len(full_list['entries']) 3 >>> full_list['total_size'] 3 >>> full_list['total_size_link'] Traceback (most recent call last): ... KeyError: 'total_size_link' The same is true if the client requests the last page of a list. >>> last_page = search_recipes("chicken", start=2, size=2) >>> len(last_page['entries']) 1 >>> full_list['total_size'] 3 >>> full_list['total_size_link'] Traceback (most recent call last): ... KeyError: 'total_size_link' Custom operations may have error handling. In this case, the error handling is in the validate() method of the 'search' field. >>> print webservice.get("/cookbooks?ws.op=find_recipes") HTTP/1.1 400 Bad Request ... search: Required input is missing. The error message may contain Unicode characters: >>> from lazr.restful.testing.helpers import encode_response >>> url = u"/cookbooks?ws.op=find_for_cuisine&cuisine=\N{SNOWMAN}" >>> response = webservice.get(url.encode("utf-8")) >>> print encode_response(response) HTTP/1.1 400 Bad Request ... cuisine: Invalid value "\u2603". Acceptable values are:... If a named operation takes an argument that's a value for a vocabulary (such as Cuisine in the example web service), the client can specify the name of the value, just as they would when changing the value with a PUT or PATCH request. >>> general_cookbooks = webservice.get( ... "/cookbooks?ws.op=find_for_cuisine&cuisine=General") >>> print general_cookbooks.jsonBody()['total_size'] 3 POST operations =============== A collection may also expose named operations in response to POST requests. These operations are usually factories. Here's a helper method that creates a new cookbook by invoking a factory operation on the collection of cookbooks. >>> def create_cookbook(name, cuisine, copyright_date, price=12.34): ... date = copyright_date.isoformat() ... return webservice.named_post( ... "/cookbooks", "create", {}, ... name=name, cuisine=cuisine, ... copyright_date=date, last_printing=date, price=price) >>> print webservice.get(quote('/cookbooks/The Cake Bible')) HTTP/1.1 404 Not Found ... >>> from datetime import date >>> print create_cookbook("The Cake Bible", "Dessert", date(1988, 1, 1)) HTTP/1.1 201 Created ... Location: http://.../cookbooks/The%20Cake%20Bible ... >>> print webservice.get("/cookbooks/The%20Cake%20Bible") HTTP/1.1 200 Ok ... POST operations can have custom validation. For instance, you can't create a cookbook with a name that's already in use. This exception is raised by the create() method itself. >>> print create_cookbook("The Cake Bible", "Dessert", date(1988, 1, 1)) HTTP/1.1 409 Conflict ... A cookbook called "The Cake Bible" already exists. A POST request has no meaning unless it specifies a custom operation. >>> print webservice.post("/cookbooks", 'text/plain', '') HTTP/1.1 400 Bad Request ... No operation name given. You can't invoke a nonexistent operation: >>> print webservice.named_post("/cookbooks", "nosuchop", {}) HTTP/1.1 400 Bad Request ... No such operation: nosuchop lazr.restful-0.19.3/src/lazr/restful/example/base/tests/test_integration.py0000644000175000017500000000263211631755356027344 0ustar benjibenji00000000000000# Copyright 2008 Canonical Ltd. All rights reserved. """Test harness for LAZR doctests.""" __metaclass__ = type __all__ = [] import os import doctest from pkg_resources import resource_filename from zope.configuration import xmlconfig from zope.testing.cleanup import cleanUp from van.testing.layer import zcml_layer, wsgi_intercept_layer from lazr.restful.example.base.root import CookbookServiceRootResource from lazr.restful.testing.webservice import ( WebServiceTestPublication, WebServiceApplication) class CookbookWebServiceTestPublication(WebServiceTestPublication): def getApplication(self, request): return CookbookServiceRootResource() DOCTEST_FLAGS = ( doctest.ELLIPSIS | doctest.NORMALIZE_WHITESPACE | doctest.REPORT_NDIFF) class FunctionalLayer: allow_teardown = False zcml = os.path.abspath(resource_filename('lazr.restful', 'ftesting.zcml')) zcml_layer(FunctionalLayer) class WSGILayer(FunctionalLayer): @classmethod def make_application(self): return WebServiceApplication({}, CookbookWebServiceTestPublication) wsgi_intercept_layer(WSGILayer) def additional_tests(): """See `zope.testing.testrunner`.""" tests = sorted( [name for name in os.listdir(os.path.dirname(__file__)) if name.endswith('.txt')]) suite = doctest.DocFileSuite(optionflags=DOCTEST_FLAGS, *tests) suite.layer = WSGILayer return suite lazr.restful-0.19.3/src/lazr/restful/example/base/tests/entry.txt0000644000175000017500000010570511631755356025317 0ustar benjibenji00000000000000Entries ******* Most objects published by a lazr.restful web service are entries: self-contained data structures with an independent existence from any other entry. Entries are distinguished from collections, which are groupings of entries. All entries in a web service work pretty much the same way. This document illustrates the general features of entries, using the example web service's dishes and recipes as examples. >>> from lazr.restful.testing.webservice import WebServiceCaller >>> webservice = WebServiceCaller(domain='cookbooks.dev') ======= Reading ======= It's possible to get a JSON 'representation' of an entry by sending a GET request to the entry's URL. Here we see that the cookbook 'Everyday Greens' is a vegetarian cookbook. >>> from urllib import quote >>> greens_url = quote("/cookbooks/Everyday Greens") >>> webservice.get(greens_url).jsonBody()['cuisine'] u'Vegetarian' Data is served encoded in UTF-8, and a good client will automatically convert it into Unicode. >>> construsions_url = quote("/cookbooks/Construsions un repas") >>> webservice.get(construsions_url).jsonBody()['cuisine'] u'Fran\xe7aise' Content negotiation =================== By varying the 'Accept' header, the client can request either a JSON or XHTML representation of an entry, or a WADL description of the entry's capabilities. >>> def negotiated_type(accept_header, ... uri='/cookbooks/Everyday%20Greens'): ... return webservice.get( ... uri, accept_header).getheader('Content-Type') >>> negotiated_type('application/json') 'application/json' >>> negotiated_type('application/xhtml+xml') 'application/xhtml+xml' >>> negotiated_type('application/vnd.sun.wadl+xml') 'application/vnd.sun.wadl+xml' >>> negotiated_type('') 'application/json' >>> negotiated_type('text/html') 'application/json' >>> negotiated_type('application/json, application/vnd.sun.wadl+xml') 'application/json' >>> negotiated_type('application/json, application/xhtml+xml') 'application/json' >>> negotiated_type('application/vnd.sun.wadl+xml, text/html, ' ... 'application/json') 'application/vnd.sun.wadl+xml' >>> negotiated_type('application/json;q=0.5, application/vnd.sun.wadl+xml') 'application/vnd.sun.wadl+xml' >>> negotiated_type('application/json;q=0, application/xhtml+xml;q=0.05,' ... 'application/vd.sun.wadl+xml;q=0.1') 'application/vd.sun.wadl+xml' The client can also set the 'ws.accept' query string variable, which will take precedence over any value set for the Accept header. >>> def qs_negotiated_type(query_string, header): ... uri = '/cookbooks/Everyday%20Greens?ws.accept=' + query_string ... return negotiated_type(header, uri) >>> qs_negotiated_type('application/json', '') 'application/json' >>> qs_negotiated_type('application/json', 'application/xhtml+xml') 'application/json' >>> negotiated_type('application/json;q=0, application/xhtml+xml;q=0.5,' ... 'application/json;q=0.5, application/xhtml+xml;q=0,') 'application/xhtml+xml' Earlier versions of lazr.restful served a misspelling of the WADL media type. For purposes of backwards compatibility, lazr.restful will still serve this media type if it's requested. >>> negotiated_type('application/vd.sun.wadl+xml') 'application/vd.sun.wadl+xml' XHTML representations ===================== Every entry has an XHTML representation. The default representation is a simple definition list. >>> print webservice.get(greens_url, 'application/xhtml+xml') HTTP/1.1 200 Ok ...
...
Getting the XHTML representation works correctly even when some of the fields have non-ascii values. >>> print webservice.get(construsions_url, 'application/xhtml+xml') HTTP/1.1 200 Ok ...
...
cuisine
Française
...
But it's possible to define a custom HTML view for a particular object type. Here's a simple view that serves some hard-coded HTML. >>> class DummyView: ... ... def __init__(*args): ... pass ... ... def __call__(*args): ... return "foo" Register the view as the IWebServiceClientRequest view for an ICookbook entry... >>> from lazr.restful.interfaces import IWebServiceClientRequest >>> from lazr.restful.example.base.interfaces import ICookbook >>> from zope.interface.interfaces import IInterface >>> view_name = "lazr.restful.EntryResource" >>> from zope.component import getGlobalSiteManager >>> manager = getGlobalSiteManager() >>> manager.registerAdapter( ... factory=DummyView, ... required=[ICookbook, IWebServiceClientRequest], ... provided=IInterface, name=view_name) ...and the XHTML representation of an ICookbook will be the result of calling a DummyView object. >>> print webservice.get(greens_url, 'application/xhtml+xml') HTTP/1.1 200 Ok ... foo Before we continue, here's some cleanup code to remove the custom view we just defined. >>> from zope.component import getGlobalSiteManager >>> ignored = getGlobalSiteManager().unregisterAdapter( ... factory=DummyView, ... required=[ICookbook, IWebServiceClientRequest], ... provided=IInterface, name=view_name) >>> print webservice.get(greens_url, 'application/xhtml+xml') HTTP/1.1 200 Ok ...
...
Visibility ========== There are two recipes in "James Beard's American Cookery", but one of them has been marked private. The private one cannot be retrieved. >>> print webservice.get('/recipes/3') HTTP/1.1 200 Ok ... >>> print webservice.get('/recipes/5') HTTP/1.1 401 Unauthorized ... If a resource itself is visible to the client, but it contains information that's not visible, the information will be redacted. Here, a cookbook resource is visible to our client, but its value for the 'confirmed' field is not visible. >>> cookbook = webservice.get(greens_url).jsonBody() >>> print cookbook['name'] Everyday Greens >>> print cookbook['confirmed'] tag:launchpad.net:2008:redacted Named operations ================ Some entries support custom operations through GET. The custom operation to be invoked is named in the query string's 'ws.op' argument. You can search a cookbook's recipes by specifying the 'find_recipes' operation. >>> joy_url = quote("/cookbooks/The Joy of Cooking") >>> recipes = webservice.get( ... "%s?ws.op=find_recipes&search=e" % joy_url).jsonBody() >>> sorted([r['self_link'] for r in recipes['entries']]) [u'.../recipes/2', u'.../recipes/4'] A named operation can take as an argument the URL to another object. Here the 'dish' argument is the URL to a dish, and the named operation finds a recipe for making that dish. >>> def find_recipe_in_joy(dish_url): ... """Look up a dish in 'The Joy of Cooking'.""" ... return webservice.get("%s?ws.op=find_recipe_for&dish=%s" % ... (joy_url, quote(dish_url))).jsonBody() >>> dish_url = webservice.get("/recipes/2").jsonBody()['dish_link'] >>> find_recipe_in_joy(dish_url)['instructions'] u'Draw, singe, stuff, and truss...' The URL passed in to a named operation may be an absolute URL, or it may be relative to the versioned service root. This is for developer convenience only, as lazr.restful never serves relative URLs. >>> print dish_url http://cookbooks.dev/devel/dishes/Roast%20chicken >>> relative_url = quote("/dishes/Roast chicken") >>> find_recipe_in_joy(relative_url)['instructions'] u'Draw, singe, stuff, and truss...' A URL relative to the unversioned service root will not work. >>> relative_url = quote("/devel/dishes/Roast chicken") >>> find_recipe_in_joy(relative_url) Traceback (most recent call last): ... ValueError: dish: No such object "/devel/dishes/Roast%20chicken". Some entries support custom operations through POST. You can invoke a custom operation to modify a cookbook's name, making it seem more interesting. >>> print webservice.get(joy_url).jsonBody()['cuisine'] General >>> print webservice.named_post(joy_url, 'make_more_interesting', {}) HTTP/1.1 200 Ok ... >>> new_joy_url = quote("/cookbooks/The New The Joy of Cooking") >>> print webservice.get(new_joy_url).jsonBody()['name'] The New The Joy of Cooking Custom operations may have error handling. >>> print webservice.named_post(new_joy_url, 'make_more_interesting', {}) HTTP/1.1 400 Bad Request ... The 'New' trick can't be used on this cookbook because its name already starts with 'The New'. >>> import simplejson >>> ignore = webservice.patch( ... new_joy_url, 'application/json', ... simplejson.dumps({"name": "The Joy of Cooking"})) Trying to invoke a nonexistent custom operation yields an error. >>> print webservice.get("%s?ws.op=no_such_operation" % joy_url) HTTP/1.1 400 Bad Request ... No such operation: no_such_operation ============ Modification ============ It's possible to modify an entry by sending to the server a document asserting what the entry should look like. The document may only describe part of the entry's new state, in which case the client should use the PATCH HTTP method. Or it may completely describe the entry's state, in which case the client should use PUT. >>> def modify_entry(url, representation, method, headers=None): ... "A helper function to PUT or PATCH an entry." ... new_headers = {'Content-type': 'application/json'} ... if headers is not None: ... new_headers.update(headers) ... return webservice( ... url, method, simplejson.dumps(representation), headers) >>> def modify_cookbook(cookbook, representation, method, headers=None): ... "A helper function to PUT or PATCH a cookbook." ... return modify_entry( ... '/cookbooks/' + quote(cookbook), representation, ... method, headers) Here we use the web service to change the cuisine of the "Everyday Greens" cookbook. The data returned is the new JSON representation of the object. >>> print webservice.get(greens_url).jsonBody()['revision_number'] 0 >>> result = modify_cookbook('Everyday Greens', {'cuisine' : 'American'}, ... 'PATCH') >>> print result HTTP/1.1 209 Content Returned ... Content-Type: application/json ... {...} >>> greens = result.jsonBody() >>> print greens['cuisine'] American Whenever a client modifies a cookbook, the revision_number is incremented behind the scenes. >>> print greens['revision_number'] 1 A modification may cause one of en entry's links to point to another object. Here, we change the 'dish_link' field of a roast chicken recipe, turning it into a recipe for baked beans. >>> old_dish = webservice.get("/recipes/1").jsonBody()['dish_link'] >>> print old_dish http://.../dishes/Roast%20chicken >>> new_dish = webservice.get("/recipes/4").jsonBody()['dish_link'] >>> print new_dish http://.../dishes/Baked%20beans >>> new_entry = modify_entry( ... "/recipes/2", {'dish_link' : new_dish}, 'PATCH').jsonBody() >>> print new_entry['dish_link'] http://.../dishes/Baked%20beans When changing one of an entry's links, you can use an absolute URL (as seen above) or a URL relative to the versioned service root. Let's use a relative URL to change the baked beans recipe back to a roast chicken recipe. >>> relative_old_dish = quote('/dishes/Roast chicken') >>> new_entry = modify_entry( ... "/recipes/2", {'dish_link' : relative_old_dish}, ... 'PATCH').jsonBody() >>> print new_entry['dish_link'] http://.../dishes/Roast%20chicken A modification might cause an entry's address to change. Here we use the web service to change the cookbook's name to 'Everyday Greens 2'. >>> print modify_cookbook('Everyday Greens', ... {'name' : 'Everyday Greens 2'}, 'PATCH') HTTP/1.1 301 Moved Permanently ... Location: http://.../Everyday%20Greens%202 ... At this point we can no longer manipulate this cookbook by sending HTTP requests to http://cookbooks.dev/1.0/cookbooks/Everyday%20Greens, because that cookbook now 'lives' at http://cookbooks.dev/1.0/cookbooks/Everyday%20Greens%202. To change the cookbook name back, we need to send a PATCH request to the new address. >>> print modify_cookbook('Everyday Greens 2', ... {'name' : 'Everyday Greens'}, 'PATCH') HTTP/1.1 301 Moved Permanently ... Location: http://.../cookbooks/Everyday%20Greens ... The PATCH HTTP method is useful for simple changes, but not all HTTP clients support PATCH. It's possible to fake a PATCH request with POST, by setting the X-HTTP-Method-Override header to "PATCH". Because Firefox 3 mangles the Content-Type header for POST requests, you may also set the X-Content-Type-Override header, which will override the value of Content-Type. >>> print modify_cookbook('Everyday Greens', ... {'cuisine' : 'General'}, 'POST', ... {'X-HTTP-Method-Override' : 'PATCH', ... 'Content-Type': 'not-a-valid-content/type', ... 'X-Content-Type-Override': 'application/json'}) HTTP/1.1 209 Content Returned ... Here, the use of a nonexistent HTTP method causes an error. >>> print modify_cookbook('Everyday Greens', ... {'cuisine' : 'General'}, 'POST', ... {'X-HTTP-Method-Override' : 'NOSUCHMETHOD'}) HTTP/1.1 405 Method Not Allowed ... X-HTTP-Method-Override is only respected when the underlying HTTP method is POST. If you use X-HTTP-Method-Override with any other HTTP method, your value is ignored. Here, a nonexistent HTTP method is ignored in favor of HTTP GET. >>> print webservice('/cookbooks/Everyday%20Greens', 'GET', ... headers={'X-HTTP-Method-Override' : 'NOSUCHMETHOD'}) HTTP/1.1 200 Ok ... Content-Type: application/json ... Even if a client supports PATCH, sometimes it's easier to GET a document, modify it, and send it back. If you have the full document at hand, you can use the PUT method. We happen to have a full document from when we sent a GET request to the 'Everday Greens' cookbook. Modifying that document and PUTting it back is less work than constructing a new document and sending it with PATCH. As with PATCH, a successful PUT serve the new representation of the object that was modified. >>> greens = webservice.get(greens_url).jsonBody() >>> print greens['cuisine'] General >>> greens['cuisine'] = 'Vegetarian' >>> print modify_cookbook('Everyday Greens', greens, 'PUT') HTTP/1.1 209 Content Returned ... {...} >>> greens = webservice.get(greens_url).jsonBody() >>> print greens['cuisine'] Vegetarian Because our patch format is the same as our representation format (a JSON hash), any document that works with a PUT request will also work with a PATCH request. >>> print modify_cookbook('Everyday Greens', greens, 'PATCH') HTTP/1.1 209 Content Returned ... Content negotiation during modification ======================================= When making a PATCH, you don't have to get a JSON representation back. You can also get an HTML representation. >>> print modify_cookbook('Everyday Greens', {}, 'PATCH', ... headers={'Accept': 'application/xhtml+xml'}) HTTP/1.1 209 Content Returned ... Content-Type: application/xhtml+xml ... ... You can even get a WADL representation, though that's pretty useless. >>> headers = {'Accept':'application/vd.sun.wadl+xml'} >>> print modify_cookbook('Everyday Greens', {}, 'PATCH', ... headers=headers) HTTP/1.1 209 Content Returned ... Content-Type: application/vd.sun.wadl+xml ... Server-side modification ======================== Sometimes the server will transparently modify a value sent by the client, to clean it up or put it into a canonical form. For this purpose, the response to a PUT or PATCH request includes a brand new JSON representation of the object, so that the client can know whether and which changes were made. Here's an example. If a cookbook's description contains leading or trailing whitespace, the whitespace will be stripped. >>> greens = webservice.get(greens_url).jsonBody() >>> greens['description'] u'' >>> first_etag = greens['http_etag'] Send in a name with leading or trailing whitespace and it'll be transparently trimmed. The document returned from the POST request will be the new representation, modified by both client and server. >>> greens = webservice( ... greens_url, "PATCH", ... simplejson.dumps({'description' : ' A description '}), ... {'Content-type': 'application/json'}).jsonBody() >>> greens['description'] u'A description' >>> greens['http_etag'] == first_etag False The canonicalization works for PUT requests as well. >>> greens['description'] = " Another description " >>> greens = webservice(greens_url, "PUT", simplejson.dumps(greens), ... {'Content-type': 'application/json'}).jsonBody() >>> greens['description'] u'Another description' Conditional GET, PUT and PATCH ============================== When you GET an entry you're given an ETag; an opaque string that changes whenever the entry changes. >>> response = webservice.get(greens_url) >>> greens_etag = response.getheader('ETag') >>> greens = response.jsonBody() The ETag is present in the HTTP response headers when you GET an entry, but it's also present in the representation of the entry itself. >>> greens['http_etag'] == greens_etag True This is so you can get the ETags for all the entries in a collection at once, without making a separate HTTP request for each. >>> cookbooks = webservice.get('/cookbooks').jsonBody() >>> etags = [book['http_etag'] for book in cookbooks['entries']] The ETag provided with an entry of a collection is the same as the ETag you'd get if you got that entry individually. >>> first_book = cookbooks['entries'][0] >>> first_book_2 = webservice.get(first_book['self_link']).jsonBody() >>> first_book['http_etag'] == first_book_2['http_etag'] True When you make a GET request, you can provide the ETag as the If-None-Match header. This lets you save time when the resource hasn't changed. >>> print webservice.get(greens_url, ... headers={'If-None-Match': greens_etag}) HTTP/1.1 304 Not Modified ... Conditional GET works the same way whether the request goes through the web service's virtual host or through the website-level interface designed for Ajax. >>> from lazr.restful.testing.webservice import WebServiceAjaxCaller >>> ajax = WebServiceAjaxCaller(domain='cookbooks.dev') >>> etag = 'dummy-etag' >>> response = ajax.get(greens_url, headers={'If-None-Match' : etag}) >>> etag = response.getheader("Etag") >>> print ajax.get(greens_url, headers={'If-None-Match' : etag}) HTTP/1.1 304 Not Modified ... When you make a PUT or PATCH request, you can provide the ETag as the If-Match header. This lets you detect changes that other people made to the entry, so your changes don't overwrite theirs. If the ETag you provide in If-Match matches the entry's current ETag, your request goes through. >>> print modify_cookbook('Everyday Greens', greens, 'PATCH', ... {'If-Match' : greens_etag}) HTTP/1.1 209 Content Returned ... If the ETags don't match, it's because somebody modified the entry after you got your copy of it. Your request will fail with status code 412. >>> print modify_cookbook('Everyday Greens', greens, 'PATCH', ... {'If-Match' : '"an-old-etag"'}) HTTP/1.1 412 Precondition Failed ... If you specify a number of ETags, and any of them match, your request will go through. >>> greens = webservice.get(greens_url).jsonBody() >>> match = '"an-old-etag", %s' % greens['http_etag'] >>> print modify_cookbook('Everyday Greens', greens, 'PATCH', ... {'If-Match' : match}) HTTP/1.1 209 Content Returned ... Both PUT and PATCH requests work this way. >>> print modify_cookbook('Everyday Greens', greens, 'PUT', ... {'If-Match' : 'an-old-etag'}) HTTP/1.1 412 Precondition Failed ... Conditional writes are a little more complicated ************************************************ OK, but consider the 'copyright_date' field of a cookbook. This is published as a read-only field; the client can't change it. But it's not read-only on the server side. What if this value changes on the server-side? What happens to the ETag then? Does it change, causing a conditional PATCH to fail, even if the PATCH doesn't touch that read-only field? >>> greens = webservice.get(greens_url).jsonBody() >>> print greens['copyright_date'] 2003-01-01 >>> etag_before_server_modification = greens['http_etag'] >>> from lazr.restful.example.base.root import C4 >>> greens_object = C4 >>> old_date = greens_object.copyright_date >>> old_date datetime.date(2003, 1, 1) Let's change the server-side value and find out. >>> import datetime >>> greens_object.copyright_date = datetime.date(2005, 12, 12) >>> new_greens = webservice.get(greens_url).jsonBody() >>> print new_greens['copyright_date'] 2005-12-12 >>> etag_after_server_modification = new_greens['http_etag'] The ETag has indeed changed. >>> etag_before_server_modification == etag_after_server_modification False So if we try to modify the cookbook using the old ETag, it should fail, right? >>> body = {'description' : 'New description.'} >>> print modify_cookbook('Everyday Greens', body, 'PATCH', ... {'If-Match' : etag_before_server_modification}) HTTP/1.1 209 Content Returned ... Actually, it succeeds! How does that work? Well, the ETag consists of two parts, separated by a dash. >>> read_before, write_before = etag_before_server_modification.split('-') >>> read_after, write_after = etag_after_server_modification.split('-') The first part of the ETag only changes when a field the client can't modify changes. This is the part of the ETag that changed when copyright_date changed. >>> read_before == read_after False The second part only changes when a field changes that the client can modify. This part of the ETag hasn't changed. >>> write_before == write_after True When you make a conditional write, the second part of the ETag you provide is checked against the second part of the ETag generated on the server. The first part of the ETag is ignored. The point of checking the ETag is to avoid conflicts where two clients modify the same resource. But no client can modify any of those read-only fields, so changes to them don't matter for purposes of avoiding conflicts. If you hack the ETag to something that's not "two parts, separated by a dash", lazr.restful will still handle it. (Of course, since that ETag will never match anything, you'll always get a 412 error.) >>> body = {'description' : 'New description.'} >>> print modify_cookbook('Everyday Greens', body, 'PATCH', ... {'If-Match' : "Weird etag"}) HTTP/1.1 412 Precondition Failed ... Conditional PUT fails where a PATCH would succeed, but not because lazr.restful rejects an old ETag. To verify this, let's change the cookbook's copyright_date again, behind the scenes. >>> greens = webservice.get(greens_url).jsonBody() >>> old_etag = greens['http_etag'] >>> greens_object.copyright_date = datetime.date(2005, 11, 11) A totally bogus ETag fails with a 412 error. >>> greens['description'] = 'Another new description' >>> print modify_cookbook('Everyday Greens', greens, 'PUT', ... {'If-Match' : "Not the old ETag"}) HTTP/1.1 412 Precondition Failed ... When we use the original ETag, we don't cause a 412 error, but the PUT request fails anyway. >>> print modify_cookbook('Everyday Greens', greens, 'PUT', ... {'If-Match' : old_etag}) HTTP/1.1 400 Bad Request ... http_etag: You tried to modify a read-only attribute. copyright_date: You tried to modify a read-only attribute. Rather, it's because a PUT request includes a representation of the entire resource, and lazr.restful thinks the client is trying to modify the fields that changed behind the scenes--in this case, copyright_date and http_etag. Conditional reads are *not* more complicated ******************************************** The point of the two-part ETag is to avoid spurious 412 errors when doing conditional writes. When making a conditional _read_ request, the condition will fail if _any_ part of the ETag is different. >>> new_etag = webservice.get(greens_url).jsonBody()['http_etag'] >>> print webservice.get( ... greens_url, ... headers={'If-None-Match': new_etag}) HTTP/1.1 304 Not Modified ... >>> print webservice.get( ... greens_url, ... headers={'If-None-Match': "changed" + new_etag}) HTTP/1.1 200 Ok ... lazr.restful checks the entire ETag on conditional GET because the purpose of a conditional read is to avoid getting data that hasn't changed. A server-side change to a read-only field like copyright_date doesn't affect future client writes, but it _is_ a change to the representation. A bit of cleanup: restore the old value for the cookbook's copyright_date. >>> greens_object.copyright_date = old_date Changing object relationships ============================= In addition to changing an object's data fields, you can change its relationships to other objects. Here we change which dish a recipe is for. >>> recipe_url = '/recipes/3' >>> recipe = webservice.get(recipe_url).jsonBody() >>> print recipe['dish_link'] http://.../dishes/Roast%20chicken >>> def modify_dish(url, recipe, new_dish_url): ... recipe['dish_link'] = new_dish_url ... return webservice.put( ... url, 'application/json', simplejson.dumps(recipe)) >>> new_dish = webservice.get(quote('/dishes/Baked beans')).jsonBody() >>> new_dish_url = new_dish['self_link'] >>> recipe['dish_link'] = new_dish_url >>> print modify_dish(recipe_url, recipe, new_dish_url) HTTP/1.1 209 Content Returned ... >>> recipe = webservice.get(recipe_url).jsonBody() >>> print recipe['dish_link'] http://.../dishes/Baked%20beans Identification of the dish is done by specifying a URL; a random string won't work. >>> print modify_dish(recipe_url, recipe, 'A random string') HTTP/1.1 400 Bad Request ... dish_link: "A random string" is not a valid URI. But not just any URL will do. It has to identify an object in the web service. >>> print modify_dish(recipe_url, recipe, 'http://www.canonical.com') HTTP/1.1 400 Bad Request ... dish_link: No such object "http://www.canonical.com". >>> print modify_dish( ... recipe_url, recipe, ... 'http://www.canonical.com/dishes/Baked%20beans') HTTP/1.1 400 Bad Request ... dish_link: No such object "http://www.canonical.com/dishes/Baked%20beans". This URL would be valid, but it uses the wrong protocol (HTTPS instead of HTTP). >>> https_link = recipe['dish_link'].replace('http:', 'https:') >>> print modify_dish(recipe_url, recipe, https_link) HTTP/1.1 400 Bad Request ... dish_link: No such object "https://.../Baked%20beans". Even a URL that identifies an object in the web service won't work, if the object isn't the right kind of object. A recipe must be for a dish, not a cookbook: >>> print modify_dish(recipe_url, recipe, recipe['cookbook_link']) HTTP/1.1 400 Bad Request ... dish_link: Your value points to the wrong kind of object Date formats ============ lazr.restful web services serve and parse dates in ISO 8601 format. Only UTC dates are allowed. The tests that follow make a number of PATCH requests that include values for a cookbook's 'copyright_date' attribute. >>> greens = webservice.get(greens_url).jsonBody() >>> greens['copyright_date'] u'2003-01-01' >>> def patch_greens_copyright_date(date): ... "A helper method to try and change a date field." ... return modify_cookbook( ... 'Everyday Greens', {'copyright_date' : date}, 'PATCH') These requests aren't actually trying to modify 'copyright_date', which is read-only. They're asserting that 'copyright_date' is a certain value. If the assertion succeeds (because 'copyright_date' does in fact have that value), the response code is 200. If the assertion could not be understood (because the date is in the wrong format), the response code is 400, and the body is an error message about the date format. If the assertion _fails_ (because 'copyright_date' happens to be read-only), the response code is also 400, but the error message talks about an attempt to modify a read-only attribute. The two 400 error codes below are caused by a failure to understand the assertion. The string used in the assertion might not be a date. >>> print patch_greens_copyright_date('dummy') HTTP/1.1 400 Bad Request ... copyright_date: Value doesn't look like a date. Or it might be a date that's not in UTC. >>> print patch_greens_copyright_date(u'2005-06-06T00:00:00.000000+05:00') HTTP/1.1 400 Bad Request ... copyright_date: Time not in UTC. There are five ways to specify UTC: >>> print patch_greens_copyright_date(u'2003-01-01T00:00:00.000000Z') HTTP/1.1 209 Content Returned ... >>> print patch_greens_copyright_date(u'2003-01-01T00:00:00.000000+00:00') HTTP/1.1 209 Content Returned ... >>> print patch_greens_copyright_date(u'2003-01-01T00:00:00.000000+0000') HTTP/1.1 209 Content Returned ... >>> print patch_greens_copyright_date(u'2003-01-01T00:00:00.000000-00:00') HTTP/1.1 209 Content Returned ... >>> print patch_greens_copyright_date(u'2003-01-01T00:00:00.000000-0000') HTTP/1.1 209 Content Returned ... A value with a missing timezone is treated as UTC. >>> print patch_greens_copyright_date(u'2003-01-01T00:00:00.000000') HTTP/1.1 209 Content Returned ... Less precise time measurements may also be acceptable. >>> print patch_greens_copyright_date(u'2003-01-01T00:00:00Z') HTTP/1.1 209 Content Returned ... >>> print patch_greens_copyright_date(u'2003-01-01') HTTP/1.1 209 Content Returned ... What you can't do ================= A document that would be acceptable as the payload of a PATCH request might not be acceptable as the payload of a PUT request. >>> print modify_cookbook('Everyday Greens', {'name' : 'Greens'}, 'PUT') HTTP/1.1 400 Bad Request ... You didn't specify a value for the attribute 'cuisine'. A document that's not a valid JSON document is also unacceptable. >>> print webservice.patch(greens_url, "application/json", "{") HTTP/1.1 400 Bad Request ... Entity-body was not a well-formed JSON document. A document that's valid JSON but is not a JSON hash is unacceptable. >>> print modify_cookbook('Everyday Greens', 'name=Greens', 'PATCH') HTTP/1.1 400 Bad Request ... Expected a JSON hash. An entry's read-only attributes can't be modified. >>> print modify_cookbook( ... 'Everyday Greens', ... {'copyright_date' : u'2001-01-01T01:01:01+00:00Z'}, 'PATCH') HTTP/1.1 400 Bad Request ... copyright_date: You tried to modify a read-only attribute. You can send a document that includes a value for a read-only attribute, but it has to be the same as the current value. >>> print modify_cookbook( ... 'Everyday Greens', ... {'copyright_date' : greens['copyright_date']}, 'PATCH') HTTP/1.1 209 Content Returned ... You can't change the link to an entry's associated collection. >>> print modify_cookbook('Everyday Greens', ... {'recipes_collection_link' : 'dummy'}, ... 'PATCH') HTTP/1.1 400 Bad Request ... recipes_collection_link: You tried to modify a collection... Again, you can send a document that includes a value for an associated collection link; you just can't _change_ the value. >>> print modify_cookbook( ... 'Everyday Greens', ... {'recipes_collection_link' : greens['recipes_collection_link']}, ... 'PATCH') HTTP/1.1 209 Content Returned ... You can't directly change an entry's URL address. >>> print modify_cookbook('Everyday Greens', ... {'self_link' : 'dummy'}, 'PATCH') HTTP/1.1 400 Bad Request ... self_link: You tried to modify a read-only attribute. You can't directly change an entry's ETag. >>> print modify_cookbook('Everyday Greens', ... {'http_etag' : 'dummy'}, 'PATCH') HTTP/1.1 400 Bad Request ... http_etag: You tried to modify a read-only attribute. You can't change an entry's resource type. >>> print modify_cookbook('Everyday Greens', ... {'resource_type_link' : 'dummy'}, 'PATCH') HTTP/1.1 400 Bad Request ... resource_type_link: You tried to modify a read-only attribute. You can't refer to a link to an associated object or collection as though it were the actual object. A cookbook has a 'recipes_collection_link', but it doesn't have 'recipes' directly. >>> print modify_cookbook( ... 'Everyday Greens', {'recipes' : 'dummy'}, 'PATCH') HTTP/1.1 400 Bad Request ... recipes: You tried to modify a nonexistent attribute. A recipe has a 'dish_link', but it doesn't have a 'dish' directly. >>> url = quote('/cookbooks/The Joy of Cooking/Roast chicken') >>> print webservice.patch(url, 'application/json', ... simplejson.dumps({'dish' : 'dummy'})) HTTP/1.1 400 Bad Request ... dish: You tried to modify a nonexistent attribute. You can't set values that violate data integrity rules. For instance, you can't set a required value to None. >>> print modify_cookbook('Everyday Greens', ... {'name' : None}, 'PATCH') HTTP/1.1 400 Bad Request ... name: Missing required value. And of course you can't modify attributes that don't exist. >>> print modify_cookbook( ... 'Everyday Greens', {'nonesuch' : 'dummy'}, 'PATCH') HTTP/1.1 400 Bad Request ... nonesuch: You tried to modify a nonexistent attribute. Deletion ======== Some entries may be deleted with the HTTP DELETE method. In the example web service, recipes can be deleted. >>> recipe_url = "/recipes/6" >>> print webservice.get(recipe_url) HTTP/1.1 200 Ok ... >>> print webservice.delete(recipe_url) HTTP/1.1 200 Ok ... >>> print webservice.get(recipe_url) HTTP/1.1 404 Not Found ... lazr.restful-0.19.3/src/lazr/restful/example/base/tests/traversal.txt0000644000175000017500000000144011631755356026150 0ustar benjibenji00000000000000traversal ********* The TraverseWithGet class makes it easy to implement IPublishTraverse just by defining a get() method like the one seen in Python's dict class. In fact, a dict will work fine. >>> from lazr.restful.example.base.traversal import TraverseWithGet >>> obj1, obj2 = object(), object() >>> context = {"name1":obj1, "name2":obj2} >>> request = object() >>> container = TraverseWithGet(context, request) Traversal is handled with the context's get() method. >>> value = container.publishTraverse(request, "name1") >>> value == obj1 True If the object cannot be found, publishTraverse raises a NotFound error. >>> container.publishTraverse(request, "nosuchname") Traceback (most recent call last): ... NotFound: ... name: 'nosuchname' lazr.restful-0.19.3/src/lazr/restful/example/base/tests/hostedfile.txt0000644000175000017500000001177611631755356026310 0ustar benjibenji00000000000000Hosted files ************ Some resources have binary files, usually images, associated with them. The Launchpad web service exposes these files as resources that respond to GET, PUT, and DELETE. The files themselves are managed by a service-specific backend implementation. >>> from lazr.restful.testing.webservice import WebServiceCaller >>> webservice = WebServiceCaller(domain='cookbooks.dev') ============== File resources ============== A cookbook starts out with a link to a cover image, but no actual cover. >>> from urllib import quote >>> greens_url = quote("/cookbooks/Everyday Greens") >>> greens = webservice.get(greens_url).jsonBody() >>> greens['cover_link'] u'http://.../cookbooks/Everyday%20Greens/cover' >>> greens_cover = greens['cover_link'] >>> print webservice.get(greens_cover) HTTP/1.1 404 Not Found ... We can upload a cover with PUT. >>> print webservice.put(greens_cover, 'image/png', ... "Pretend this is an image file.") HTTP/1.1 200 Ok ... Once the cover has been uploaded, we can GET it. The resource acts as a dispatcher pointing to the externally-hosted mugshot on the public Internet. >>> result = webservice.get(greens_cover) >>> print result HTTP/1.1 303 See Other ... Location: http://cookbooks.dev/.../filemanager/0 ... Files uploaded to the example web service are backed by a simple file manager that stores files and makes them available by number. A real web service will use some other scheme. This was the first file we ever uploaded, so it got the number zero. Here it is retrieved from the file manager. >>> filemanager_url = result.getheader('location') >>> response = webservice.get(filemanager_url) >>> print response HTTP/1.1 200 Ok ... Content-Type: image/png ... Pretend this is an image file. The simple file manager has some nice features like setting the Content-Disposition, Last-Modified, and ETag headers. >>> print response.getheader('Content-Disposition') attachment; filename="cover" Note that the name of the file is "cover", the same as the field whose value we set to the file. This is because we didn't specify a Content-Disposition header. >>> response.getHeader('Last-Modified') is None False >>> etag = response.getheader('ETag') Make a second request using the ETag, and you'll get the response code 304 ("Not Modified"). >>> print webservice.get(filemanager_url, ... headers={'If-None-Match': etag}) HTTP/1.1 304 Not Modified ... PUT is also used to modify a hosted file. Here's one that provides a filename as part of Content-Disposition. >>> print webservice.put(greens_cover, 'image/png', ... "Pretend this is another image file.", ... {'Content-Disposition': ... 'attachment; filename="greens-cover.png"'}) HTTP/1.1 200 Ok ... The new cover is available at a different URL. >>> result = webservice.get(greens_cover) >>> print result HTTP/1.1 303 See Other ... Location: http://cookbooks.dev/.../filemanager/1 ... When we GET that URL we see that the filename we provided is given back to us in the Content-Disposition header. >>> filemanager_url = result.getheader('location') >>> print webservice.get(filemanager_url) HTTP/1.1 200 Ok ... Content-Disposition: attachment; filename="greens-cover.png" ... The example web service also defines a named operation for setting a cookbook's cover. There's no real point to this, but it's common for a real web service to define a more complex named operation that manipulates uploaded files. >>> print webservice.named_post(greens_url, 'replace_cover', ... cover='\x01\x02') HTTP/1.1 200 Ok ... >>> print webservice.get(greens_cover) HTTP/1.1 303 See Other ... Location: http://cookbooks.dev/devel/filemanager/2 ... Deleting a cover (with DELETE) disables the redirect. >>> print webservice.delete(greens_cover) HTTP/1.1 200 Ok ... >>> print webservice.get(greens_cover) HTTP/1.1 404 Not Found ... ============== Error handling ============== You can't change a hosted file by PUTting to the URI of the entry that owns the file. >>> greens['cover_link'] = 'http://google.com/logo.png' >>> import simplejson >>> print webservice.put(greens_url, 'application/json', ... simplejson.dumps(greens)) HTTP/1.1 400 Bad Request ... cover_link: To modify this field you need to send a PUT request to its URI (http://.../cookbooks/Everyday%20Greens/cover). If a hosted file is read-only, the client won't be able to modify or delete it. >>> url = '/recipes/1/prepared_image' >>> print webservice.put(url, 'application/x-tar-gz', 'fakefiledata') HTTP/1.1 405 Method Not Allowed... Allow: GET ... >>> print webservice.delete(url) HTTP/1.1 405 Method Not Allowed... Allow: GET ... lazr.restful-0.19.3/src/lazr/restful/example/base/tests/field.txt0000644000175000017500000003401011631755356025227 0ustar benjibenji00000000000000Field resources *************** Each of an entry's fields has its own HTTP resource. If you only need to change one of an entry's fields, you can send PUT or PATCH to the field resource itself, rather than PUT/PATCH to the entry. >>> from lazr.restful.testing.webservice import WebServiceCaller >>> webservice = WebServiceCaller(domain='cookbooks.dev') >>> from urllib import quote >>> cookbook_url = quote("/cookbooks/The Joy of Cooking") >>> field_url = cookbook_url + "/description" >>> import simplejson >>> def set_description(description): ... """Sets the description for "The Joy of Cooking".""" ... return webservice(field_url, 'PATCH', ... simplejson.dumps(description)).jsonBody() >>> print set_description("New description") New description >>> print webservice.get(field_url) HTTP/1.1 200 Ok ... Content-Type: application/json ... "New description" PATCH on a field resource works identically to PUT. >>> representation = simplejson.dumps('Bold description') >>> print webservice.put(field_url, 'application/json', ... representation).jsonBody() Bold description If you get a field that contains a link to another object, you'll see the link, rather than the actual object. >>> link_field_url = "/recipes/3/cookbook_link" >>> print webservice.get(link_field_url).jsonBody() http://.../cookbooks/James%20Beard%27s%20American%20Cookery >>> collection_url = quote( ... "/cookbooks/The Joy of Cooking/recipes_collection_link") >>> print webservice.get(collection_url).jsonBody() http://.../cookbooks/The%20Joy%20of%20Cooking/recipes Changing a field resource that contains a link works the same way as changing a field resource that contains a scalar value. >>> new_value = simplejson.dumps( ... webservice.get(cookbook_url).jsonBody()['self_link']) >>> print new_value "http://.../cookbooks/The%20Joy%20of%20Cooking" >>> print webservice(link_field_url, 'PATCH', new_value) HTTP/1.1 209 Content Returned ... Content-Type: application/json ... "http://cookbooks.dev/.../cookbooks/The%20Joy%20of%20Cooking" The same rules for modifying a field apply whether you're modifying the entry as a whole or just modifying a single field. >>> date_field_url = cookbook_url + "/copyright_date" >>> print webservice.put(date_field_url, 'application/json', ... simplejson.dumps("string")) HTTP/1.1 400 Bad Request ... copyright_date: Value doesn't look like a date. >>> print webservice(collection_url, 'PATCH', new_value) HTTP/1.1 400 Bad Request ... recipes_collection_link: You tried to modify a collection attribute. Field resources also support GET, for when you only need part of an entry. You can get either a JSON or XHTML-fragment representation. >>> print webservice.get(field_url).jsonBody() Bold description >>> print webservice.get(field_url, 'application/xhtml+xml') HTTP/1.1 200 Ok ... Content-Type: application/xhtml+xml ... <b>Bold description</b> Cleanup. >>> ignored = set_description("Description") Changing a field resource can move the entry -------------------------------------------- If you modify a field that the entry uses as part of its URL (such as a cookbook's name), the field's URL will change. You'll be redirected to the new field URL. >>> name_url = cookbook_url + "/name" >>> representation = simplejson.dumps("The Joy of Cooking Extreme") >>> print webservice.put(name_url, 'application/json', ... representation) HTTP/1.1 301 Moved Permanently ... Location: http://.../cookbooks/The%20Joy%20of%20Cooking%20Extreme/name ... Note that the entry's URL has also changed. >>> print webservice.get(cookbook_url) HTTP/1.1 404 Not Found ... >>> new_cookbook_url = quote("/cookbooks/The Joy of Cooking Extreme") >>> print webservice.get(new_cookbook_url) HTTP/1.1 200 Ok ... Cleanup. >>> representation = simplejson.dumps("The Joy of Cooking") >>> new_name_url = new_cookbook_url + "/name" >>> print webservice.put(new_name_url, 'application/json', ... representation) HTTP/1.1 301 Moved Permanently ... Location: http://.../cookbooks/The%20Joy%20of%20Cooking/name ... Field resources can give more detail than entry resources ========================================================= An entry resource, and the field resource for one of the entry's fields, will display the same basic information. But the entry field can give a lot more detail. For instance, here's the representation of a cookbook's 'cuisine' field within the cookbook entry itself. >>> cookbook = webservice.get(cookbook_url).jsonBody() >>> print cookbook['cuisine'] General Here's the representation of the resource for the same 'cuisine' field. >>> for cuisine in sorted( ... webservice.get(cookbook_url + '/cuisine').jsonBody()): ... print(sorted(cuisine.items())) [(u'title', u'American'), (u'token', u'AMERICAN')] ... [(u'selected', True), (u'title', u'General'), (u'token', u'GENERAL')] The detailed representation includes information about the other values the 'status' field can take. This information is also published in the WADL file, but that's not easily accessible to some clients, especially JavaScript clients. XHTML representations don't work this way. Complex XHTML representations require custom code; see "Custom representations" below. By default, the XHTML representation of a field is a simple HTML-escaped string, similar to what's seen in the JSON representation of the entry. >>> print webservice.get(cookbook_url + '/cuisine', ... 'application/xhtml+xml') HTTP/1.1 200 Ok ... General ================= Supported methods ================= Field resources support GET, PUT, and PATCH. >>> for method in ['HEAD', 'POST', 'DELETE', 'OPTIONS']: ... print webservice(field_url, method) HTTP/1.1 405 Method Not Allowed... Allow: GET PUT PATCH ... HTTP/1.1 405 Method Not Allowed... Allow: GET PUT PATCH ... HTTP/1.1 405 Method Not Allowed... Allow: GET PUT PATCH ... HTTP/1.1 405 Method Not Allowed... Allow: GET PUT PATCH ... =============== Conditional GET =============== Field resources have ETags independent of their parent entries. They respond to conditional GET. >>> response = webservice.get(cookbook_url) >>> cookbook_etag = response.getheader('ETag') >>> response = webservice.get(field_url) >>> etag = response.getheader('ETag') >>> cookbook_etag == etag False >>> print webservice.get(field_url, headers={'If-None-Match': etag}) HTTP/1.1 304 Not Modified ... >>> ignored = set_description("new description") >>> print webservice.get(field_url, ... headers={'If-None-Match': etag}) HTTP/1.1 200 Ok ... ================= Conditional write ================= Every field supports conditional PUT and PATCH, just like the entries do. >>> response = webservice.get(field_url) >>> cookbook_etag = response.getheader('ETag') The first attempt to modify the field succeeds, because the ETag provided in If-Match is the one we just got from a GET request. >>> representation = simplejson.dumps("New description") >>> print webservice.put(field_url, 'application/json', ... representation, ... headers={'If-Match': cookbook_etag}) HTTP/1.1 209 Content Returned ... But when the field is modified, the ETag changes. Any subsequent requests that use that ETag in If-Match will fail. >>> print webservice.put(field_url, 'application/json', ... representation, ... headers={'If-Match': cookbook_etag}) HTTP/1.1 412 Precondition Failed ... >>> ignored = set_description("Description") ============================ Custom XHTML representations ============================ Every entry has an XHTML representation. The default representation is a simple text node. >>> print webservice.get(field_url, 'application/xhtml+xml') HTTP/1.1 200 Ok ... Description But it's possible to define a custom HTML renderer for a particular object and field type. Here's a simple renderer that bolds whatever value it's given. >>> from zope import component >>> from zope.interface import implementer >>> from zope.schema.interfaces import ITextLine >>> from lazr.restful.interfaces import ( ... IFieldHTMLRenderer, IWebServiceClientRequest) >>> from lazr.restful.example.base.interfaces import ICookbook >>> from lazr.restful.testing.webservice import simple_renderer >>> @component.adapter(ICookbook, ITextLine, IWebServiceClientRequest) ... @implementer(IFieldHTMLRenderer) ... def dummy_renderer(context, field, request): ... """Bold the original string and add a snowman.""" ... return simple_renderer >>> print webservice.get(cookbook_url +'/name', 'application/xhtml+xml') HTTP/1.1 200 Ok ... The Joy of Cooking Register the renderer as the IFieldHTMLRenderer adapter for an ITextLine field of an IPerson entry... >>> from zope.component import getGlobalSiteManager >>> manager = getGlobalSiteManager() >>> manager.registerAdapter(dummy_renderer) ...and the XHTML representation of an ICookbook's description will be the result of calling a dummy_renderer object. >>> from lazr.restful.testing.helpers import encode_response >>> response = webservice.get(field_url, 'application/xhtml+xml') >>> print encode_response(response) HTTP/1.1 200 Ok ... \u2603 Description In fact, that adapter will be used for every ITextLine field of an ICookbook. >>> print encode_response( ... webservice.get(cookbook_url +'/name', 'application/xhtml+xml')) HTTP/1.1 200 Ok ... The Joy of Cooking The adapter will not be used for ITextLine fields of other interfaces: >>> dish_field_url = quote('/dishes/Roast chicken/name') >>> print webservice.get(dish_field_url, 'application/xhtml+xml') HTTP/1.1 200 Ok ... Roast chicken It will not be used for non-text fields of ICookbook. >>> print webservice.get(cookbook_url + '/copyright_date', ... 'application/xhtml+xml') HTTP/1.1 200 Ok ... 1995-01-01 Combined JSON/HTML representations ---------------------------------- You can get a combined JSON/HTML representation of an entry by setting the "include=lp_html" parameter on the application/json media type. >>> response = webservice.get( ... cookbook_url, 'application/json;include=lp_html') >>> print response.getheader("Content-Type") application/json;include=lp_html The cookbook's description is a normal JSON representation... >>> json = response.jsonBody() >>> print json['description'] Description ...but the JSON dictionary will include a 'lp_html' sub-dictionary... >>> html = json['lp_html'] ...which includes HTML representations of the fields with HTML representations: >>> sorted(html.keys()) [u'description', u'name'] >>> from lazr.restful.testing.helpers import encode_unicode >>> print encode_unicode(html['description']) \u2603 Description >>> print encode_unicode(html['name']) \u2603 The Joy of Cooking Cleanup ------- Before we continue, here's some cleanup code to remove the custom renderer we just defined. >>> ignored = getGlobalSiteManager().unregisterAdapter(dummy_renderer) Compare the HTML generated by the custom renderer, to the XHTML generated now that the default adapter is back in place. >>> ignored = set_description("Bold description") >>> print webservice.get(field_url, 'application/xhtml+xml') HTTP/1.1 200 Ok ... <b>Bold description</b> >>> ignored = set_description("Description") The default renderer escapes HTML tags because it thinks they might contain XSS attacks. If you define a custom adapter, you can generate XHTML without worrying about the tags being escaped. The downside is that you're responsible for escaping user-entered HTML tags yourself to avoid XSS attacks. Defining a custom representation for a single field =================================================== It's also possible to define a custom HTML representation of one particular field, by registering a view on the field. This code creates a custom renderer for ICookbook.description, by registering a view on ICookbook called "description". >>> @component.adapter(ICookbook, ITextLine, IWebServiceClientRequest) ... @implementer(IFieldHTMLRenderer) ... def dummy_renderer(context, field, request): ... """Bold the original string, add a snowman, and encode UTF-8.""" ... def render(value): ... return (u"\N{SNOWMAN} %s" % value).encode("utf-8") ... return render >>> manager.registerAdapter(dummy_renderer, name='description') This renderer is identical to the one shown earlier, except that it returns UTF-8 instead of Unicode. >>> response = webservice.get(field_url, 'application/xhtml+xml') >>> print encode_response(response) HTTP/1.1 200 Ok ... \u2603 Description Unlike what happened when we registered a renderer for ICookbook/ITextLine, other ITextLine fields of ICookbook are not affected. >>> print webservice.get(cookbook_url + '/name', 'application/xhtml+xml') HTTP/1.1 200 Ok ... The Joy of Cooking The XHTML representation of an entry incorporates any custom XHTML representations of that entry's fields. >>> response = webservice.get(cookbook_url, 'application/xhtml+xml') >>> print encode_response(response) HTTP/1.1 200 Ok ...
description
\u2603 Description
... Before we continue, here's some code to unregister the view. >>> ignored = getGlobalSiteManager().unregisterAdapter( ... dummy_renderer, name='description') >>> print webservice.get(field_url, 'application/xhtml+xml') HTTP/1.1 200 Ok ... Description lazr.restful-0.19.3/src/lazr/restful/example/base/tests/wadl.txt0000644000175000017500000012321711631755356025103 0ustar benjibenji00000000000000lazr.restful's WADL documents ***************************** Every resource in the web service has a WADL representation that describes the capabilities of the resource in a machine-readable format. These documents are similar to the HTML documents that provide human beings with links to click and forms to fill out. Entry resources =============== Let's get a WADL representation of an entry resource (in this case, a cookbook), and see what's inside. >>> from urllib import quote >>> from lazr.restful.testing.webservice import WebServiceCaller >>> webservice = WebServiceCaller(domain='cookbooks.dev') >>> entry_url = quote("/cookbooks/The Joy of Cooking") >>> wadl = webservice.get( ... entry_url, 'application/vnd.sun.wadl+xml').body It's an XML document. >>> print wadl ... Let's parse it and make sure it validates against the WADL schema. >>> from lxml import etree >>> from lazr.restful import WADL_SCHEMA_FILE >>> wadl_schema = etree.XMLSchema(etree.parse(open(WADL_SCHEMA_FILE))) >>> from StringIO import StringIO >>> def validate(body): ... res = wadl_schema.validate( ... etree.parse(StringIO(body.decode('utf8')))) ... if not res: ... res = (res, wadl_schema.error_log) ... return res ... >>> validate(wadl) True >>> tree = etree.fromstring(wadl) The root tag of any WADL document is called 'application'. Note the namespaced tag name; this test will be ignoring the namespace from this point on. >>> tree.tag '{http://research.sun.com/wadl/2006/10}application' >>> def wadl_tag(tag): ... """Return a WADL tag name properly namespace-qualified.""" ... return '{http://research.sun.com/wadl/2006/10}%s' % tag The tag for a WADL representation of an entry contains a single tag. >>> def single_list_value(single_item_list): ... msg = "Expected 1 value, but received %s" % len(single_item_list) ... assert len(single_item_list) == 1, msg ... return single_item_list[0] >>> resources = single_list_value(tree) >>> resources.tag '...resources' The tag describes a set of resources rooted at the person's URL. >>> resources.attrib['base'] 'http://.../The%20Joy%20of%20Cooking' The tag contains a single tag. The WADL description of a person describes only one resource: the person itself. >>> resource = single_list_value(resources) >>> resource.tag '...resource' >>> resource.attrib['path'] '' What are the capabilities of this resource? >>> resource.attrib['type'] 'http://...#cookbook' Since all cookbook resources work the same way, the capabilities of any particular cookbook resource are described by reference to a separate WADL document. This document is located at the service root (/), and the part of the document that describes a person resource has the XML id "cookbook". We'll look at this document later. To summarize: the WADL representation of an entry is a very short XML document that says "The resource at this URL is of type [foo]," where [foo] is a reference to another WADL document. Collection resources ==================== The WADL description of a collection looks a lot like the WADL description of an entry. It's an tag that contains a tag that contains a tag. >>> wadl = webservice.get('/cookbooks', ... 'application/vnd.sun.wadl+xml').body >>> validate(wadl) True >>> tree = etree.fromstring(wadl) >>> tree.tag '...application' >>> resources = single_list_value(tree) >>> resources.tag '...resources' >>> resources.attrib['base'] 'http://.../cookbooks' The tag defines the capabilities of the collection resource by referencing another WADL document. >>> resource = single_list_value(resources) >>> resource.tag '...resource' >>> resource.attrib['path'] '' >>> resource.attrib['type'] 'http://...#cookbooks' Scoped collection resources =========================== The WADL representation of a scoped collection is pretty similar to the representation of a top-level collection. >>> wadl = webservice.get(entry_url + '/recipes', ... 'application/vnd.sun.wadl+xml').body >>> validate(wadl) True >>> tree = etree.fromstring(wadl) >>> tree.tag '...application' >>> resources = single_list_value(tree) >>> resources.tag '...resources' >>> resources.attrib['base'] 'http://.../cookbooks/The%20Joy%20of%20Cooking/recipes' >>> resource = single_list_value(resources) >>> resource.attrib['path'] '' >>> resource.attrib['type'] 'http://...#recipe-page-resource' Hosted file resources ===================== A hosted file resource is the web service's access point for a file (usually a binary) hosted elsewhere. The most prominent hosted file in the example web service is a cookbook's cover image. A hosted file resource responds to GET requests by redirecting the client to the externally hosted file. It responds to PUT or DELETE requests by updating or deleting the underlying binary file. The behavior laid out in the previous paragraph is also defined in the WADL documents served by the web service itself. You can use content negotiation to get a WADL description of any hosted file resource. A cookbook starts out with no hosted cover, and an ordinary GET to the place where the cover would be results in a 404 error. >>> print webservice.get(entry_url + '/cover') HTTP/1.1 404 Not Found ... But if the client is interested in learning how to create a cover at /cookbooks/Everyday%20Greens/cover, it can use content negotiation to get a WADL description of the resource-to-be. >>> wadl = webservice.get( ... entry_url + '/cover', ... 'application/vnd.sun.wadl+xml').body >>> validate(wadl) True >>> tree = etree.fromstring(wadl) Just like with the WADL description of an entry or collection resource, the WADL representation of a hosted file resource is not much more than a reference to a big WADL file at the service root. To find out about the capabilities of the resource-to-be, the client needs to fetch the WADL representation of the service root and look for the XML element with the ID "HostedFile". >>> tree.tag '...application' >>> resources = single_list_value(tree) >>> resources.tag '...resources' >>> resources.attrib['base'] 'http://.../The%20Joy%20of%20Cooking/cover' >>> resource = single_list_value(resources) >>> resource.tag '...resource' >>> resource.attrib['path'] '' >>> resource.attrib['type'] 'http://...#HostedFile' The service root ================ All the WADL documents we've seen so far have been very simple, describing a single resource and describing it by reference to another WADL document. That other document is the WADL representation of the service root. It describes the capabilities of every kind of resource the service offers. It also describes the JSON representations those resources serve, and any custom methods invokable on those resources. It's a big document. >>> wadl = webservice.get( ... '/', 'application/vnd.sun.wadl+xml').body >>> validate(wadl) True >>> tree = etree.fromstring(wadl) Like all WADL documents, the WADL representation of the service root is rooted at an tag. >>> tree.tag '...application' As with the WADL descriptions of collection and entry resources, this tag describes the capabilities of the server root resource. But that tag also contains all the information referenced by all the other WADL documents in the system. #PersonEntry is here, as is #people and #ScopedCollection and many other targets of a tag's 'resource_type' attribute. Also located here are the descriptions of the JSON representations those resources serve. Description of the full service -------------------------------- The first two tags in the WADL file are human-readable documentation for the entire web service. >>> from lxml.etree import _Comment >>> children = [child for child in tree ... if not isinstance(child, _Comment)] >>> service_doc, version_doc = children[:2] >>> print service_doc.attrib['title'] About this service >>> for p_tag in service_doc: ... print p_tag.text This is a web service. It's got resources! >>> print version_doc.attrib['title'] About version devel >>> for p_tag in version_doc: ... print p_tag.text The unstable development version. Don't use this unless you like changing things. Description of the service root itself -------------------------------------- Let's whittle down the complexity a little more by taking a look at the description of the service root resource itself. >>> resources, service_root_type, service_root_repr = children[2:5] >>> # We'll deal with the rest of the children later. >>> other_children = children[5:] The service root is an instance of a special resource type that responds only to GET. >>> service_root_type.tag '...resource_type' >>> service_root_type.attrib['id'] 'service-root' >>> (doc, get) = list(service_root_type) >>> get.tag '...method' >>> get.attrib['name'] 'GET' A client that makes a GET request to a resource of this type can get a representation of type 'service-root-json', or it can get a WADL representation. The WADL representation is the document we're looking at right now. >>> response = single_list_value(get) >>> json_repr, wadl_repr = list(response) >>> json_repr.attrib['href'] '#service-root-json' >>> wadl_repr.attrib['mediaType'] 'application/vnd.sun.wadl+xml' The details of the 'service-root-json' representation are given immediately afterwards: >>> service_root_repr.tag '...representation' >>> service_root_repr.attrib['id'] 'service-root-json' >>> service_root_repr.attrib['mediaType'] 'application/json' The JSON representation contains a link for each collection resource exposed at the top level. The WADL representation has a tag for every link in the JSON representation. >>> def name_attrib(tag): ... return tag.attrib['name'] >>> params = sorted([param for param in service_root_repr], ... key=name_attrib) >>> (cookbook_set_param, dish_set_param, featured_cookbook_param, ... recipes_param, resource_type_param) = params >>> cookbook_set_param.tag '...param' >>> cookbook_set_param.attrib['name'] 'cookbooks_collection_link' >>> cookbook_set_param.attrib['path'] "$['cookbooks_collection_link']" >>> featured_cookbook_param.tag '...param' >>> featured_cookbook_param.attrib['name'] 'featured_cookbook_link' >>> featured_cookbook_param.attrib['path'] "$['featured_cookbook_link']" >>> resource_type_param.attrib['name'] 'resource_type_link' >>> resource_type_param.attrib['path'] "$['resource_type_link']" This is saying that applying the JSONPath expression '$['cookbooks_collection_link']' to the JSON document will give you something interesting: a variable we like to call 'cookbooks_collection_link'. Applying the JSONPath expression '$['featured_cookbook_link']' will give you a variable we like to call 'featured_cookbook_link'. What's special about these variables? Well, they're links to other resources. This is represented in WADL by giving the tag a child tag. The tag explains what sort of resource is at the other end of the link. >>> cookbook_set_param_link = single_list_value(cookbook_set_param) >>> cookbook_set_param_link.tag '...link' >>> cookbook_set_param_link.attrib['resource_type'] 'http://...#cookbooks' >>> featured_cookbook_param_link = single_list_value( ... featured_cookbook_param) >>> featured_cookbook_param_link.tag '...link' >>> featured_cookbook_param_link.attrib['resource_type'] 'http://...#cookbook' A WADL client can read this and know that if it ever finds a resource of type "service-root", it can send a GET request to that URL and get back a JSON document. By applying the JSON expression '["cookbooks_collection_link"]' to that document, it will find the URL to the list of cookbooks. By applying the JSON expression '["featured_cookbook_collection_link"]' the client can find a URL that will point to the currently featured cookbook. The list of cookbooks has the characteristics described (in WADL format) at http://...#cookbooks. A single cookbook has the characteristics described at http://...#cookbook. But where is the WADL client going to find a resource of type "service-root" in the first place? The tag will explain everything. >>> resources.tag '...resources' >>> print resources.attrib['base'] http://cookbooks.dev/devel/ As with the tags shown earlier, this one contains a single tag. >>> resource = single_list_value(resources) >>> resource.tag '...resource' >>> resource.attrib['path'] '' >>> resource.attrib['type'] '#service-root' Aha! There's a resource of type "service-root" right there at the service root! Who would have thought? The rest of the document ======================== Looking at the children of the tag can be overwhelming. The description of the service root resource is relatively simple, but then there are a huge number of and tags that don't have anything to do directly with the service root: they form a description of the web service as a whole. I'll explain the large-scale structure of the document and then show some examples in detail. The first few tags are tags which describe the different possible kinds of top-level collections (cookbook collections, dish collections, and so on). We'll look at those tags first. Each type of collection resource has one tag devoted to it in the WADL document: 1. A tag describing the HTTP requests a client can make to manipulate the collection resource. It might look like this:: A collection of cookbooks, as exposed through the web service. We know we've reached the end of the collection resources when we see a tag that supports the PUT method. Collections never support PUT, so that must be an entry resource_type. >>> collection_resource_types = [] >>> for index, child in enumerate(other_children): ... put_method = [method for method in list(child) ... if method.tag.endswith("method") ... and method.attrib['name'] == 'PUT'] ... if len(put_method) > 0: ... first_entry_type_index = index ... break ... collection_resource_types.append(child) When we find a that supports PUT, we know that we've come to the end of the collection resources and reached an entry resource, like a recipe or cookbook. Each kind of entry resource has five tags devoted to it in the WADL document: 1. A tag describing the HTTP requests a client can make to manipulate the entry resource. It might look like this:: 2. A tag describing the document sent to the client in response to a GET request for the entry, and expected from the client along with a PUT request. This would be the "#message-full" referenced in the above WADL example. 3. A tag describing the document expected from the server along with a PATCH request. This would be the "#message-diff" referenced in the above WADL example. 4. A tag describing the capabilities of a page of a collection that contains this kind of entry. Some entry types may have both a top-level collection and a regular collection page . They need to be declared separately because top-level collections can have custom operations, and random collection pages can't. It might look like this:: 5. A tag describing the document sent to the client in response to a GET request for a collection. This includes top-level collections as well as scoped and other collections. This would be the "#message-page" referenced in the above WADL example, and the "#cookbook-page" referenced in the very first example. (The last tag is a hard-coded description of a hosted binary file resource. Since it's hard-coded, this test doesn't deal with it.) This code splits the tags into 5-tuples and stores the tuples in 'entry_resource_descriptions'. >>> entry_resource_descriptions = [] >>> entry_resource_types = other_children[first_entry_type_index:-3] >>> (hosted_binary_resource_type, scalar_type, simple_binary_type ... ) = other_children[-3:] >>> for index in range(0, len(entry_resource_types), 5): ... entry_resource_descriptions.append( ... (tuple(entry_resource_types[index:index + 5]))) Before looking at the descriptions of entry resources, we'll examine the collection resource types. ========================== A collection resource type ========================== First let's look at the different collection resource types defined in the WADL file, and their representations. >>> sorted([type.attrib['id'] for type in collection_resource_types]) ['cookbooks', 'dishes', 'recipes'] There's one tag for every top-level collection on the site. We'll be taking a close look at the resource type for the site's collection of cookbooks. >>> resource_type = single_list_value([ ... type for type in collection_resource_types ... if type.attrib['id'] == 'cookbooks']) >>> resource_type.tag '...resource_type' Any collection resource supports one standard HTTP method (GET) in addition to any custom operations. This particular collection resource is the top-level list of cookbooks, and it exposes a few other custom operations. >>> methods = resource_type.findall(wadl_tag('method')) >>> sorted((method.attrib['name'], method.attrib['id']) ... for method in methods) [('GET', 'cookbooks-find_for_cuisine'), ('GET', 'cookbooks-find_recipes'), ('GET', 'cookbooks-get'), ('POST', 'cookbooks-create')] >>> (create, find_for_cuisine, find_recipes, get) = sorted( ... methods, key=lambda i: i.attrib['id']) The standard GET operation is a way of getting either a page of the collection (see "The representation of a collection" later in this test) or else a short WADL description of a particular collection, like the one shown earlier in this test. >>> response = single_list_value(get) >>> json_rep, wadl_rep = list(response) >>> json_rep.attrib['href'] 'http://...#cookbook-page' >>> wadl_rep.attrib['mediaType'] 'application/vnd.sun.wadl+xml' >>> wadl_rep.attrib['id'] 'cookbooks-wadl' Operation parameters ==================== The tag for a named operation will contain one or more tags describing the parameters the operation accepts. The 'create' custom operation takes a number of parameters. >>> create_request = create.find(wadl_tag('request')) >>> create_response = create.find(wadl_tag('response')) >>> create_representation = single_list_value(create_request) >>> sorted([param.attrib['name'] for param in create_representation]) ['copyright_date', 'cuisine', 'description', 'last_printing', 'name', 'price', 'ws.op'] The 'ws.op' parameter is present for every named operation, and has a fixed value: the name of the operation. >>> ws_op = create_representation[0] >>> ws_op.attrib['fixed'] 'create' The 'create' custom operation also defines a return value. When you invoke that operation the Location header of the response is a link to the newly created cookbook. >>> create_response_param = single_list_value(create_response) >>> create_response_param.attrib['style'] 'header' >>> create_response_param.attrib['name'] 'Location' >>> create_response_param_link = single_list_value( ... create_response_param) >>> create_response_param_link.attrib['resource_type'] 'http://...#cookbook' The 'find_recipes' custom operation accepts one parameter in addition to 'ws.op'. >>> find_request = find_recipes.find(wadl_tag('request')) >>> find_response = find_recipes.find(wadl_tag('response')) >>> [param.attrib['name'] for param in find_request] ['ws.op', 'vegetarian', 'search'] >>> ws_op = find_request[0] >>> ws_op.attrib['fixed'] 'find_recipes' The 'find_recipes' custom operation returns a collection of recipes. >>> find_response_representation = single_list_value(find_response) >>> find_response_representation.attrib['href'] 'http://...#recipe-page' ====================== An entry resource type ====================== Now let's look at how the service root WADL describes entry resources. >>> sorted([entry[0].attrib['id'] ... for entry in entry_resource_descriptions]) ['cookbook', 'cookbook_subclass', 'dish', 'recipe'] There's one tag for every kind of entry on the site. Let's take a close look at the WADL description of a cookbook. >>> cookbook_description = [entry for entry in entry_resource_descriptions ... if entry[0].attrib['id'] == 'cookbook'][0] >>> (entry_type, full_rep, diff_rep, ... collection_type, collection_rep) = cookbook_description ===================== Entry representations ===================== The definition of a cookbook is contained in a tag and two tags. First lets look at the two representations. Each tag specifies what media type it's describing. Both of these tags describe JSON documents. >>> full_rep.tag '...representation' >>> full_rep.attrib['mediaType'] 'application/json' >>> full_rep.attrib['id'] 'cookbook-full' >>> diff_rep.tag '...representation' >>> diff_rep.attrib['mediaType'] 'application/json' >>> diff_rep.attrib['id'] 'cookbook-diff' Representation parameters ========================= An earlier section showed how tags could contain tags describing the parameters to a custom operation. A tag may also contain tags that point clients to interesting parts of the representation. Our JSON representations of entries are hashes, and we've chosen to specify a parameter for each key of the hash. >>> full_params = list(full_rep) >>> full_params[0].tag '...param' >>> full_rep_names = sorted([param.attrib['name'] ... for param in full_params]) >>> full_rep_names ['confirmed', 'copyright_date', ..., 'self_link', 'web_link'] In addition to a name, each representation parameter has a 'path', a JSONPath expression you can apply to the JSON data structure to get the parameter's value. >>> sorted([param.attrib['path'] for param in full_params]) [..., "$['cuisine']", ... "$['self_link']", "$['web_link']"] So to get the cuisine out of a JSON data structure you need to look up the element called "cuisine". Just as with operation parameters, a representation parameter may have an optional data type. >>> copyright_date = single_list_value( ... [param for param in full_params ... if param.attrib['name'] == 'copyright_date']) >>> copyright_date.attrib['type'] 'xsd:date' As with operation parameters, the default is "xsd:string". >>> cuisine = single_list_value([param for param in full_params ... if param.attrib['name'] == 'cuisine']) >>> 'type' in cuisine.attrib False A parameter may also have human-readable documentation: an optional name for the parameter and an optional short description. >>> doc = copyright_date[0] >>> doc.tag '...doc' >>> print "\n".join([node.text for node in doc]) Copyright Date The copyright date for this work. Every entry resource has the 'http_etag' parameter, which includes the current value used in the 'ETag' HTTP header for the entry. >>> [etag] = [param for param in full_params ... if param.attrib['name'] == 'http_etag'] >>> etag.attrib['path'] "$['http_etag']" Some parameters are links to other resources. These parameters have a child tag called 'link' with information about what's on the other end of the link. >>> recipes = single_list_value([ ... param for param in full_params ... if param.attrib['name'] == 'recipes_collection_link']) >>> doc, recipes_link = recipes.getchildren() >>> recipes_link.tag '...link' The link's 'resource_type' attribute tells the client about the capabilities of the resource at the other end of the link. For example, the list of a cookbook's recipes is a collection of recipes. >>> recipes_link.attrib['resource_type'] 'http://...#recipe-page-resource' A link parameter may also link to one specific resource. For instance, each recipe links to one dish. This is represented in WADL by a 'dish_link' parameter. >>> recipe_description = [entry for entry in entry_resource_descriptions ... if entry[0].attrib['id'] == 'recipe'][0] >>> recipe_params = list(recipe_description)[1] >>> [dish_param] = [param for param in recipe_params ... if param.attrib['name'] == 'dish_link'] >>> dish_link = dish_param[1] >>> dish_link.tag '...link' The dish_link's 'resource_type' indicates that the thing on the other end of the link is a dish. >>> dish_link.attrib['resource_type'] 'http://...#dish' The full representation contains all fields, even read-only ones, because it's describing the document you receive when you make a GET request. You can modify such a document and send it back with PUT, so the full representation also suffices to describe the documents you PUT. But you can't send values for read-only fields with PATCH, so we define a second representation for use with PATCH requests. >>> diff_params = list(diff_rep) >>> diff_params[0].tag '...param' >>> diff_rep_names = sorted([param.attrib['name'] ... for param in diff_params]) >>> 'self_link' in full_rep_names True >>> 'self_link' in diff_rep_names False >>> 'resource_type_link' in full_rep_names True >>> 'resource_type_link' in diff_rep_names False >>> 'recipes_collection_link' in full_rep_names True >>> 'recipes_collection_link' in diff_rep_names False Fields that have server-side mutators are considered read-write fields, and show up in the PATCH representation. >>> 'cuisine' in diff_rep_names True Fields marked read-only show up in the PUT representation but not the PATCH representation. >>> 'copyright_date' in full_rep_names True >>> 'copyright_date' in diff_rep_names False Most tags don't tell the client much beyond where to find a certain parameter, but some of our parameters take values from a proscribed vocabulary. These parameters correspond to Choice fields who take their vocabulary from an EnumeratedValue. For these parameters, the server provides the client with information about the possible values. >>> cuisine_param = single_list_value( ... [param for param in full_params ... if param.attrib['name'] == 'cuisine']) >>> cuisine_options = cuisine_param.getchildren() >>> cuisine_options[0].tag '...doc' >>> cuisine_options[1].tag '...option' >>> sorted(status.attrib['value'] for status in cuisine_options[1:]) ['American', ... 'General', 'Vegetarian'] Parameter data types ==================== Operation and representation parameters are always transfered between client and server as strings. But a parameter may have a logical data type which indicates how to treat the string. Data types are specified using the primitive types from the XML Schema standard. Here's a parameter of type 'date'. >>> copyright_date = single_list_value( ... [param for param in create_representation ... if param.attrib.get('name') == 'copyright_date']) >>> copyright_date.attrib['type'] 'xsd:date' Here's a parameter of type 'binary'. >>> cover = single_list_value( ... [param for param in full_rep ... if param.attrib.get('name') == 'cover_link']) >>> cover.attrib['type'] 'binary' If no data type is provided, the WADL standard says to default to "xsd:string". This is the case for the vast majority of parameters. >>> cuisine = single_list_value( ... [param for param in create_representation ... if param.attrib.get('name') == 'cuisine']) >>> 'type' in cuisine.attrib False If a parameter represents a choice among several options, it might be described with a list of valid values. The "cuisine" parameter is a choice among several preset values, and the WADL describes those values. >>> values = cuisine[1:] >>> sorted([value.attrib['value'] for value in values]) ['American', ... 'Vegetarian'] Here's the same field for a named operation. >>> doc, request, response = list(find_for_cuisine) >>> ws_op, cuisine = list(request) >>> values = cuisine[1:] >>> sorted([value.attrib['value'] for value in values]) ['American', ... 'Vegetarian'] The entry resource type itself ============================== The 'representation' tags tell you what representations a resource sends and receives. In this case the representations are for a 'bugtask' resource. What about the resource itself? All bug tasks are pretty much the same, and so most of the information about a bug task is kept in the tag. >>> entry_type.tag '...resource_type' >>> def sort_by_id(method): ... return method.attrib['id'] >>> methods = sorted( ... entry_type.findall(wadl_tag('method')), key=sort_by_id) >>> (findRecipeFor, findRecipes, get, makeMoreInteresting, patch, ... put, replace_cover) = methods A resource type tells the client about the four standard operations on a resource (GET, PUT, PATCH, and possibly DELETE), as well as any custom GET or POST operations. The name of a method is always the HTTP method used to activate it. Different operations that use the same method are distinguished by XML id. Here that's not a problem; we have one custom POST method ('bug_task-transitionToStatus'), plus the standard GET, PATCH, and PUT. >>> sorted((method.attrib['name'], method.attrib['id']) ... for method in methods) [('GET', 'cookbook-find_recipe_for'), ('GET', 'cookbook-find_recipes'), ('GET', 'cookbook-get'), ('PATCH', 'cookbook-patch'), ('POST', 'cookbook-make_more_interesting'), ('POST', 'cookbook-replace_cover'), ('PUT', 'cookbook-put')] Standard entry operations ========================= The standard GET operation defines a 'response' tag, which tells the client that they can use content negotiation to get three different representations of a bug task: the JSON one described above as "BugTask-full", an XHTML snippet, and a WADL document--the sort of document shown in the very first section of this test. >>> response = get.find(wadl_tag('response')) >>> response.tag '...response' >>> full, html, wadl = list(response) >>> full.attrib['href'] 'http://...#cookbook-full' >>> html.attrib['id'] 'cookbook-xhtml' >>> html.attrib['mediaType'] 'application/xhtml+xml' >>> wadl.attrib['id'] 'cookbook-wadl' >>> wadl.attrib['mediaType'] 'application/vnd.sun.wadl+xml' Note that the JSON representation is just a hyperlink to the representation defined earlier. Similarly, the standard PUT and PATCH methods each include a 'request' tag, which tells the client which representation it should send along with a request. >>> request = single_list_value(put) >>> request.tag '...request' >>> representation = single_list_value(request) >>> representation.attrib['href'] 'http://...#cookbook-full' >>> response = single_list_value(patch) >>> representation = single_list_value(response) >>> representation.attrib['href'] 'http://...#cookbook-diff' This is why we defined the representations separately. Now we can link to them instead of describing them every time. Some entries support DELETE. In this example, cookbook entries can't be deleted, but recipe entries can. >>> recipe_description = [entry for entry in entry_resource_descriptions ... if entry[0].attrib['id'] == 'recipe'][0] >>> recipe_type = recipe_description[0] >>> methods = sorted( ... recipe_type.findall(wadl_tag('method')), key=sort_by_id) >>> (delete, get, patch, put) = methods >>> delete.attrib['id'] 'recipe-delete' DELETE is a very simple HTTP method that takes no payload and returns nothing, so there's nothing inside this tag. It's just a flag that this resource type supports DELETE. Custom operations ================= Custom operations activated with GET may have URL parameters. >>> get_request = findRecipes.find(wadl_tag('request')) >>> [param.attrib['name'] for param in get_request] ['ws.op', 'search'] Custom operations activated with POST may have a form-encoded or multipart representation. If the operation has no binary parameters, the recommended content type is application/x-www-form-urlencoded. >>> post_request = makeMoreInteresting.find(wadl_tag('request')) >>> post_representation = post_request.find(wadl_tag('representation')) >>> post_representation.attrib['mediaType'] 'application/x-www-form-urlencoded' >>> [param.attrib['name'] for param in post_representation] ['ws.op'] If the operation has a binary parameter, the recommended content type is multipart/form-data. >>> binary_post_request = replace_cover.find(wadl_tag('request')) >>> binary_post_representation = binary_post_request.find( ... wadl_tag('representation')) >>> binary_post_representation.attrib['mediaType'] 'multipart/form-data' The 'ws.op' parameter is present for all custom operations, either as a URL parameter or as part of the representation. It's always required, and fixed to a particular value. >>> ws_op = single_list_value(post_representation) >>> ws_op.attrib['required'] 'true' >>> ws_op.attrib['fixed'] 'make_more_interesting' ===================== Hosted file resources ===================== One interesting type of parameter not shown above is a link to an externally hosted file managed by the web service, such as a cookbook's cover. >>> cover_param = single_list_value( ... [param for param in full_rep ... if param.attrib['name'] == 'cover_link']) >>> doc, cover_link = cover_param >>> cover_link.attrib['resource_type'] 'http://...#HostedFile' What can the client do to this hosted file resource? >>> get, put, delete = list(hosted_binary_resource_type) The client can send GET to the resource, and be redirected to a file hosted externally. >>> get.tag '...method' >>> get.attrib['name'] 'GET' >>> get_response = single_list_value(get) >>> get_representation = single_list_value(get_response) >>> get_representation.attrib['status'] '303' >>> redirect_param = single_list_value(get_representation) >>> redirect_param.tag '...param' >>> redirect_param.attrib['style'] 'header' >>> redirect_param.attrib['name'] 'Location' The client can PUT a hosted file: >>> put.tag '...method' >>> put.attrib['name'] 'PUT' The client can DELETE an existing file: >>> delete.tag '...method' >>> delete.attrib['name'] 'DELETE' ======================================== A non-top-level collection resource type ======================================== We're almost done with our in-depth look at the WADL description of a cookbook. Now we need to consider a page of cookbooks. This is different from the top-level collection of cookbooks because a top-level collection can have custom operations. A "page of cookbooks" resource only supports the standard GET operation. The "page of cookbooks" might be served from a scoped collection (the collection of cookbooks owned by a user), a named operation (search for cookbooks by cuisine), or by following the 'next' link from a top-level collection. This describes a list of cookbooks. >>> collection_type.tag '...resource_type' >>> collection_type.attrib['id'] 'cookbook-page-resource' The only method supported is the standard GET. >>> get = single_list_value(collection_type) >>> get.attrib['id'] 'cookbook-page-resource-get' In response to the standard GET operation, collection resources will serve a JSON representation, described immediately below. >>> get_response = single_list_value(get) >>> json_representation = single_list_value(get_response) >>> json_representation.attrib['href'] '#cookbook-page' ================================== The representation of a collection ================================== The representation of one type of entry (say, a cookbook) looks very different from the representation of another type (say, a recipe), but all collections look pretty much the same, no matter what kind of entries they contain. In fact, a top-level collection (say, the collection of recipes) references the same tag as a corresponding scoped collection (say, the recipes in a cookbook) or any other collection (say, the second page of the top-level recipe collection). >>> collection_rep.tag '...representation' >>> collection_rep.attrib['mediaType'] 'application/json' All collection representations have the same five tags. >>> [param.attrib['name'] for param in collection_rep] ['resource_type_link', 'total_size', 'total_size_link', 'start', 'next_collection_link', 'prev_collection_link', 'entries', 'entry_links'] >>> (type_link, size, size_link, start, next, prev, entries, ... entry_links) = collection_rep So what's the difference between a collection of people and a collection of bug tasks? Well, the ID is different, but that's just a name. >>> collection_rep.attrib['id'] 'cookbook-page' No, the real difference is the 'entry_links' parameter. It tells the client that this particular collection contains links to objects of type 'cookbook'. >>> entry_links.attrib['path'] "$['entries'][*]['self_link']" >>> link = single_list_value(entry_links) >>> link.attrib['resource_type'] 'http://...#cookbook' This tells the client that a 'collection of cookbooks' resource contains lots of links to 'cookbook' resources. The 'next_collection_link' and 'prev_collection_link' parameters are also links to other resources. What are these other resources? Other pages of the same collection! >>> next_type = single_list_value(next) >>> next_type.attrib['resource_type'] '#cookbook-page-resource' >>> prev_type = single_list_value(prev) >>> prev_type.attrib['resource_type'] '#cookbook-page-resource' Misspelled media type ===================== Earlier versions of lazr.restful served WADL documents with a misspelled media type. For purposes of backwards compatibility, a client can still request this media type, and lazr.restful will serve a standard WADL document with a misspelled Content-Type. >>> misspelling = 'application/vd.sun.wadl+xml' >>> misspelled_response = webservice.get("/", misspelling) >>> misspelled_response.getHeader("Content-Type") == misspelling True >>> wadl_from_misspelled_response = misspelled_response.body >>> validate(wadl_from_misspelled_response) True This works with any kind of resource you might request the WADL for: the service root, an entry resource, or a hosted binary file resource. >>> misspelled_response = webservice.get(entry_url, misspelling) >>> misspelled_response.getHeader("Content-Type") == misspelling True >>> misspelled_response = webservice.get( ... entry_url + "/cover", misspelling) >>> misspelled_response.getHeader("Content-Type") == misspelling True lazr.restful-0.19.3/src/lazr/restful/example/base/tests/redirect.txt0000644000175000017500000000406111631755356025750 0ustar benjibenji00000000000000Introduction ************ The RedirectResource is a simple resource implementation that redirects the client to another URL. The example web service implements the CookbookSetTraverse class to serve a RedirectResource at /cookbooks/featured. This particular RedirectResource redirects you to the currently featured cookbook. >>> from lazr.restful.testing.webservice import WebServiceCaller >>> webservice = WebServiceCaller(domain='cookbooks.dev') >>> print webservice.get("/cookbooks/featured") HTTP/1.1 301 Moved Permanently ... Location: http://.../cookbooks/Mastering%20the%20Art%20of%20French%20Cooking ... The redirect has an extra twist when the resource was requested from a web browser as opposed to a web service client. When a web browser follows a redirect, it does not re-send the Accept header from the original request. This can lead to lazr.restful serving the wrong representation (usually HTML instead of JSON). To avoid this problem, lazr.restful sends the original Accept header in the redirect URL itself, so it can look it up when the browser follows the redirect. >>> from lazr.restful.testing.webservice import ( ... WebServiceAjaxCaller) >>> ajax = WebServiceAjaxCaller(domain='cookbooks.dev') >>> print ajax.get("/cookbooks/featured") HTTP/1.1 301 Moved Permanently ... Location: http://.../cookbooks/Mastering%20the%20Art%20of%20French%20Cooking?ws.accept=application/json ... >>> print ajax.get( ... "/cookbooks/featured", ... headers=dict(Accept="application/xhtml+xml")) HTTP/1.1 301 Moved Permanently ... Location: http://...?ws.accept=application/xhtml%2Bxml ... The redirect works even if the redirect URI contains characters not valid in URIs. In this case, the redirect is to a URL that contains curly braces (see traversal.py for details). >>> print ajax.get("/cookbooks/featured-invalid") HTTP/1.1 301 Moved Permanently ... Location: http://.../Mastering%20the%20Art%20of%20French%20Cooking{invalid}?ws.accept=application/json ... lazr.restful-0.19.3/src/lazr/restful/example/base/tests/representation-cache.txt0000644000175000017500000003762711631755356030270 0ustar benjibenji00000000000000********************************** The in-memory representation cache ********************************** Rather than having lazr.restful calculate a representation of an entry every time it's requested, you can register an object as the representation cache. String representations of entries are generated once and stored in the representation cache. lazr.restful works fine when there is no representation cache installed; in fact, this is the only test that uses one. >>> from zope.component import getUtility >>> from lazr.restful.interfaces import IRepresentationCache >>> getUtility(IRepresentationCache) Traceback (most recent call last): ... ComponentLookupError: ... DictionaryBasedRepresentationCache ================================== A representation cache can be any object that implements IRepresentationCache, but for test purposes we'll be using a simple DictionaryBasedRepresentationCache. This object transforms the IRepresentationCache operations into operations on a Python dict-like object. >>> from lazr.restful.simple import DictionaryBasedRepresentationCache >>> dictionary = {} >>> cache = DictionaryBasedRepresentationCache(dictionary) It's not a good idea to use a normal Python dict in production, because there's no limit on how large the dict can become. In a real situation you want something with an LRU implementation. That said, let's see how the DictionaryBasedRepresentationCache works. All IRepresentationCache implementations will cache a representation under a key derived from the object whose representation it is, the media type of the representation, and a web service version name. >>> from lazr.restful.example.base.root import C4 as greens_object >>> json = "application/json" >>> print cache.get(greens_object, json, "devel") None >>> print cache.get(greens_object, json, "devel", "missing") missing >>> cache.set(greens_object, json, "devel", "This is the 'devel' value.") >>> print cache.get(greens_object, json, "devel") This is the 'devel' value. >>> sorted(dictionary.keys()) ['http://cookbooks.dev/devel/cookbooks/Everyday%20Greens,application/json'] This allows different representations of the same object to be stored for different versions. >>> cache.set(greens_object, json, "1.0", "This is the '1.0' value.") >>> print cache.get(greens_object, json, "1.0") This is the '1.0' value. >>> sorted(dictionary.keys()) ['http://cookbooks.dev/1.0/cookbooks/Everyday%20Greens,application/json', 'http://cookbooks.dev/devel/cookbooks/Everyday%20Greens,application/json'] Deleting an object from the cache will remove all its representations. >>> cache.delete(greens_object) >>> sorted(dictionary.keys()) [] >>> print cache.get(greens_object, json, "devel") None >>> print cache.get(greens_object, json, "1.0") None DO_NOT_CACHE ------------ Representation caches treat the constant object DO_NOT_CACHE specially. If key_for() returns DO_NOT_CACHE, the resulting object+type+version combination will not be cached at all. >>> class TestRepresentationCache(DictionaryBasedRepresentationCache): ... def key_for(self, object, media_type, version): ... if (object == "Don't cache this." ... and version != "cache everything!"): ... return self.DO_NOT_CACHE ... return object + ',' + media_type + ',' + version >>> test_dict = {} >>> test_cache = TestRepresentationCache(test_dict) The key_for() implementation defined above will not cache a representation of the string "Don't cache this." >>> test_cache.set("Cache this.", json, "1.0", "representation") >>> test_cache.set("Don't cache this.", json, "1.0", "representation") >>> test_dict.keys() ['Cache this.,application/json,1.0'] ...UNLESS the representation being cached is for the version "cache everything!" >>> test_cache.set("Don't cache this.", json, "cache everything!", ... "representation") >>> for key in sorted(test_dict.keys()): ... print key Cache this.,application/json,1.0 Don't cache this.,application/json,cache everything! Even if an uncacheable value somehow gets into the cache, it's not retrievable. >>> bad_key = "Don't cache this.,application/json,1.0" >>> test_dict[bad_key] = "This representation should not be cached." >>> print test_cache.get("Don't cache this.", json, "1.0") None A representation cache ====================== Now let's register our DictionaryBasedRepresentationCache as the representation cache for this web service, and see how it works within lazr.restful. >>> from zope.component import getSiteManager >>> sm = getSiteManager() >>> sm.registerUtility(cache, IRepresentationCache) >>> from lazr.restful.testing.webservice import WebServiceCaller >>> webservice = WebServiceCaller(domain='cookbooks.dev') When we retrieve a JSON representation of an entry, that representation is added to the cache. >>> recipe_url = "/recipes/1" >>> ignored = webservice.get(recipe_url) >>> [the_only_key] = dictionary.keys() >>> print the_only_key http://cookbooks.dev/devel/recipes/1,application/json Note that the cache key incorporates the web service version name ("devel") and the media type of the representation ("application/json"). Associated with the key is a string: the JSON representation of the object. >>> import simplejson >>> print simplejson.loads(dictionary[the_only_key])['self_link'] http://cookbooks.dev/devel/recipes/1 If we get a representation of the same resource from a different web service version, that representation is stored separately. >>> ignored = webservice.get(recipe_url, api_version="1.0") >>> for key in sorted(dictionary.keys()): ... print key http://cookbooks.dev/1.0/recipes/1,application/json http://cookbooks.dev/devel/recipes/1,application/json >>> key1 = "http://cookbooks.dev/1.0/recipes/1,application/json" >>> key2 = "http://cookbooks.dev/devel/recipes/1,application/json" >>> dictionary[key1] == dictionary[key2] False Cache invalidation ================== lazr.restful automatically invalidates the representation cache when certain changes happen that it knows about. For instance, when the client sends a PATCH request to modify an object, the modified representation is returned in preference to the previously cached representation. >>> old_instructions = webservice.get( ... recipe_url, api_version='devel').jsonBody()['instructions'] >>> print old_instructions You can always judge... >>> response = webservice.patch(recipe_url, 'application/json', ... simplejson.dumps(dict(instructions="New instructions")), ... api_version='devel') >>> print response.status 209 >>> print response.jsonBody()['instructions'] New instructions The modified representation is immediately available in the cache. >>> from lazr.restful.example.base.root import RECIPES >>> recipe = [recipe for recipe in RECIPES if recipe.id == 1][0] >>> cached_representation = cache.get(recipe, json, 'devel') >>> print simplejson.loads(cached_representation)['instructions'] New instructions Cleanup. >>> recipe.instructions = old_instructions When the client invokes a named operation using POST, the object is always removed from the cache, because there's no way to know what the POST did. >>> dictionary.clear() >>> from lazr.restful.example.base.root import COOKBOOKS >>> cookbook = [cookbook for cookbook in COOKBOOKS ... if cookbook.name == "Everyday Greens"][0] >>> cache.set(cookbook, json, 'devel', "Dummy value.") >>> print dictionary.keys()[0] http://.../devel/cookbooks/Everyday%20Greens,application/json >>> from urllib import quote >>> greens_url = quote("/cookbooks/Everyday Greens") >>> ignore = webservice.named_post( ... greens_url, "replace_cover", cover="foo") >>> print len(dictionary.keys()) 0 But unless the web service is the only way to manipulate a data set, you'll also need to come up with your own cache invalidation rules. Without those rules, other kinds of changes won't trigger cache invalidations, and your web service cache will grow stale. Let's signal a change to a recipe. Suppose someone changed that recipe, using a web application that has no connection to the web service except for a shared database. Let's further suppose that our ORM lets us detect the database change. What do we do when that change happens? To remove an object's representation from the cache, we pass it into the cache's delete() method. >>> ignore = webservice.get(recipe_url, api_version='devel') >>> print cache.get(recipe, json, 'devel') {...} >>> cache.delete(recipe) This deletes all the relevant representations. >>> print cache.get(recipe, json, 'devel') None >>> dictionary.keys() [] Data visibility =============== Only full representations are added to the cache. If the representation you request includes a redacted field (because you don't have permission to see that field's true value), the representation is not added to the cache. >>> greens = webservice.get(greens_url).jsonBody() >>> print greens['confirmed'] tag:launchpad.net:2008:redacted >>> dictionary.keys() [] This means that if your entry resources typically contain data that's only visible to a select few users, you won't get much benefit out of a representation cache. What if a full representation is in the cache, and the user requests a representation that must be redacted? Let's put some semi-fake data in the cache and find out. >>> import simplejson >>> greens['name'] = "This comes from the cache; it is not generated." >>> greens['confirmed'] = True >>> cache.set(greens_object, json, 'devel', simplejson.dumps(greens)) When we GET the corresponding resource, we get a representation that definitely comes from the cache, not the original data source. >>> cached_greens = webservice.get(greens_url).jsonBody() >>> print cached_greens['name'] This comes from the cache; it is not generated. But the redacted value is still redacted. >>> print cached_greens['confirmed'] tag:launchpad.net:2008:redacted Cleanup: clear the cache. >>> dictionary.clear() Unauthorized ============ When a client tries to fetch an object they lack permission to view, they get a 401 error. >>> recipe_url ="/recipes/5" >>> response = webservice.get(recipe_url) >>> print response.status 401 >>> len(dictionary.keys()) 0 This happens even if the forbidden object has a cached representation. To demonstrate this, we'll temporarily make recipe #5 public and get its representation. >>> from lazr.restful.example.base.root import C3_D2 as recipe >>> recipe.private = False >>> print webservice.get("/recipes/5").jsonBody()['instructions'] Without doubt the most famous... Now there's a representation in the cache. >>> len(dictionary.keys()) 1 If we make the recipe private again, the client can no longer retrieve a representation, even though there's one in the cache. (This happens when an object's URL construction code raises an Unauthorized exception.) >>> recipe.private = True >>> response = webservice.get(recipe_url) >>> print response.status 401 Cleanup: clear the cache. >>> dictionary.clear() Collections =========== Collections are full of entries, and representations of collections are built from the cache if possible. We'll demonstrate this with the collection of recipes. First, we'll hack the cached representation of a single recipe. >>> recipe = webservice.get("/recipes/1").jsonBody() >>> recipe['instructions'] = "This representation is from the cache." >>> [recipe_key] = dictionary.keys() >>> dictionary[recipe_key] = simplejson.dumps(recipe) Now, we get the collection of recipes. >>> recipes = webservice.get("/recipes").jsonBody()['entries'] The fake instructions we put into an entry's cached representation are also present in the collection. >>> for instructions in ( ... sorted(recipe['instructions'] for recipe in recipes)): ... print instructions A perfectly roasted chicken is... Draw, singe, stuff, and truss... ... This representation is from the cache. To build the collection, lazr.restful had to generate representations of all the cookbook entries. As it generated each representation, it populated the cache. >>> for key in sorted(dictionary.keys()): ... print key http://cookbooks.dev/devel/recipes/1,application/json http://cookbooks.dev/devel/recipes/2,application/json http://cookbooks.dev/devel/recipes/3,application/json http://cookbooks.dev/devel/recipes/4,application/json If we request the collection again, all the entry representations will come from the cache. >>> for key in dictionary.keys(): ... value = simplejson.loads(dictionary[key]) ... value['instructions'] = "This representation is from the cache." ... dictionary[key] = simplejson.dumps(value) >>> recipes = webservice.get("/recipes").jsonBody()['entries'] >>> for instructions in ( ... sorted(recipe['instructions'] for recipe in recipes)): ... print instructions This representation is from the cache. This representation is from the cache. This representation is from the cache. This representation is from the cache. enable_server_side_representation_cache ======================================= A configuration setting allows you to disable the cache without de-registering it. This is useful when you're debugging a cache implementation or a cache invalidation algorithm. >>> from lazr.restful.interfaces import IWebServiceConfiguration >>> config = getUtility(IWebServiceConfiguration) >>> print config.enable_server_side_representation_cache True Set this configuration value to False, and representations will not be served from the cache, even if present. >>> config.enable_server_side_representation_cache = False >>> recipes = webservice.get("/recipes").jsonBody()['entries'] >>> for instructions in ( ... sorted(recipe['instructions'] for recipe in recipes)): ... print instructions A perfectly roasted chicken is... Draw, singe, stuff, and truss... Preheat oven to... You can always judge... New representations will not be added to the cache. >>> before_keys = len(dictionary.keys()) >>> dishes = webservice.get("/dishes") >>> len(dictionary.keys()) == before_keys True The cache is still registered as a utility. >>> print getUtility(IRepresentationCache) And it's still populated, as we can see by re-enabling it. >>> config.enable_server_side_representation_cache = True >>> recipes = webservice.get("/recipes").jsonBody()['entries'] >>> for instructions in ( ... sorted(recipe['instructions'] for recipe in recipes)): ... print instructions This representation is from the cache. This representation is from the cache. This representation is from the cache. This representation is from the cache. Once re-enabled, new representations are once again added to the cache. >>> dishes = webservice.get("/dishes") >>> len(dictionary.keys()) > before_keys True Cleanup ======= De-register the cache. >>> sm.registerUtility(None, IRepresentationCache) Of course, the hacks we made to the cached representations have no effect on the objects themselves. Once the hacked cache is gone, the representations look just as they did before. >>> recipes = webservice.get("/recipes").jsonBody()['entries'] >>> for instructions in ( ... sorted(recipe['instructions'] for recipe in recipes)): ... print instructions A perfectly roasted chicken is... Draw, singe, stuff, and truss... Preheat oven to... You can always judge... lazr.restful-0.19.3/src/lazr/restful/example/base/tests/__init__.py0000644000175000017500000000000011631755356025504 0ustar benjibenji00000000000000lazr.restful-0.19.3/src/lazr/restful/example/base/tests/root.txt0000644000175000017500000001453511631755356025141 0ustar benjibenji00000000000000Introduction ************ The service root (/[version]/) is a resource that responds to GET by describing the web service. The description is a JSON map full of links to the top-level web service objects. >>> from lazr.restful.testing.webservice import WebServiceCaller >>> webservice = WebServiceCaller(domain='cookbooks.dev') >>> top_level_response = webservice.get("/") >>> top_level_links = top_level_response.jsonBody() >>> sorted(top_level_links.keys()) [u'cookbooks_collection_link', u'dishes_collection_link', u'featured_cookbook_link', u'recipes_collection_link', u'resource_type_link'] >>> top_level_links['cookbooks_collection_link'] u'http://cookbooks.dev/devel/cookbooks' >>> print top_level_links['resource_type_link'] http://cookbooks.dev/devel/#service-root The client can explore the entire web service by following these links to other resources, and following the links served in those resources' JSON representations, and so on. If the client doesn't know the capabilities of a certain resource it can request a WADL representation of that resource (see the wadl.txt test) and find out. There is no XHTML representation available for the service root. >>> print webservice.get('/', 'application/xhtml+xml') HTTP/1.1 200 Ok ... Content-Type: application/json ... Though web services in general support all HTTP methods, this particular resource supports only GET. Eventually it will also support HEAD and OPTIONS. >>> for method in ['HEAD', 'POST', 'PUT', 'DELETE', 'OPTIONS']: ... print webservice("/", method) HTTP/1.1 405 Method Not Allowed... Allow: GET ... HTTP/1.1 405 Method Not Allowed... Allow: GET ... HTTP/1.1 405 Method Not Allowed... Allow: GET ... HTTP/1.1 405 Method Not Allowed... Allow: GET ... HTTP/1.1 405 Method Not Allowed... Allow: GET ... Conditional GET =============== The service root never changes except when the web service is upgraded. To avoid getting it more often than that, a client can store the value of the 'ETag' response header the first time it retrieves the service root. >>> etag = top_level_response.getheader('ETag') >>> etag is None False The value of 'ETag' can be used in a subsequent request as the 'If-None-Match' request header. If the client's old ETag matches the current ETag, the representation won't be served again. >>> conditional_response = webservice.get( ... '/', headers={'If-None-Match' : etag}) >>> conditional_response.status 304 >>> conditional_response.body '' >>> conditional_response = webservice.get( ... '/', headers={'If-None-Match' : '"a-very-old-etag"'}) >>> conditional_response.status 200 >>> conditional_response.jsonBody() {...} You can specify a number of etags in If-None-Match. You'll get a new representation only if *none* of them match: >>> conditional_response = webservice.get( ... '/', ... headers={'If-None-Match' : '"a-very-old-etag", %s' % etag}) >>> conditional_response.status 304 >>> conditional_response = webservice.get( ... '/', ... headers={'If-None-Match' : '"a-very-old-etag", "another-etag"'}) >>> conditional_response.status 200 Top-level entry links ===================== Most of the links at the top level are links to collections. But an especially important entry may also be given a link from the service root. The cookbook web service has a 'featured cookbook' which may change over time. >>> print top_level_links['featured_cookbook_link'] http://.../cookbooks/featured Caching policy ============== The service root resource is served with the Cache-Control header giving a configurable value for "max-age". An old version of the service root can be cached for a long time: >>> response = webservice.get('/', api_version='1.0') >>> print response.getheader('Cache-Control') max-age=10000 The latest version of the service root should be cached for less time. >>> response = webservice.get('/', api_version='devel') >>> print response.getheader('Cache-Control') max-age=2 Both the WADL and JSON representations of the service root are cacheable. >>> wadl_type = 'application/vnd.sun.wadl+xml' >>> response = webservice.get('/', wadl_type) >>> print response.getheader('Cache-Control') max-age=2 The Date header is set along with Cache-Control so that the client can easily determine when the cache is stale. >>> response.getheader('Date') is None False Date and Cache-Control are set even when the request is a conditional request where the condition failed. >>> etag = response.getheader('ETag') >>> conditional_response = webservice.get( ... '/', wadl_type, headers={'If-None-Match' : etag}) >>> conditional_response.status 304 >>> print conditional_response.getheader('Cache-Control') max-age=2 >>> conditional_response.getheader('Date') is None False To avoid triggering a bug in httplib2, lazr.restful does not send the Cache-Control or Date headers to clients that identify as Python-httplib2. # XXX leonardr 20100412 # bug=http://code.google.com/p/httplib2/issues/detail?id=97 >>> agent = 'Python-httplib2/$Rev: 259$' >>> response = webservice.get( ... '/', wadl_type, headers={'User-Agent' : agent}) >>> print response.getheader('Cache-Control') None >>> print response.getheader('Date') None If the client identifies as an agent _based on_ httplib2, we take a chance and send the Cache-Control headers. >>> agent = "Custom client (%s)" % agent >>> response = webservice.get( ... '/', wadl_type, headers={'User-Agent' : agent}) >>> print response.getheader('Cache-Control') max-age=2 >>> response.getheader('Date') is None False If the caching policy says not to cache the service root resource at all, the Cache-Control and Date headers are not present. >>> from zope.component import getUtility >>> from lazr.restful.interfaces import IWebServiceConfiguration >>> policy = getUtility(IWebServiceConfiguration).caching_policy >>> old_value = policy[-1] >>> policy[-1] = 0 >>> response = webservice.get('/') >>> print response.getheader('Cache-Control') None >>> response.getheader('Date') is None True Cleanup. >>> policy[-1] = old_value lazr.restful-0.19.3/src/lazr/restful/example/base/interfaces.py0000644000175000017500000002106711631755356024746 0ustar benjibenji00000000000000# Copyright 2009 Canonical Ltd. All rights reserved. # pylint: disable-msg=E0211,E0213 """Interface objects for the LAZR example web service.""" __metaclass__ = type __all__ = ['AlreadyNew', 'Cuisine', 'ICookbook', 'ICookbookSet', 'IDish', 'IDishSet', 'IFileManager', 'IHasGet', 'IFileManagerBackedByteStorage', 'IRecipe', 'IRecipeSet', 'NameAlreadyTaken'] from zope.interface import Attribute, Interface from zope.schema import Bool, Bytes, Choice, Date, Float, Int, TextLine, Text from zope.location.interfaces import ILocation from lazr.enum import EnumeratedType, Item from lazr.restful.fields import CollectionField, Reference from lazr.restful.interfaces import IByteStorage, ITopLevelEntryLink from lazr.restful.declarations import ( collection_default_content, export_as_webservice_collection, export_as_webservice_entry, export_destructor_operation, export_factory_operation, export_read_operation, export_write_operation, exported, operation_parameters, operation_returns_collection_of, operation_returns_entry, webservice_error) class AlreadyNew(Exception): """A cookbook's name prohibits the cheap 'The New' trick.""" webservice_error(400) class NameAlreadyTaken(Exception): """The name given for a cookbook is in use by another cookbook.""" webservice_error(409) class WhitespaceStrippingTextLine(TextLine): """A TextLine that won't abide leading or trailing whitespace.""" def set(self, object, value): """Strip whitespace before setting.""" if value is not None: value = value.strip() super(WhitespaceStrippingTextLine, self).set(object, value) class Cuisine(EnumeratedType): """A vocabulary for cuisines.""" GENERAL = Item("General", "General cooking") VEGETARIAN = Item("Vegetarian", "Vegetarian cooking") AMERICAN = Item("American", "Traditional American cooking") DESSERT = Item("Dessert", "Desserts") FRANCAISE = Item("Fran\xc3\xa7aise".decode("utf-8"), "Cuisine fran\xc3\xa7aise".decode("utf-8")) class IHasGet(Interface): """A marker interface objects that implement traversal with get().""" def get(name): """Traverse to a contained object.""" class IDish(ILocation): """A dish, annotated for export to the web service.""" export_as_webservice_entry(plural_name='dishes') name = exported(TextLine(title=u"Name", required=True)) recipes = exported(CollectionField( title=u"Recipes in this cookbook", value_type=Reference(schema=Interface))) def removeRecipe(recipe): """Remove one of this dish's recipes.""" class IRecipe(ILocation): """A recipe, annotated for export to the web service.""" export_as_webservice_entry() id = exported(Int(title=u"Unique ID", required=True)) dish = exported(Reference(title=u"Dish", schema=IDish)) cookbook = exported(Reference(title=u"Cookbook", schema=Interface)) instructions = exported(Text(title=u"How to prepare the recipe", required=True)) private = exported(Bool(title=u"Whether the public can see this recipe.", default=False)) prepared_image = exported( Bytes(0, 5000, title=u"An image of the prepared dish.", readonly=True)) @export_destructor_operation() def delete(): """Delete the recipe.""" class ICookbook(IHasGet, ILocation): """A cookbook, annotated for export to the web service.""" export_as_webservice_entry() name = exported(TextLine(title=u"Name", required=True)) copyright_date = exported( Date(title=u"Copyright Date", description=u"The copyright date for this work."), readonly=True) description = exported( WhitespaceStrippingTextLine(title=u"Description", required=False)) revision_number = exported( Int(title=u"A record of the number of times " "this cookbook has been modified.")) confirmed = exported(Bool( title=u"Whether this information has been confirmed", default=False)) cuisine = exported(Choice( vocabulary=Cuisine, title=u"Cuisine", required=False, default=None)) last_printing = exported( Date(title=u"Last printing", description=u"The date of this work's most recent printing.")) # Don't try this at home! Float is a bad choice for a 'price' # field because it's imprecise. Decimal is a better choice. But # this is just an example and we need a Float field, so... price = exported(Float(title=u"Retail price of the cookbook")) recipes = exported(CollectionField(title=u"Recipes in this cookbook", value_type=Reference(schema=IRecipe))) cover = exported( Bytes(0, 5000, title=u"An image of the cookbook's cover.")) @operation_parameters( search=TextLine(title=u"String to search for in recipe name.")) @operation_returns_collection_of(IRecipe) @export_read_operation() def find_recipes(search): """Search for recipes in this cookbook.""" @operation_parameters( dish=Reference(title=u"Dish to search for.", schema=IDish)) @operation_returns_entry(IRecipe) @export_read_operation() def find_recipe_for(dish): """Find a recipe in this cookbook for a given dish.""" @export_write_operation() def make_more_interesting(): """Alter a cookbook to make it seem more interesting.""" def removeRecipe(recipe): """Remove one of this cookbook's recipes.""" @operation_parameters(cover=Bytes(title=u"New cover")) @export_write_operation() def replace_cover(cover): """Replace the cookbook's cover.""" # Resolve dangling references IDish['recipes'].value_type.schema = IRecipe IRecipe['cookbook'].schema = ICookbook class ICookbookSubclass(ICookbook): """A published subclass of ICookbook. This entry interface is never used, but it acts as a test case for a published entry interface that subclasses another published entry interface. """ export_as_webservice_entry() class IFeaturedCookbookLink(ITopLevelEntryLink): """A marker interface.""" class ICookbookSet(IHasGet): """The set of all cookbooks, annotated for export to the web service.""" export_as_webservice_collection(ICookbook) @collection_default_content() def getCookbooks(): """Return the list of cookbooks.""" @operation_parameters( search=TextLine(title=u"String to search for in recipe name."), vegetarian=Bool(title=u"Whether or not to limit the search to " "vegetarian cookbooks.", default=False)) @operation_returns_collection_of(IRecipe) @export_read_operation() def find_recipes(search, vegetarian): """Search for recipes across cookbooks.""" @operation_parameters( cuisine=Choice(vocabulary=Cuisine, title=u"Cuisine to search for in recipe name.")) @operation_returns_collection_of(ICookbook) @export_read_operation() def find_for_cuisine(cuisine): """Search for cookbooks of a given cuisine.""" @export_factory_operation( ICookbook, ['name', 'description', 'cuisine', 'copyright_date', 'last_printing', 'price']) def create(name, description, cuisine, copyright_date, last_printing, price): """Create a new cookbook.""" featured = Attribute("The currently featured cookbook.") class IDishSet(IHasGet): """The set of all dishes, annotated for export to the web service.""" export_as_webservice_collection(IDish) @collection_default_content() def getDishes(): """Return the list of dishes.""" class IRecipeSet(IHasGet): """The set of all recipes, annotated for export to the web service.""" export_as_webservice_collection(IRecipe) @collection_default_content() def getRecipes(): """Return the list of recipes.""" def removeRecipe(recipe): """Remove a recipe from the list.""" class IFileManager(IHasGet): """A simple manager for hosted binary files. This is just an example for how you might host binary files. It's only useful for testing purposes--you'll need to come up with your own implementation, or use lazr.librarian. """ def put(name, value): """Store a file in the manager.""" def delete(name): """Delete a file from the manager.""" class IFileManagerBackedByteStorage(IByteStorage): id = Attribute("The manager ID for this file.") lazr.restful-0.19.3/src/lazr/restful/example/base/subscribers.py0000644000175000017500000000112011631755356025135 0ustar benjibenji00000000000000# Copyright 2009 Canonical Ltd. All rights reserved. """Event listeners for the example web service.""" __metaclass__ = type __all__ = ['update_cookbook_revision_number'] from zope.interface import Interface import grokcore.component from lazr.lifecycle.interfaces import IObjectModifiedEvent from lazr.restful.example.base.interfaces import ICookbook @grokcore.component.subscribe(ICookbook, IObjectModifiedEvent) def update_cookbook_revision_number(object, event): """Increment ICookbook.revision_number.""" if ICookbook.providedBy(object): object.revision_number += 1 lazr.restful-0.19.3/src/lazr/restful/example/base/filemanager.py0000644000175000017500000000471711631755356025100 0ustar benjibenji00000000000000# Copyright 2009 Canonical Ltd. All rights reserved. """The file manager for the LAZR example web service.""" __metaclass__ = type __all__ = ['FileManager', 'ManagedFileResource'] import datetime # Import SHA in a way compatible with both Python 2.4 and Python 2.6. try: import hashlib sha_constructor = hashlib.sha1 except ImportError: import sha sha_constructor = sha.new from zope.interface import implements import grokcore.component from lazr.restful import ReadOnlyResource from lazr.restful.example.base.interfaces import IFileManager from lazr.restful.utils import get_current_web_service_request class FileManager: implements(IFileManager) def __init__(self): """Initialize with an empty list of files.""" self.files = {} self.counter = 0 def get(self, id): """See `IFileManager`.""" return self.files.get(id) def put(self, mediaType, representation, filename): """See `IFileManager`.""" id = str(self.counter) self.counter += 1 self.files[id] = ManagedFileResource( representation, mediaType, filename, datetime.datetime.now()) return id def delete(self, key): """See `IFileManager`.""" if key in self.files: del self.files[key] grokcore.component.global_utility(FileManager) class ManagedFileResource(ReadOnlyResource): def __init__(self, representation, mediaType, filename, last_modified): """Initialize with a file to be managed.""" self.representation = representation self.mediaType = mediaType self.filename = filename self.last_modified = last_modified sum = sha_constructor() sum.update(representation) self.etag = sum.hexdigest() def __call__(self): """Write the file to the current request.""" request = get_current_web_service_request() response = request.response # Handle a conditional request incoming_etag = request.getHeader('If-None-Match') if incoming_etag == self.etag: response.setStatus(304) return response.setHeader("Content-Type", self.mediaType) response.setHeader( "Last-Modified", self.last_modified.isoformat()) response.setHeader("ETag", self.etag) response.setHeader( "Content-Disposition", 'attachment; filename="%s"' % self.filename) return self.representation lazr.restful-0.19.3/src/lazr/restful/example/base/root.py0000644000175000017500000003054111631755356023603 0ustar benjibenji00000000000000# Copyright 2009 Canonical Ltd. All rights reserved. """Data model objects for the LAZR example web service.""" __metaclass__ = type __all__ = ['Cookbook', 'CookbookServiceRootResource', 'CookbookSet', 'CookbookWebServiceObject', 'WebServiceConfiguration'] from datetime import date import grokcore.component from zope.interface import implements from zope.location.interfaces import ILocation from zope.component import adapts, getMultiAdapter, getUtility from zope.schema.interfaces import IBytes from lazr.restful import directives, ServiceRootResource from lazr.restful.interfaces import ( IByteStorage, IEntry, IServiceRootResource, ITopLevelEntryLink, IWebServiceConfiguration) from lazr.restful.example.base.interfaces import ( AlreadyNew, Cuisine, ICookbook, ICookbookSet, IDish, IDishSet, IFileManager, IRecipe, IRecipeSet, IHasGet, NameAlreadyTaken) from lazr.restful.simple import BaseWebServiceConfiguration from lazr.restful.testing.webservice import WebServiceTestPublication from lazr.restful.utils import get_current_web_service_request #Entry classes. class CookbookWebServiceObject: """A basic object published through the web service.""" class SimpleByteStorage(CookbookWebServiceObject, grokcore.component.MultiAdapter): """A simple IByteStorage implementation""" implements(IByteStorage, ILocation) grokcore.component.adapts(IEntry, IBytes) grokcore.component.provides(IByteStorage) def __init__(self, entry, field): self.entry = entry self.field = field self.is_stored = getattr( self.entry, field.__name__, None) is not None if self.is_stored: self.filename = getattr(self.entry, field.__name__).filename self.id = getattr(self.entry, field.__name__).id else: self.filename = field.__name__ self.id = None # AbsoluteURL implementation. self.__parent__ = self.entry.context self.__name__ = self.field.__name__ @property def alias_url(self): """The URL to the managed file. This URL will always contain the name of the latest version, no matter the version of the original request. This is not ideal, but it's acceptable because 1) this is just a test implementation, and 2) the ByteStorage implementation cannot change between versions. """ return 'http://cookbooks.dev/%s/filemanager/%s' % ( getUtility(IWebServiceConfiguration).active_versions[-1], self.id) def createStored(self, mediaType, representation, filename=None): self.representation = representation if filename is None: filename = self.field.__name__ self.id = getUtility(IFileManager).put( mediaType, representation, filename) self.filename = filename setattr(self.entry, self.field.__name__, self) def deleteStored(self): getUtility(IFileManager).delete(self.id) setattr(self.entry, self.field.__name__, None) class Cookbook(CookbookWebServiceObject): """An object representing a cookbook""" implements(ICookbook) def __init__(self, name, description, cuisine, copyright_date, last_printing=None, price=0, confirmed=False): self.name = name self.cuisine = cuisine self.description = description self.recipes = [] self.copyright_date = copyright_date self.last_printing = last_printing self.price = price self.confirmed = confirmed self.cover = None self.revision_number = 0 @property def __name__(self): return self.name @property def __parent__(self): return getUtility(ICookbookSet) def get(self, name): """See `IHasGet`.""" match = [recipe for recipe in self.recipes if recipe.dish.name == name] if len(match) > 0: return match[0] return None def find_recipes(self, search): """See `ICookbook`.""" recipes = [] for recipe in self.recipes: if search in recipe.dish.name: recipes.append(recipe) return recipes def make_more_interesting(self): """See `ICookbook`.""" if self.name.find("The New ") == 0: raise AlreadyNew( "The 'New' trick can't be used on this cookbook " "because its name already starts with 'The New'.") self.name = "The New " + self.name def find_recipe_for(self, dish): """See `ICookbook`.""" for recipe in self.recipes: if recipe.dish == dish: return recipe return None def removeRecipe(self, recipe): """See `ICookbook`.""" self.recipes.remove(recipe) def replace_cover(self, cover): entry = getMultiAdapter( (self, get_current_web_service_request()), IEntry) storage = SimpleByteStorage(entry, ICookbook['cover']) storage.createStored('application/octet-stream', cover, 'cover') class Dish(CookbookWebServiceObject): implements(IDish) def __init__(self, name): self.name = name self.recipes = [] @property def __name__(self): return self.name @property def __parent__(self): return getUtility(IDishSet) def removeRecipe(self, recipe): self.recipes.remove(recipe) class Recipe(CookbookWebServiceObject): implements(IRecipe) def __init__(self, id, cookbook, dish, instructions, private=False): self.id = id self.dish = dish self.dish.recipes.append(self) self.cookbook = cookbook self.cookbook.recipes.append(self) self.instructions = instructions self.private = private self.prepared_image = None @property def __name__(self): return str(self.id) @property def __parent__(self): return getUtility(IRecipeSet) def delete(self): getUtility(IRecipeSet).removeRecipe(self) # Top-level objects. class CookbookTopLevelObject(CookbookWebServiceObject, grokcore.component.GlobalUtility): """An object published at the top level of the web service.""" implements(ILocation) @property def __parent__(self): return getUtility(IServiceRootResource) @property def __name__(self): raise NotImplementedError() class FeaturedCookbookLink(CookbookTopLevelObject): """A link to the currently featured cookbook.""" implements(ITopLevelEntryLink) grokcore.component.provides(ITopLevelEntryLink) @property def __parent__(self): return getUtility(ICookbookSet) __name__ = "featured" link_name = "featured_cookbook" entry_type = ICookbook class CookbookSet(CookbookTopLevelObject): """The set of all cookbooks.""" implements(ICookbookSet) grokcore.component.provides(ICookbookSet) __name__ = "cookbooks" def __init__(self, cookbooks=None): if cookbooks is None: cookbooks = COOKBOOKS self.cookbooks = list(cookbooks) self.featured = self.cookbooks[0] def getCookbooks(self): return self.cookbooks def get(self, name): match = [c for c in self.cookbooks if c.name == name] if len(match) > 0: return match[0] return None def find_recipes(self, search, vegetarian=False): recipes = [] for cookbook in self.cookbooks: if not vegetarian or cookbook.cuisine == Cuisine.VEGETARIAN: recipes.extend(cookbook.find_recipes(search)) return recipes def find_for_cuisine(self, cuisine): """See `ICookbookSet`""" cookbooks = [] for cookbook in self.cookbooks: if cookbook.cuisine == cuisine: cookbooks.append(cookbook) return cookbooks def create(self, name, description, cuisine, copyright_date, last_printing=None, price=0): for cookbook in self.cookbooks: if cookbook.name == name: raise NameAlreadyTaken( 'A cookbook called "%s" already exists.' % name) cookbook = Cookbook(name, description, cuisine, copyright_date, last_printing, price) self.cookbooks.append(cookbook) return cookbook class DishSet(CookbookTopLevelObject): """The set of all dishes.""" implements(IDishSet) grokcore.component.provides(IDishSet) __name__ = "dishes" def __init__(self, dishes=None): if dishes is None: dishes = DISHES self.dishes = list(dishes) def getDishes(self): return self.dishes def get(self, name): match = [d for d in self.dishes if d.name == name] if len(match) > 0: return match[0] return None class RecipeSet(CookbookTopLevelObject): """The set of all recipes.""" implements(IRecipeSet) grokcore.component.provides(IRecipeSet) __name__ = "recipes" def __init__(self, recipes=None): if recipes is None: recipes = RECIPES self.recipes = list(recipes) def getRecipes(self): return self.recipes def get(self, id): id = int(id) match = [r for r in self.recipes if r.id == id] if len(match) > 0: return match[0] return None def removeRecipe(self, recipe): self.recipes.remove(recipe) recipe.cookbook.removeRecipe(recipe) recipe.dish.removeRecipe(recipe) # Define some globally accessible sample data. def year(year): """Turn a year into a datetime.date object.""" return date(year, 1, 1) C1 = Cookbook(u"Mastering the Art of French Cooking", "", Cuisine.FRANCAISE, year(1961)) C2 = Cookbook(u"The Joy of Cooking", "", Cuisine.GENERAL, year(1995), price=20) C3 = Cookbook(u"James Beard's American Cookery", "", Cuisine.AMERICAN, year(1972)) C4 = Cookbook(u"Everyday Greens", "", Cuisine.VEGETARIAN, year(2003)) C5 = Cookbook(u"I'm Just Here For The Food", "", Cuisine.GENERAL, year(2002)) C6 = Cookbook(u"Cooking Without Recipes", "", Cuisine.GENERAL, year(1959)) C7 = Cookbook(u"Construsions un repas", "", Cuisine.FRANCAISE, year(2007)) COOKBOOKS = [C1, C2, C3, C4, C5, C6, C7] D1 = Dish("Roast chicken") C1_D1 = Recipe(1, C1, D1, u"You can always judge...") C2_D1 = Recipe(2, C2, D1, u"Draw, singe, stuff, and truss...") C3_D1 = Recipe(3, C3, D1, u"A perfectly roasted chicken is...") D2 = Dish("Baked beans") C2_D2 = Recipe(4, C2, D2, "Preheat oven to...") C3_D2 = Recipe(5, C3, D2, "Without doubt the most famous...", True) D3 = Dish("Foies de voilaille en aspic") C1_D3 = Recipe(6, C1, D3, "Chicken livers sauteed in butter...") DISHES = [D1, D2, D3] RECIPES = [C1_D1, C2_D1, C3_D1, C2_D2, C3_D2, C1_D3] # Define classes for the service root. class CookbookServiceRootResource(ServiceRootResource): """A service root for the cookbook web service. Traversal to top-level resources is handled with the get() method. The top-level objects are stored in the top_level_names dict. """ implements(IHasGet) @property def top_level_names(self): """Access or create the list of top-level objects.""" return {'cookbooks': getUtility(ICookbookSet), 'dishes' : getUtility(IDishSet), 'recipes' : getUtility(IRecipeSet), 'filemanager': getUtility(IFileManager)} def get(self, name): """Traverse to a top-level object.""" obj = self.top_level_names.get(name) return obj # Define the web service configuration. class WebServiceConfiguration(BaseWebServiceConfiguration): directives.publication_class(WebServiceTestPublication) caching_policy = [10000, 2] code_revision = 'test.revision' default_batch_size = 5 hostname = 'cookbooks.dev' match_batch_size = 50 active_versions = ['1.0', 'devel'] service_description = """

This is a web service.

It's got resources!

""" version_descriptions = { 'devel' : """

The unstable development version.

Don't use this unless you like changing things.

""" } last_version_with_mutator_named_operations = None first_version_with_total_size_link = None use_https = False view_permission = 'lazr.restful.example.base.View' lazr.restful-0.19.3/src/lazr/restful/example/base/security.py0000644000175000017500000000170711631755356024471 0ustar benjibenji00000000000000# Copyright 2009 Canonical Ltd. All rights reserved. """A simple security policy for the LAZR example web service.""" __metaclass__ = type __all__ = [ 'CookbookWebServiceSecurityPolicy', ] from zope.security.simplepolicies import PermissiveSecurityPolicy from zope.security.proxy import removeSecurityProxy from lazr.restful.example.base.interfaces import ICookbook, IRecipe class CookbookWebServiceSecurityPolicy(PermissiveSecurityPolicy): """A very basic security policy.""" def checkPermission(self, permission, object): """Check against a simple policy. * Private recipes are always hidden. * Any fields protected by lazr.restful.example.base.ViewPrivate are hidden. """ if IRecipe.providedBy(object): return not removeSecurityProxy(object).private elif permission == "lazr.restful.example.base.ViewPrivate": return False else: return True lazr.restful-0.19.3/src/lazr/restful/example/base/__init__.py0000644000175000017500000000000011631755356024342 0ustar benjibenji00000000000000lazr.restful-0.19.3/src/lazr/restful/example/base/traversal.py0000644000175000017500000000552511631755356024627 0ustar benjibenji00000000000000# Copyright 2009 Canonical Ltd. All rights reserved. """Traversal rules for the LAZR example web service.""" __metaclass__ = type __all__ = [ 'BelowRootAbsoluteURL', 'CookbookSetTraverse', 'RootAbsoluteURL', 'TraverseWithGet', ] from urllib import unquote from zope.component import adapts from zope.interface import implements from zope.publisher.interfaces import IPublishTraverse, NotFound from zope.publisher.interfaces.browser import IDefaultBrowserLayer from zope.traversing.browser import absoluteURL, AbsoluteURL import grokcore.component from lazr.restful.example.base.interfaces import ICookbookSet, IHasGet from lazr.restful.example.base.root import CookbookWebServiceObject from lazr.restful.simple import ( RootResourceAbsoluteURL, SimulatedWebsiteRequest, ) from lazr.restful import RedirectResource def web_service_request_to_web_site_request(web_service_request): """Create a simulated website request for generating web_link.""" body = web_service_request.bodyStream.getCacheStream() environ = dict(web_service_request.environment) return SimulatedWebsiteRequest(body, environ) class RootAbsoluteURL(RootResourceAbsoluteURL): """A technique for generating the service's root URL. This class contains no code of its own. It's defined so that grok will pick it up. """ class BelowRootAbsoluteURL(AbsoluteURL): """A technique for generating a root URL given an ILocation. This class contains no code of its own. It's defined so that grok will pick it up. """ class TraverseWithGet(grokcore.component.MultiAdapter): """A simple IPublishTraverse that uses the get() method.""" grokcore.component.adapts(IHasGet, IDefaultBrowserLayer) grokcore.component.implements(IPublishTraverse) def __init__(self, context, request): self.context = context def publishTraverse(self, request, name): name = unquote(name) value = self.context.get(name) if value is None: raise NotFound(self, name) return value class CookbookSetTraverse(TraverseWithGet): """An IPublishTraverse that implements a custom redirect.""" grokcore.component.adapts(ICookbookSet, IDefaultBrowserLayer) grokcore.component.implements(IPublishTraverse) def publishTraverse(self, request, name): if name == 'featured': url = absoluteURL(self.context.featured, request) return RedirectResource(url, request) elif name == 'featured-invalid': # This is to enable a test case in which the redirect URL # contains characters not valid in URLs. url = absoluteURL(self.context.featured, request) + "{invalid}" return RedirectResource(url, request) else: return super(CookbookSetTraverse, self).publishTraverse( request, name) lazr.restful-0.19.3/src/lazr/restful/example/multiversion/0000755000175000017500000000000011636155340024061 5ustar benjibenji00000000000000lazr.restful-0.19.3/src/lazr/restful/example/multiversion/tests/0000755000175000017500000000000011636155340025223 5ustar benjibenji00000000000000lazr.restful-0.19.3/src/lazr/restful/example/multiversion/tests/test_integration.py0000644000175000017500000000277711631755356031204 0ustar benjibenji00000000000000# Copyright 2008 Canonical Ltd. All rights reserved. """Test harness for doctests for lazr.restful multiversion example service.""" __metaclass__ = type __all__ = [] import os import doctest from pkg_resources import resource_filename from zope.component import getUtility from van.testing.layer import zcml_layer, wsgi_intercept_layer from lazr.restful.example.multiversion.root import ( MultiversionWebServiceRootResource) from lazr.restful.interfaces import IWebServiceConfiguration from lazr.restful.simple import Publication from lazr.restful.testing.webservice import WebServiceApplication DOCTEST_FLAGS = ( doctest.ELLIPSIS | doctest.NORMALIZE_WHITESPACE | doctest.REPORT_NDIFF) class FunctionalLayer: zcml = os.path.abspath(resource_filename( 'lazr.restful.example.multiversion', 'site.zcml')) zcml_layer(FunctionalLayer) class WSGILayer(FunctionalLayer): @classmethod def make_application(self): getUtility(IWebServiceConfiguration).hostname = "multiversion.dev" getUtility(IWebServiceConfiguration).port = None root = MultiversionWebServiceRootResource() return WebServiceApplication(root, Publication) wsgi_intercept_layer(WSGILayer) def additional_tests(): """See `zope.testing.testrunner`.""" tests = sorted( [name for name in os.listdir(os.path.dirname(__file__)) if name.endswith('.txt')]) suite = doctest.DocFileSuite(optionflags=DOCTEST_FLAGS, *tests) suite.layer = WSGILayer return suite lazr.restful-0.19.3/src/lazr/restful/example/multiversion/tests/operation.txt0000644000175000017500000001166311631755356030003 0ustar benjibenji00000000000000**************** Named operations **************** Named operations have some special features that are too obscure to mention in the introductory doctest. total_size versus total_size_link --------------------------------- In old versions of lazr.restful, named operations that return collections always send a 'total_size' containing the total size of a collection. >>> from lazr.restful.testing.webservice import WebServiceCaller >>> webservice = WebServiceCaller(domain='multiversion.dev') In the example web service, named operations always send 'total_size' up to version '2.0'. >>> from zope.component import getUtility >>> from lazr.restful.interfaces import IWebServiceConfiguration >>> config = getUtility(IWebServiceConfiguration) >>> print config.first_version_with_total_size_link 2.0 When the 'byValue' operation is invoked in version 1.0, it always returns a total_size. >>> def get_collection(version, op='byValue', value="bar", size=2): ... url = '/pairs?ws.op=%s&value=%s&ws.size=%s' % (op, value, size) ... return webservice.get(url, api_version=version).jsonBody() >>> print sorted(get_collection('1.0').keys()) [u'entries', u'next_collection_link', u'start', u'total_size'] The operation itself doesn't change between 1.0 and 2.0, but in version 2.0, the operation starts returning total_size_link. >>> print sorted(get_collection('2.0').keys()) [u'entries', u'next_collection_link', u'start', u'total_size_link'] The same happens in 3.0. >>> print sorted(get_collection('3.0', 'by_value').keys()) [u'entries', u'next_collection_link', u'start', u'total_size_link'] However, if the total size is easy to calculate (for instance, because all the results fit on one page), a total_size is returned instead of total_size_link. >>> print sorted(get_collection('3.0', 'by_value', size=100).keys()) [u'entries', u'start', u'total_size'] >>> print get_collection('3.0', 'by_value', 'no-such-value') {u'total_size': 0, u'start': 0, u'entries': []} Mutators as named operations ---------------------------- Mutator methods (methods invoked when a field is modified) are annotated the same way as named operations, and in old versions of lazr.restful, they were actually published as named operations. In the example web service, mutator methods are published as named operations in the 'beta' and '1.0' versions. >>> print config.last_version_with_mutator_named_operations 1.0 If you look at the definition of IKeyValuePair in lazr.restful.example.multiversion.resources, you'll see that the 'a_comment' field has two different mutators at different times. Sometimes there is no mutator, sometimes the mutator is 'comment_mutator_1', and sometimes the mutator is 'comment_mutator_2'. Before version '2.0', mutators are published as named operations; in version '2.0' and afterwards, they are not. This helper method makes it easy to invoke a given mutator in a given version. >>> def call_mutator(version, op="comment_mutator_1"): ... url = '/pairs/foo' ... entity_body = "ws.op=%s&comment=value" % op ... body = webservice.post( ... url, 'application/x-www-form-urlencoded', entity_body, ... api_version=version) ... return body In version 'beta', the 'a_comment' field has no mutator at all. >>> print call_mutator("beta") HTTP/1.1 405 Method Not Allowed ... >>> print call_mutator("beta", op="comment_mutator_2") HTTP/1.1 405 Method Not Allowed ... In version '1.0', the 'comment_mutator_1' method is the mutator for 'a_comment'. Because mutators are published as named operation in version '1.0', we can also invoke the mutator method directly. >>> print call_mutator("1.0", "comment_mutator_1") HTTP/1.1 200 Ok ... 'comment_mutator_2' still doesn't work, because it's not the mutator for 'a_comment' in version '1.0'. >>> print call_mutator("1.0", "comment_mutator_2") HTTP/1.1 400 Bad Request ... Note that the response code is 400 ("Bad Request"), not 405 ("Method Not Allowed"). 405 means that the resource publishes no named POST operations at all. This resource does post one named POST operation ('comment_mutator_1'), so 405 isn't appropriate. In version '2.0', the 'comment_mutator_1' method is still the mutator for 'a_comment', but mutators are no longer published as named operations, so we can no longer invoke the mutator method directly. >>> print call_mutator("2.0") HTTP/1.1 405 Method Not Allowed ... Note that we're back to a 405 response code. That mutator was the only named POST operation on the key-value object, and now it's no longer published as a named operation. In version '3.0', the 'comment_mutator_2' method becomes the mutator for 'a_comment'. But since version '3.0' is after version '1.0', that method is not published as a named operation. >>> print call_mutator("3.0", "comment_mutator_2") HTTP/1.1 405 Method Not Allowed ... lazr.restful-0.19.3/src/lazr/restful/example/multiversion/tests/introduction.txt0000644000175000017500000002224411631755356030521 0ustar benjibenji00000000000000Multi-version web services ************************** lazr.restful lets you publish two or more mutually incompatible web services from the same underlying code. This lets you improve your web service to take advantage of new features of lazr.restful, without sacrificing backwards compatibility. The web service in example/multiversion illustrates the multiversion features of lazr.restful. >>> from lazr.restful.testing.webservice import WebServiceCaller >>> webservice = WebServiceCaller(domain='multiversion.dev') The multiversion web service serves four named versions of the same web service: "beta", "1.0", "2.0", and "3.0". Once you make a request to the service root of a particular version, the web service only serves you links within that version. >>> top_level_response = webservice.get( ... "/", api_version="beta").jsonBody() >>> print top_level_response['key_value_pairs_collection_link'] http://multiversion.dev/beta/pairs >>> top_level_response = webservice.get( ... "/", api_version="1.0").jsonBody() >>> print top_level_response['key_value_pairs_collection_link'] http://multiversion.dev/1.0/pairs >>> top_level_response = webservice.get( ... "/", api_version="2.0").jsonBody() >>> print top_level_response['key_value_pairs_collection_link'] http://multiversion.dev/2.0/pairs >>> top_level_response = webservice.get( ... "/", api_version="3.0").jsonBody() >>> print top_level_response['key_value_pairs_collection_link'] http://multiversion.dev/3.0/pairs Like all web services, the multiversion service also serves a development version which tracks the current state of the web service, including all changes that have not yet been folded into a named version. The default name for the development version is "devel" (see example/base/tests/service.txt), but in this web service it's called "trunk". >>> top_level_response = webservice.get( ... "/", api_version="trunk").jsonBody() >>> print top_level_response['key_value_pairs_collection_link'] http://multiversion.dev/trunk/pairs All versions of the web service can be accessed through Ajax. >>> from lazr.restful.testing.webservice import WebServiceAjaxCaller >>> ajax = WebServiceAjaxCaller(domain='multiversion.dev') >>> body = ajax.get('/', api_version="1.0").jsonBody() >>> print body['resource_type_link'] http://multiversion.dev/1.0/#service-root >>> body = ajax.get('/', api_version="trunk").jsonBody() >>> print body['resource_type_link'] http://multiversion.dev/trunk/#service-root An attempt to access a nonexistent version yields a 404 error. >>> print webservice.get('/', api_version="no_such_version") HTTP/1.1 404 Not Found ... Collections =========== The web service presents a single collection of key-value pairs. In versions previous to 2.0, the collection omits key-value pairs where the value is None. In 2.0 and 3.0, all key-value pairs are published. >>> from operator import itemgetter >>> def show_pairs(version): ... body = webservice.get('/pairs', api_version=version).jsonBody() ... for entry in sorted(body['entries'], key=itemgetter('key')): ... print "%s: %s" % (entry['key'], entry['value']) >>> show_pairs('beta') 1: 2 Also delete: me Delete: me foo: bar foo2: bar foo3: bar >>> show_pairs('1.0') 1: 2 Also delete: me Delete: me foo: bar foo2: bar foo3: bar >>> show_pairs('2.0') 1: 2 Also delete: me Delete: me Some: None foo: bar foo2: bar foo3: bar >>> show_pairs('3.0') 1: 2 Also delete: me Delete: me Some: None foo: bar foo2: bar foo3: bar Entries ======= Let's take a look at 'comment' and 'deleted', two fields with interesting properties. The 'comment' field is not modified directly, but by internal mutator methods which append some useless text to your comment. >>> import simplejson >>> def get_comment(version): ... response = webservice.get("/pairs/foo", api_version=version) ... return response.jsonBody()['comment'] >>> def change_comment(comment, version, get_comment_afterwards=True): ... ignored = webservice.patch( ... "/pairs/foo/", 'application/json', ... simplejson.dumps({"comment": comment}), ... api_version=version) ... if get_comment_afterwards: ... return get_comment(version) ... return None >>> get_comment('1.0') u'' >>> print change_comment('I changed 1.0', '1.0') I changed 1.0 (modified by mutator #1) >>> print change_comment('I changed 2.0', '2.0') I changed 2.0 (modified by mutator #1) >>> print change_comment('I changed 3.0', '3.0') I changed 3.0 (modified by mutator #2) You can try to modify the 'comment' field from a version that doesn't publish that field, but lazr.restful will ignore your request. >>> change_comment('I changed beta', 'beta', False) >>> print get_comment('1.0') I changed 3.0 (modified by mutator #2) A field called 'deleted' is published starting in version '3.0'. A comment field is called 'a_comment' in version 'beta' and 'comment' in all later versions. >>> def show_fields(version): ... entry_body = webservice.get( ... '/pairs/foo', api_version=version).jsonBody() ... for key in sorted(entry_body.keys()): ... print key >>> show_fields('beta') a_comment http_etag key resource_type_link self_link value >>> show_fields('1.0') comment http_etag key resource_type_link self_link value >>> show_fields('3.0') comment deleted http_etag key resource_type_link self_link value In the 'beta' version, attempting to delete a key-value pair will result in a status code of 405 ("Method Not Available"). >>> response = webservice.delete('/pairs/Delete', api_version='beta') >>> response.status 405 As of '1.0', attempting to delete a key-value pair results in the key-value pair being totally removed from the web service. >>> ignore = webservice.delete('/pairs/Delete', api_version='1.0') >>> show_pairs('beta') 1: 2 Also delete: me foo: bar foo2: bar foo3: bar In '3.0', deleting a key-value pair simply sets its 'deleted' field to True. (This is an abuse of the HTTP DELETE method, but it makes a good demo.) >>> body = webservice.get( ... '/pairs/Also%20delete', api_version='3.0').jsonBody() >>> body['deleted'] False >>> ignore = webservice.delete('/pairs/Also%20delete', api_version='3.0') The "deleted" key-value pair is still visible in all versions: >>> show_pairs('beta') 1: 2 Also delete: me foo: bar foo2: bar foo3: bar And in a version which publishes the 'delete' field, we can check the key-value pair's value for that field. >>> body = webservice.get( ... '/pairs/Also%20delete', api_version='3.0').jsonBody() >>> body['deleted'] True Fields ====== If an entry field is not published in a certain version, the corresponding field resource does not exist for that version. >>> print webservice.get('/pairs/foo/deleted', api_version='beta').body Object: <...>, name: u'deleted' >>> print webservice.get( ... '/pairs/foo/deleted', api_version='3.0').jsonBody() False Named operations ================ The collection of key-value pairs defines a named operation for finding pairs, given a value. This operation is present in some versions of the web service but not others. In some versions it's called "byValue"; in others, it's called "by_value". >>> def show_value(version, op): ... url = '/pairs?ws.op=%s&value=bar' % op ... body = webservice.get(url, api_version=version).jsonBody() ... return body['entries'][0]['key'] The named operation is not published at all in the 'beta' version of the web service. >>> print show_value("beta", 'byValue') Traceback (most recent call last): ... ValueError: No such operation: byValue >>> print show_value("beta", 'by_value') Traceback (most recent call last): ... ValueError: No such operation: by_value In the '1.0' and '2.0' versions, the named operation is published as 'byValue'. 'by_value' does not work. >>> print show_value("1.0", 'byValue') foo >>> print show_value("2.0", 'byValue') foo >>> print show_value("2.0", 'by_value') Traceback (most recent call last): ... ValueError: No such operation: by_value In the '3.0' version, the named operation is published as 'by_value'. 'byValue' does not work. >>> print show_value("3.0", "by_value") foo >>> print show_value("3.0", 'byValue') Traceback (most recent call last): ... ValueError: No such operation: byValue In the 'trunk' version, the named operation has been removed. Neither 'byValue' nor 'by_value' work. >>> print show_value("trunk", 'byValue') Traceback (most recent call last): ... ValueError: No such operation: byValue >>> print show_value("trunk", 'by_value') Traceback (most recent call last): ... ValueError: No such operation: by_value lazr.restful-0.19.3/src/lazr/restful/example/multiversion/tests/wadl.txt0000644000175000017500000001102311631755356026720 0ustar benjibenji00000000000000Multi-version WADL documents **************************** A given version of the web service generates a WADL document which describes that version only. Let's go through the WADL documents for the different versions and see how they differ. >>> from lazr.restful.testing.webservice import WebServiceCaller >>> webservice = WebServiceCaller(domain='multiversion.dev') We'll start with a helper function that retrieves the WADL description for a given version of the key-value web service, and decomposes the top-level tags in the WADL document into a dictionary for easy access later. This works because all versions of the web service publish a single top-level collection and a single entry type, so the document's top-level structure is always the same. >>> from lxml import etree >>> from lxml.etree import _Comment >>> def wadl_contents_for_version(version): ... """Parse the key-value service's WADL into a dictionary.""" ... wadl = webservice.get( ... '/', media_type='application/vnd.sun.wadl+xml', ... api_version=version).body ... tree = etree.fromstring(wadl) ... ... keys = ("service_doc version_doc base service_root " ... "service_root_json pair_collection pair_entry" ... "pair_full_json pair_diff_jaon pair_page pair_page_json" ... "hosted_file hosted_file_representation" ... ).split() ... ... tags = [child for child in tree if not isinstance(child, _Comment)] ... contents = {} ... for i in range(0, len(keys)): ... contents[keys[i]] = tags[i] ... return contents Let's take a look at the differences. In 'beta', the 'by_value' method is not present at all. >>> contents = wadl_contents_for_version('beta') >>> print contents['version_doc'].attrib['title'] About version beta >>> print contents['base'].attrib['base'] http://multiversion.dev/beta/ >>> pair_collection = contents['pair_collection'] >>> sorted([method.attrib['id'] for method in pair_collection]) ['key_value_pairs-get'] As a side note, see that the service documentation and version documentation tags are empty, because this service's configuration doesn't specify that information: >>> len(list(contents['service_doc'])) 0 >>> len(list(contents['version_doc'])) 0 In '2.0', the by_value method is called 'byValue'. >>> contents = wadl_contents_for_version('2.0') >>> print contents['version_doc'].attrib['title'] About version 2.0 >>> print contents['base'].attrib['base'] http://multiversion.dev/2.0/ >>> pair_collection = contents['pair_collection'] >>> sorted([method.attrib['id'] for method in pair_collection]) ['key_value_pairs-byValue', 'key_value_pairs-get'] In '3.0', the method changes its name to 'by_value'. >>> contents = wadl_contents_for_version('3.0') >>> print contents['version_doc'].attrib['title'] About version 3.0 >>> print contents['base'].attrib['base'] http://multiversion.dev/3.0/ >>> pair_collection = contents['pair_collection'] >>> sorted([method.attrib['id'] for method in pair_collection]) ['key_value_pairs-by_value', 'key_value_pairs-get'] In 'trunk', the method disappears. >>> contents = wadl_contents_for_version('trunk') >>> print contents['base'].attrib['base'] http://multiversion.dev/trunk/ >>> pair_collection = contents['pair_collection'] >>> sorted([method.attrib['id'] for method in pair_collection]) ['key_value_pairs-get'] total_size_link =============== The version in which total_size_link is introduced is controlled by the first_version_with_total_size_link attribute of the web service configuration (IWebServiceConfiguration) utility. We'll configure the web service to begin including `total_size_link` values in version 3.0: >>> from zope.component import getUtility >>> from lazr.restful.interfaces import IWebServiceConfiguration >>> config = getUtility(IWebServiceConfiguration) >>> config.first_version_with_total_size_link = '3.0' Now if we request the WADL for 3.0 it will include a description of total_size_link. >>> webservice.get('/', media_type='application/vnd.sun.wadl+xml', ... api_version='3.0').body '...>> wadl = webservice.get('/', media_type='application/vnd.sun.wadl+xml', ... api_version='2.0').body >>> 'total_size_link' in wadl False lazr.restful-0.19.3/src/lazr/restful/example/multiversion/tests/__init__.py0000644000175000017500000000000011631755356027332 0ustar benjibenji00000000000000lazr.restful-0.19.3/src/lazr/restful/example/multiversion/root.py0000644000175000017500000000352111631755356025427 0ustar benjibenji00000000000000"""The RESTful service root.""" __metaclass__ = type __all__ = [ 'BelowRootAbsoluteURL', 'RootAbsoluteURL', 'WebServiceConfiguration', 'MultiversionWebServiceRootResource', ] from zope.traversing.browser import AbsoluteURL from lazr.restful.wsgi import BaseWSGIWebServiceConfiguration from lazr.restful.simple import RootResource, RootResourceAbsoluteURL from lazr.restful.example.multiversion.resources import ( IKeyValuePair, PairSet, KeyValuePair) class RootAbsoluteURL(RootResourceAbsoluteURL): """A technique for generating the service's root URL. This class contains no code of its own. It's defined so that grok will pick it up. """ class BelowRootAbsoluteURL(AbsoluteURL): """A technique for generating a root URL given an ILocation. This class contains no code of its own. It's defined so that grok will pick it up. """ class WebServiceConfiguration(BaseWSGIWebServiceConfiguration): code_revision = '1' active_versions = ['beta', '1.0', '2.0', '3.0', 'trunk'] first_version_with_total_size_link = '2.0' last_version_with_mutator_named_operations = '1.0' use_https = False view_permission = 'zope.Public' class MultiversionWebServiceRootResource(RootResource): """The root resource for the WSGI example web service.""" def _build_top_level_objects(self): pairset = PairSet() pairset.pairs = [ KeyValuePair(pairset, "foo", "bar"), KeyValuePair(pairset, "foo2", "bar"), KeyValuePair(pairset, "foo3", "bar"), KeyValuePair(pairset, "1", "2"), KeyValuePair(pairset, "Some", None), KeyValuePair(pairset, "Delete", "me"), KeyValuePair(pairset, "Also delete", "me") ] collections = dict(pairs=(IKeyValuePair, pairset)) return collections, {} lazr.restful-0.19.3/src/lazr/restful/example/multiversion/__init__.py0000644000175000017500000000000011631755356026170 0ustar benjibenji00000000000000lazr.restful-0.19.3/src/lazr/restful/example/multiversion/README.txt0000644000175000017500000000031211631755356025563 0ustar benjibenji00000000000000Multiversion web service ************************ This web service demonstrates lazr.restful's ability to serve multiple backwards-incompatible versions of a web service from the same underlying code. lazr.restful-0.19.3/src/lazr/restful/example/multiversion/site.zcml0000644000175000017500000000075511631755356025733 0ustar benjibenji00000000000000 lazr.restful-0.19.3/src/lazr/restful/example/multiversion/resources.py0000644000175000017500000001052111631755356026454 0ustar benjibenji00000000000000__metaclass__ = type __all__ = ['IKeyValuePair', 'IPairSet', 'KeyValuePair', 'PairSet'] from zope.interface import implements from zope.schema import Bool, Text from zope.location.interfaces import ILocation from lazr.restful.declarations import ( collection_default_content, export_as_webservice_collection, export_as_webservice_entry, export_destructor_operation, export_operation_as, export_read_operation, export_write_operation, exported, mutator_for, operation_for_version, operation_parameters, operation_removed_in_version, operation_returns_collection_of) # Our implementations of these classes can be based on the # implementations from the WSGI example. from lazr.restful.example.wsgi.resources import ( PairSet as BasicPairSet, KeyValuePair as BasicKeyValuePair) # Our interfaces _will_ diverge from the WSGI example interfaces, so # define them separately. class IKeyValuePair(ILocation): export_as_webservice_entry() key = exported(Text(title=u"The key")) value = exported(Text(title=u"The value")) a_comment = exported(Text(title=u"A comment on this key-value pair.", readonly=True), ('1.0', dict(exported=True, exported_as='comment'))) deleted = exported(Bool(title=u"Whether this key-value pair has been " "deleted"), ('3.0', dict(exported=True)), exported=False) @mutator_for(a_comment) @export_write_operation() @operation_parameters(comment=Text()) @operation_for_version('1.0') def comment_mutator_1(comment): """A comment mutator that adds some junk on the end.""" @mutator_for(a_comment) @export_write_operation() @operation_parameters(comment=Text()) @operation_for_version('3.0') def comment_mutator_2(comment): """A comment mutator that adds different junk on the end.""" @export_destructor_operation() @operation_for_version('1.0') def total_destruction(): """A destructor that removes the key-value pair altogether.""" @export_destructor_operation() @operation_for_version('3.0') def mark_as_deleted(): """A destructor that simply sets .deleted to True.""" class IPairSet(ILocation): export_as_webservice_collection(IKeyValuePair) # In versions 2.0 and 3.0, the collection of key-value pairs # includes all pairs. @collection_default_content("2.0") def getPairs(): """Return the key-value pairs.""" # Before 2.0, it only includes pairs whose values are not None. @collection_default_content('beta') def getNonEmptyPairs(): """Return the key-value pairs that don't map to None.""" def get(request, name): """Retrieve a key-value pair by its key.""" # This operation is not published in trunk. @operation_removed_in_version('trunk') # In 3.0, it's published as 'by_value' @export_operation_as('by_value') @operation_for_version('3.0') # In 1.0 and 2.0, it's published as 'byValue' @export_operation_as('byValue') @operation_parameters(value=Text()) @operation_returns_collection_of(IKeyValuePair) @export_read_operation() @operation_for_version('1.0') # This operation is not published in versions earlier than 1.0. def find_for_value(value): """Find key-value pairs that have the given value.""" class PairSet(BasicPairSet): implements(IPairSet) def find_for_value(self, value): return [pair for pair in self.pairs if value == pair.value] def getNonEmptyPairs(self): return [pair for pair in self.pairs if pair.value is not None] class KeyValuePair(BasicKeyValuePair): implements(IKeyValuePair) def __init__(self, pairset, key, value): super(KeyValuePair, self).__init__(pairset, key, value) self.a_comment = '' self.deleted = False def comment_mutator_1(self, comment): """A comment mutator.""" self.a_comment = comment + " (modified by mutator #1)" def comment_mutator_2(self, comment): """A comment mutator.""" self.a_comment = comment + " (modified by mutator #2)" def total_destruction(self): """Remove the pair from the pairset.""" self.set.pairs.remove(self) def mark_as_deleted(self): """Set .deleted to True.""" self.deleted = True lazr.restful-0.19.3/src/lazr/restful/example/__init__.py0000644000175000017500000000000011631755356023430 0ustar benjibenji00000000000000lazr.restful-0.19.3/src/lazr/restful/example/wsgi/0000755000175000017500000000000011636155340022272 5ustar benjibenji00000000000000lazr.restful-0.19.3/src/lazr/restful/example/wsgi/tests/0000755000175000017500000000000011636155340023434 5ustar benjibenji00000000000000lazr.restful-0.19.3/src/lazr/restful/example/wsgi/tests/test_integration.py0000644000175000017500000000305211631755356027400 0ustar benjibenji00000000000000# Copyright 2008 Canonical Ltd. All rights reserved. """Test harness for doctests for lazr.restful example WSGI service.""" __metaclass__ = type __all__ = [] import os import doctest from pkg_resources import resource_filename from zope.component import getUtility from zope.configuration import xmlconfig from zope.testing.cleanup import cleanUp from van.testing.layer import zcml_layer, wsgi_intercept_layer from lazr.restful.example.wsgi.root import WSGIExampleWebServiceRootResource from lazr.restful.interfaces import IWebServiceConfiguration from lazr.restful.simple import Publication from lazr.restful.testing.webservice import WebServiceApplication DOCTEST_FLAGS = ( doctest.ELLIPSIS | doctest.NORMALIZE_WHITESPACE | doctest.REPORT_NDIFF) class FunctionalLayer: zcml = os.path.abspath(resource_filename( 'lazr.restful.example.wsgi', 'site.zcml')) zcml_layer(FunctionalLayer) class WSGILayer(FunctionalLayer): @classmethod def make_application(self): getUtility(IWebServiceConfiguration).hostname = "wsgidemo.dev" getUtility(IWebServiceConfiguration).port = None root = WSGIExampleWebServiceRootResource() return WebServiceApplication(root, Publication) wsgi_intercept_layer(WSGILayer) def additional_tests(): """See `zope.testing.testrunner`.""" tests = sorted( [name for name in os.listdir(os.path.dirname(__file__)) if name.endswith('.txt')]) suite = doctest.DocFileSuite(optionflags=DOCTEST_FLAGS, *tests) suite.layer = WSGILayer return suite lazr.restful-0.19.3/src/lazr/restful/example/wsgi/tests/introduction.txt0000644000175000017500000000221511631755356026726 0ustar benjibenji00000000000000Introduction ************ The WSGI example web service is just about the simplest web service imaginable. It publishes a collection of key-value pairs. You can get the root resource. >>> from lazr.restful.testing.webservice import WebServiceCaller >>> webservice = WebServiceCaller(domain='wsgidemo.dev') >>> top_level_response = webservice.get("/") >>> top_level_links = top_level_response.jsonBody() >>> sorted(top_level_links.keys()) [u'key_value_pairs_collection_link', u'resource_type_link'] You can get a collection resource. >>> collection_resource = webservice.get("/pairs").jsonBody() >>> links = sorted([entry['self_link'] ... for entry in collection_resource['entries']]) >>> for link in links: ... print link http://wsgidemo.dev/1.0/1 http://wsgidemo.dev/1.0/foo You can get an entry resource. >>> entry_resource = webservice.get("/pairs/1").jsonBody() >>> sorted(entry_resource.keys()) [u'http_etag', u'key', u'resource_type_link', u'self_link', u'value'] You can get a field resource. >>> print webservice.get("/pairs/foo/value").jsonBody() bar lazr.restful-0.19.3/src/lazr/restful/example/wsgi/tests/__init__.py0000644000175000017500000000000011631755356025543 0ustar benjibenji00000000000000lazr.restful-0.19.3/src/lazr/restful/example/wsgi/run.py0000644000175000017500000000040111631755356023453 0ustar benjibenji00000000000000#!/usr/bin/python from lazr.restful.wsgi import WSGIApplication if __name__ == '__main__': import sys host, port = sys.argv[1:3] server = WSGIApplication.make_server( host, port, 'lazr.restful.example.wsgi') server.handle_request() lazr.restful-0.19.3/src/lazr/restful/example/wsgi/root.py0000644000175000017500000000276211631755356023646 0ustar benjibenji00000000000000"""The RESTful service root.""" __metaclass__ = type __all__ = [ 'BelowRootAbsoluteURL', 'RootAbsoluteURL', 'WebServiceConfiguration', 'WSGIExampleWebServiceRootResource', ] from zope.traversing.browser import AbsoluteURL from lazr.restful.example.wsgi.resources import ( IKeyValuePair, PairSet, KeyValuePair) from lazr.restful.wsgi import BaseWSGIWebServiceConfiguration from lazr.restful.simple import RootResource, RootResourceAbsoluteURL class RootAbsoluteURL(RootResourceAbsoluteURL): """A technique for generating the service's root URL. This class contains no code of its own. It's defined so that grok will pick it up. """ class BelowRootAbsoluteURL(AbsoluteURL): """A technique for generating a root URL given an ILocation. This class contains no code of its own. It's defined so that grok will pick it up. """ class WebServiceConfiguration(BaseWSGIWebServiceConfiguration): code_revision = '1' active_versions = ['1.0'] use_https = False last_version_with_mutator_named_operations = None view_permission = 'zope.Public' class WSGIExampleWebServiceRootResource(RootResource): """The root resource for the WSGI example web service.""" def _build_top_level_objects(self): pairset = PairSet() pairset.pairs = [ KeyValuePair(self, "foo", "bar"), KeyValuePair(self, "1", "2") ] collections = dict(pairs=(IKeyValuePair, pairset)) return collections, {} lazr.restful-0.19.3/src/lazr/restful/example/wsgi/__init__.py0000644000175000017500000000000011631755356024401 0ustar benjibenji00000000000000lazr.restful-0.19.3/src/lazr/restful/example/wsgi/README.txt0000644000175000017500000002674311631755356024014 0ustar benjibenji00000000000000WSGI example web service ************************ lazr.restful has two example web services. The one in src/lazr/restful/example is designed to test all of lazr.restful's features and Zope integrations. The one in src/lazr/restful/example/wsgi is designed to be a simple, understandable web service that can act as WSGI middleware. Getting Started =============== Here's how to set up a web server serving up the example WSGI web service. 1. python bootstrap.py 2. bin/buildout 3. bin/run src/lazr/restful/example/wsgi/run.py localhost 8001 The web server is now running at http://localhost:8001/1.0/. (Why "1.0"? That's defined in the web service configuration; see below.) Resources ========= The resources for the WSGI example web service are: 1. A service root resource. Defined in lazr.restful.example.wsgi.root, published at /1.0/ 2. A collection resource, defined in lazr.restful.example.wsgi.resources, published at /1.0/pairs 3. Two key-value pair entries. The key-value pair is defined in lazr.restful.example.wsgi.resources. The resources themselves are published at /1.0/pairs/1 and /1.0/pairs/foo. For simplicity's sake, there are no named operations (if you're curious, the web service in lazr/restful/example has many named operations) and no way to create or delete key-value pairs. Let's take a look at the resource definitions. This is the part that will be significantly different for any web service you create. There is code in these classes that's not explained here: it's code for traversal or for URL generation, and it's explained below. The root -------- Every lazr.restful web service needs a root resource. The simplest way to get a root resource is to subclass lazr.restful.simple.RootResource and implement _build_top_level_objects(), which advertises the main features of your web service to your users. Our service root resource is lazr.restful.example.wsgi.root.WSGIExampleWebServiceRootResource. We only have one top-level object, the collection of key-value pairs, so this implementation of _build_top_level_objects() just returns 'pairs'. It maps 'pairs' to a 2-tuple (IKeyValuePair, pairset). Here, "pairset" is the collection of key-value pairs itself, and IKeyValuePair is a Zope interface class that explains what kind of object is found in the collection. You'll see IKeyValuePair again in the next section of this document. If you subclass RootResource, lazr.restful will automatically know to use that class as your service root. If you decide not to subclass RootResource, you'll have to register your root class as the utility for IServiceRootResource (see "Utilities" below). The top-level collection ------------------------ The collection of key-value pairs is defined in lazr.restful.example.wsgi.resources. First we define an interface (IPairSet) that's decorated with lazr.restful decorators: 1. export_as_webservice_collection(IKeyValuePair) tells lazr.restful that an IPairSet should be published as a collection resource, as opposed to being published as an entry or not published at all. The "IKeyValuePair" lets lazr.restful know that this is a collection of key-value pairs and not some other kind of collection. 2. @collection_default_content() tells lazr.restful to call the getPairs() method when it wants to know what items are in the collection. Then we define the implementation (PairSet), which implements getPairs(). The entry resource ------------------ The key-value pair is also defined in lazr.restful.example.wsgi.resources. First we define an interface (IKeyValuePair) that's decorated with lazr.restful decorators: 1. export_as_webservice_entry() tells lazr.restful that an IKeyValuePair should be published as an entry, as opposed to being published as an entry or not published at all. 2. exported() tells lazr.restful which attributes of an IKeyValuePair should be published to the web service, and which ones are for internal use only. In this case, both 'key' and 'value' are published. Then we define KeyValuePair, which simply stores a key and value. Traversal ========= The traversal code turns an incoming URL path like "/1.0/pairs/foo" into an object to be published. The fragment "1.0" identifies the IServiceRootResource, the fragment "pairs" identifies the IPairSet, and the fragment "foo" identifies a specific IKeyValuePair within the IPairSet. Traversal code can get very complicated. If you're interested in the complex stuff, look at the custom code in lazr.restful.example that implements IPublishTraverse. But all the example resources in lazr.restful.example.wsgi do traversal simply by subclassing lazr.restful.publisher.TraverseWithGet. With TraverseWithGet, to traverse from a parent resource to its children, all you have to do is define a get() method. This method takes a path fragment like "pairs" and returns the corresponding object. Let's take an example. A request comes in for "/1.0/pairs". What happens? 1."1.0" identifies the web service root (lazr.restful.example.wsgi.root.WSGIExampleWebServiceRootResource) 2. "pairs" is passed into WSGIExampleWebServiceRootResource.get(), and the result is the lazr.restful.example.wsgi.resources.PairSet object. (The get() implementation is inherited from RootResource. It works because our implementation of _build_top_level_objects returned a dictionary that had a key called "pairs".) 3. There are no more path fragments, so traversal is complete. The PairSet object will be published as a collection resource. Why a collection? Because PairSet implements IPairSet, which is published as a collection. (Remember export_as_webservice_collection()?) You don't have to write all the traversal code your web service will ever use. There's a set of default rules that take over once your custom traversal code returns an IEntry, the way PairSet.get() does. Here's a more complex example. A request comes in for "/1.0/pairs/foo/value". What happens? 1. "1.0" identifies the web service root (WSGIExampleWebServiceRootResource) 2. "pairs" is passed into WSGIExampleWebServiceRootResource.get(), and the result is the PairSet object. 3. "foo" is passed into PairSet.get(), and the result is the KeyValuePair object. 4. Because IKeyValuePair is published as an entry (remember export_as_webservice_entry?), the custom traversal code now stops. What happens next is entirely controlled by lazr.restful. Because of this, it doesn't make sense to have an entry subclass TraverseWithGet or implement IPublishTraverse. 5. "value" identifies a published field of IKeyValuePair. lazr.restful traverses to that field. It's a Text field. 6. There are no more path fragments, so traversal is complete. The Text field is published as a field resource. URL generation ============== URL generation is the opposite of traversal. With traversal you have a URL and you need to find which object it refers to. With URL generation you have an object and you need to find its URL. URL generation can get arbitrarily complex--for each of your resource classes, you must define a class that implements the Zope interface IAbsoluteURL. But this web service takes a simpler approach. We define two empty classes, both in lazr.restful.example.wsgi.root: RootAbsoluteURL and BelowRootAbsoluteURL. The subclasses contain no code of their own; they act as signals to lazr.restful that we want to use certain URL generation strategies. RootAbsoluteURL is a subclass of lazr.restful.simple.RootResourceAbsoluteURL. By subclassing it we tell lazr.restful to generate the URL to the root resource using the hostname, protocol, and path information specified in the web service configuration (see below). Pretty much any lazr.restful web service should be able to use this strategy, since you need to define a lot of that configuration information anyway. The second strategy is a little more work. Keep in mind the basic structure of this web service. There's a root resource, a collection resource underneath the root, a bunch of entry resources underneath the collection resource, and a bunch of field resources underneath each entry. Every resource except for the root has a unique parent. BelowRootAbsoluteURL is a subclass of zope.traversing.browser.AbsoluteURL, which knows how to generate URLs using a recursive algorithm. To calculate a resource's URL, this algorithm calculates the URL of the parent resource, and tacks a resource-specific string onto it. So the URL of a key-value pair is the URL of the collection ("http://localhost:8000/1.0/pairs"), plus a slash, plus a resource-specific string (the key, "foo"). We use RootAbsoluteURL to calculate the URL of the root resource ("http://localhost:8000/1.0"), so the recursion has a place to bottom out. All we need is a way to know the parent of any given resource, and for any given resource, which string to stick on the end of the parent's URL. We do this by having all our classes (except for the root) implement a Zope interface called ILocation. With ILocation you just have to define two properties: __parent__ and __name__. __parent__ points to the object's parent in the resource tree, and __name__ is the portion of the URL space unique to this object. An object's URL is its __parent__'s url, plus a slash, plus its __name__. Here's an example. Consider the key-value pair at http://localhost:8000/1.0/pairs/foo. Its __name__ is the name of the key, "foo". Its __parent__ is the top-level collection of key-value pairs, found with getUtility(IPairSet). The collection's __name__ is "pairs", and its __parent__ is the service root. The service root gets its URL from RootAbsoluteURL (which gets most of its information from the web service configuration), which gives us "http://localhost:8000/1.0/". Put those pieces together, and you get "http://localhost:8000/1.0/pairs/foo". Configuration ============= The configuration class (defined in lazr.restful.example.wsgi.root) contains miscellaneous service-specific configuration settings. It's consulted it for miscellaneous things that depend on the web service and shouldn't be hard-coded. The most interesting configuration setting is probably service_version_uri_prefix, which controls the version number that shows up in URLs. The simplest way to create this class is to subclass lazr.restful.simple.BaseWebServiceConfiguration. You can see what configuration settings are available by looking at the interface definition class, lazr.restful.interfaces.IWebServiceConfiguration. Just have your subclass set any of the IWebServiceConfiguration values you want to change, and leave the rest alone. This web service overrides the IWebServiceConfiguration defaults for four settings: code_revision, service_version_uri_prefix, use_https, and view_permission. Utilities ========= The service root and the web service configuration object are both registered as Zope utilities. A Zope utility is basically a singleton. These two classes are registered as singletons because from time to time, lazr.restful needs access to a canonical instance of one of these classes. lazr.restful automatically registers utilities when you subclass certain classes. Subclass lazr.restful.simple.RootResource and your subclass will be registered as the "service root resource" utility. Subclass BaseWebServiceConfiguration and your subclass will be registered as the "web service configuration" utility. If you don't want to subclass these magic classes, you'll need to register the utilities yourself, using ZCML. See lazr/restful/example/base/ for a web service that uses ZCML to register utilities. [XXX leonardr 20090803 bug=407505 miscellaneous improvements to this doc.] lazr.restful-0.19.3/src/lazr/restful/example/wsgi/site.zcml0000644000175000017500000000072511631755356024141 0ustar benjibenji00000000000000 lazr.restful-0.19.3/src/lazr/restful/example/wsgi/resources.py0000644000175000017500000000360211631755356024667 0ustar benjibenji00000000000000__metaclass__ = type __all__ = ['IKeyValuePair', 'IPairSet', 'KeyValuePair', 'PairSet'] from zope.component import getUtility from zope.interface import Attribute, implements from zope.schema import Text from zope.location.interfaces import ILocation from lazr.restful.declarations import ( collection_default_content, export_as_webservice_collection, export_as_webservice_entry, exported) from lazr.restful.interfaces import IServiceRootResource from lazr.restful.simple import TraverseWithGet class IKeyValuePair(ILocation): export_as_webservice_entry() key = exported(Text(title=u"The key")) value = exported(Text(title=u"The value")) class IPairSet(ILocation): export_as_webservice_collection(IKeyValuePair) @collection_default_content() def getPairs(): """Return the key-value pairs.""" def get(request, name): """Retrieve a key-value pair by its key.""" class KeyValuePair(object): """An object representing a key-value pair""" implements(IKeyValuePair, ILocation) def __init__(self, set, key, value): self.set = set self.key = key self.value = value # ILocation implementation @property def __parent__(self): return self.set @property def __name__(self): return self.key class PairSet(TraverseWithGet): """A repository for key-value pairs.""" implements(IPairSet, ILocation) def __init__(self): self.pairs = [] def getPairs(self): return self.pairs def get(self, request, name): pairs = [pair for pair in self.pairs if pair.key == name] if len(pairs) == 1: return pairs[0] return None # ILocation implementation @property def __parent__(self): return getUtility(IServiceRootResource) @property def __name__(self): return 'pairs' lazr.restful-0.19.3/src/lazr/restful/publisher.py0000644000175000017500000003446411631755356022260 0ustar benjibenji00000000000000# Copyright 2009 Canonical Ltd. All rights reserved. """Publisher mixins for the webservice. This module defines classes that are usually needed for integration with the Zope publisher. """ __metaclass__ = type __all__ = [ 'browser_request_to_web_service_request', 'WebServicePublicationMixin', 'WebServiceRequestTraversal', ] import simplejson import urllib import urlparse from zope.component import ( adapter, getMultiAdapter, getUtility, queryAdapter, queryMultiAdapter, ) from zope.component.interfaces import ComponentLookupError from zope.interface import ( alsoProvides, implementer, implements, ) from zope.publisher.interfaces import NotFound from zope.publisher.interfaces.browser import IBrowserRequest from zope.schema.interfaces import IBytes from zope.security.checker import ProxyFactory from lazr.uri import URI from lazr.restful import ( CollectionResource, EntryField, EntryFieldResource, EntryResource, ScopedCollection, ) from lazr.restful.interfaces import ( IByteStorage, ICollection, ICollectionField, IEntry, IEntryField, IHTTPResource, INotificationsProvider, IReference, IServiceRootResource, IWebBrowserInitiatedRequest, IWebServiceClientRequest, IWebServiceConfiguration, ) from lazr.restful.utils import tag_request_with_version_name class WebServicePublicationMixin: """A mixin for webservice publication. This should usually be mixed-in with ZopePublication, or Browser, or HTTPPublication. """ def traverseName(self, request, ob, name): """See `zope.publisher.interfaces.IPublication`. In addition to the default traversal implementation, this publication also handles traversal to collection scoped into an entry. """ # If this is the last traversal step, then look first for a scoped # collection. This is done because although Navigation handles # traversal to entries in a scoped collection, they don't usually # handle traversing to the scoped collection itself. if len(request.getTraversalStack()) == 0: try: entry = getMultiAdapter((ob, request), IEntry) except ComponentLookupError: # This doesn't look like a lazr.restful object. Let # the superclass handle traversal. pass else: if name.endswith("_link"): # The user wants to access the link resource itself, # rather than the object on the other side of the link. if name.endswith("_collection_link"): schema_name = name[:-16] else: schema_name = name[:-5] field = entry.schema.get(schema_name) return EntryField(entry, field, name) field = entry.schema.get(name) if ICollectionField.providedBy(field): result = self._traverseToScopedCollection( request, entry, field, name) if result is not None: return result elif IBytes.providedBy(field): return self._traverseToByteStorage( request, entry, field, name) elif IReference.providedBy(field): sub_entry = getattr(entry, name, None) if sub_entry is None: raise NotFound(ob, name, request) else: return sub_entry elif field is not None: return EntryField(entry, field, name) else: # Falls through to our parent version. pass return super(WebServicePublicationMixin, self).traverseName( request, ob, name) def _traverseToByteStorage(self, request, entry, field, name): """Try to traverse to a byte storage resource in entry.""" # Even if the library file is None, we want to allow # traversal, because the request might be a PUT request # creating a file here. return getMultiAdapter((entry, field.bind(entry)), IByteStorage) def _traverseToScopedCollection(self, request, entry, field, name): """Try to traverse to a collection in entry. This is done because we don't usually traverse to attributes representing a collection in our regular Navigation. This method returns None if a scoped collection cannot be found. """ collection = getattr(entry, name, None) if collection is None: return None scoped_collection = ScopedCollection(entry.context, entry, request) # Tell the IScopedCollection object what collection it's managing, # and what the collection's relationship is to the entry it's # scoped to. scoped_collection.collection = collection scoped_collection.relationship = field return scoped_collection def getDefaultTraversal(self, request, ob): """See `zope.publisher.interfaces.browser.IBrowserPublication`. The WebService doesn't use the getDefaultTraversal() extension mechanism, because it only applies to GET, HEAD, and POST methods. See getResource() for the alternate mechanism. """ # Don't traverse to anything else. return ob, None def getResource(self, request, ob): """Return the resource that can publish the object ob. This is done at the end of traversal. If the published object supports the ICollection, or IEntry interface we wrap it into the appropriate resource. """ if (ICollection.providedBy(ob) or queryMultiAdapter((ob, request), ICollection) is not None): # Object supports ICollection protocol. resource = CollectionResource(ob, request) elif (IEntry.providedBy(ob) or queryMultiAdapter((ob, request), IEntry) is not None): # Object supports IEntry protocol. resource = EntryResource(ob, request) elif (IEntryField.providedBy(ob) or queryAdapter(ob, IEntryField) is not None): # Object supports IEntryField protocol. resource = EntryFieldResource(ob, request) elif queryMultiAdapter((ob, request), IHTTPResource) is not None: # Object can be adapted to a resource. resource = queryMultiAdapter((ob, request), IHTTPResource) elif IHTTPResource.providedBy(ob): # A resource knows how to take care of itself. return ob else: # This object should not be published on the web service. raise NotFound(ob, '') # Wrap the resource in a security proxy. return ProxyFactory(resource) def _processNotifications(self, request): """Add any notification messages to the response headers. If the webservice has defined an INotificationsProvider adaptor, use it to include with the response the relevant notification messages and their severity levels. """ notifications_provider = INotificationsProvider(request, None) notifications = [] if (notifications_provider is not None and notifications_provider.notifications): notifications = ([(notification.level, notification.message) for notification in notifications_provider.notifications]) json_notifications = simplejson.dumps(notifications) request.response.setHeader( 'X-Lazr-Notifications', json_notifications) def callObject(self, request, object): """Help web browsers handle redirects correctly.""" value = super( WebServicePublicationMixin, self).callObject(request, object) self._processNotifications(request) if request.response.getStatus() / 100 == 3: vhost = URI(request.getApplicationURL()).host if IWebBrowserInitiatedRequest.providedBy(request): # This request was (probably) sent by a web # browser. Because web browsers, content negotiation, # and redirects are a deadly combination, we're going # to help the browser out a little. # # We're going to take the current request's "Accept" # header and put it into the URL specified in the # Location header. When the web browser makes its # request, it will munge the original 'Accept' header, # but because the URL it's accessing will include the # old header in the "ws.accept" header, we'll still be # able to serve the right document. location = request.response.getHeader("Location", None) if location is not None: accept = request.getHeader("Accept", "application/json") qs_append = "ws.accept=" + urllib.quote(accept) # We don't use the URI class because it will raise # an exception if the Location contains invalid # characters. Invalid characters may indeed be a # problem, but let the problem be handled # somewhere else. (scheme, netloc, path, query, fragment) = ( urlparse.urlsplit(location)) if query == '': query = qs_append else: query += '&' + qs_append uri = urlparse.urlunsplit( (scheme, netloc, path, query, fragment)) request.response.setHeader("Location", str(uri)) return value class WebServiceRequestTraversal(object): """Mixin providing web-service resource wrapping in traversal. This should be mixed in the request using to the base publication used. """ implements(IWebServiceClientRequest) VERSION_ANNOTATION = 'lazr.restful.version' def traverse(self, ob): """See `zope.publisher.interfaces.IPublisherRequest`. This is called once at the beginning of the traversal process. WebService requests call the `WebServicePublication.getResource()` on the result of the base class's traversal. """ self._removeVirtualHostTraversals() # We don't trust the value of 'ob' passed in (it's probably # None) because the publication depends on which version of # the web service was requested. # _removeVirtualHostTraversals() has determined which version # was requested and has set the application appropriately, so # now we can get a good value for 'ob' and traverse it. ob = self.publication.getApplication(self) result = super(WebServiceRequestTraversal, self).traverse(ob) return self.publication.getResource(self, result) def _removeVirtualHostTraversals(self): """Remove the /[path_override] and /[version] traversal names.""" names = list() start_stack = list(self.getTraversalStack()) config = getUtility(IWebServiceConfiguration) if config.path_override is not None: api = self._popTraversal(config.path_override) if api is not None: names.append(api) # Requests that use the webservice path override are # usually made by web browsers. Mark this request as one # initiated by a web browser, for the sake of # optimizations later in the request lifecycle. alsoProvides(self, IWebBrowserInitiatedRequest) # Only accept versioned URLs. Any of the active_versions is # acceptable. version = None for version_string in config.active_versions: if version_string is not None: version = self._popTraversal(version_string) if version is not None: names.append(version) self.setVirtualHostRoot(names=names) break if version is None: raise NotFound(self, '', self) tag_request_with_version_name(self, version) # Find the appropriate service root for this version and set # the publication's application appropriately. try: # First, try to find a version-specific service root. service_root = getUtility(IServiceRootResource, name=self.version) except ComponentLookupError: # Next, try a version-independent service root. service_root = getUtility(IServiceRootResource) self.publication.application = service_root def _popTraversal(self, name=None): """Remove a name from the traversal stack, if it is present. :name: The string to look for in the stack, or None to accept any string. :return: The name of the element removed, or None if the stack wasn't changed. """ stack = self.getTraversalStack() if len(stack) > 0 and (name is None or stack[-1] == name): item = stack.pop() self.setTraversalStack(stack) return item return None @implementer(IWebServiceClientRequest) @adapter(IBrowserRequest) def browser_request_to_web_service_request( website_request, web_service_version=None): """An adapter from a browser request to a web service request. Used to instantiate Resource objects when handling normal web browser requests. """ config = getUtility(IWebServiceConfiguration) if web_service_version is None: web_service_version = config.active_versions[-1] body = website_request.bodyStream.getCacheStream() environ = dict(website_request.environment) # Zope picks up on SERVER_URL when setting the _app_server attribute # of the new request. environ['SERVER_URL'] = website_request.getApplicationURL() web_service_request = config.createRequest(body, environ) web_service_request.setVirtualHostRoot( names=[config.path_override, web_service_version]) tag_request_with_version_name(web_service_request, web_service_version) web_service_request._vh_root = website_request.getVirtualHostRoot() return web_service_request lazr.restful-0.19.3/src/lazr/restful/configure.zcml0000644000175000017500000002506711631755356022560 0ustar benjibenji00000000000000 lazr.restful-0.19.3/src/lazr/restful/tests/0000755000175000017500000000000011636155340021030 5ustar benjibenji00000000000000lazr.restful-0.19.3/src/lazr/restful/tests/test_utils.py0000644000175000017500000002311211631755356023610 0ustar benjibenji00000000000000# Copyright 2010 Canonical Ltd. All rights reserved. """Test for lazr.restful.utils.""" __metaclass__ = type import random import testtools import unittest from zope.publisher.browser import TestRequest from zope.security.management import ( endInteraction, newInteraction, queryInteraction, ) from lazr.restful.utils import ( extract_write_portion, get_current_browser_request, is_total_size_link_active, parse_accept_style_header, sorted_named_things, VersionedDict, ) class TestUtils(unittest.TestCase): def test_two_part_extract_write_portion(self): # ETags are sometimes two-part. A hyphen seperates the parts if so. self.assertEqual('write', extract_write_portion('read-write')) def test_one_part_extract_write_portion(self): # ETags are sometimes one-part. If so, writes are predicated on the # whole ETag. self.assertEqual('etag', extract_write_portion('etag')) def test_get_current_browser_request_no_interaction(self): # When there's no interaction setup, get_current_browser_request() # returns None. self.assertEquals(None, queryInteraction()) self.assertEquals(None, get_current_browser_request()) def test_get_current_browser_request(self): # When there is an interaction, it returns the interaction's request. request = TestRequest() newInteraction(request) self.assertEquals(request, get_current_browser_request()) endInteraction() def test_is_total_size_link_active(self): # Parts of the code want to know if the sizes of collections should be # reported in an attribute or via a link back to the service. The # is_total_size_link_active function takes the version of the API in # question and a web service configuration object and returns a # boolean that is true if a link should be used, false otherwise. # Here's the fake web service config we'll be using. class FakeConfig: active_versions = ['1.0', '2.0', '3.0'] first_version_with_total_size_link = '2.0' # First, if the version is lower than the threshold for using links, # the result is false (i.e., links should not be used). self.assertEqual(is_total_size_link_active('1.0', FakeConfig), False) # However, if the requested version is equal to, or higher than the # threshold, the result is true (i.e., links should be used). self.assertEqual(is_total_size_link_active('2.0', FakeConfig), True) self.assertEqual(is_total_size_link_active('3.0', FakeConfig), True) def test_name_sorter(self): # The WADL generation often sorts classes or functions by name; the # sorted_named_things helper... helps. # First we need some named things to sort. class Thing: pass def make_named_thing(): thing = Thing() thing.__name__ = random.choice('abcdefghijk') return thing # The function sorts on the object's __name__ attribute, which # functions and classes have, see: assert hasattr(Thing, '__name__') assert hasattr(make_named_thing, '__name__') # Now we can make a bunch of things with randomly ordered names and # show that sorting them does order them by name. things = sorted_named_things(make_named_thing() for i in range(10)) names = [thing.__name__ for thing in things] self.assertEqual(names, sorted(names)) # For the sake of convenience, test_get_current_web_service_request() # and tag_request_with_version_name() are tested in test_webservice.py. class TestVersionedDict(testtools.TestCase): def setUp(self): super(TestVersionedDict, self).setUp() self.dict = VersionedDict() def test_rename_version_works(self): # rename_version works when the given version exists. self.dict.push("original") self.dict.rename_version("original", "renamed") self.assertEquals(self.dict.dict_names, ["renamed"]) def test_rename_version_fails_given_nonexistent_version(self): # rename_version gives KeyError when the given version does # not exist. self.dict.push("original") self.assertRaises( KeyError, self.dict.rename_version, "not present", "renamed") def test_dict_for_name_finds_first_dict(self): # dict_for_name finds a dict with the given name in the stack. self.dict.push("name1") self.dict['key'] = 'value1' self.assertEquals( self.dict.dict_for_name('name1'), dict(key='value1')) def test_dict_for_name_finds_first_dict(self): # If there's more than one dict with a given name, # dict_for_name() finds the first one. self.dict.push("name1") self.dict['key'] = 'value1' self.dict.push("name2") self.dict.push("name1") self.dict['key'] = 'value2' self.assertEquals( self.dict.dict_for_name('name1'), dict(key='value1')) def test_dict_for_name_returns_None_if_no_such_name(self): # If there's no dict with the given name, dict_for_name # returns None. self.assertEquals(None, self.dict.dict_for_name("name1")) def test_dict_for_name_returns_default_if_no_such_name(self): # If there's no dict with the given name, and a default value # is provided, dict_for_name returns the default. obj = object() self.assertEquals(obj, self.dict.dict_for_name("name1", obj)) def test_normalize_for_versions_fills_in_blanks(self): # `normalize_for_versions` makes sure a VersionedDict has # an entry for every one of the given versions. self.dict.push("name2") self.dict['key'] = 'value' self.dict.normalize_for_versions(['name1', 'name2', 'name3']) self.assertEquals( self.dict.stack, [('name1', dict()), ('name2', dict(key='value')), ('name3', dict(key='value'))]) def test_normalize_for_versions_uses_default_dict(self): self.dict.push("name2") self.dict['key'] = 'value' self.dict.normalize_for_versions( ['name1', 'name2'], dict(default=True)) self.assertEquals( self.dict.stack, [('name1', dict(default=True)), ('name2', dict(key='value'))]) def test_normalize_for_versions_rejects_nonexistant_versions(self): self.dict.push("nosuchversion") exception = self.assertRaises( ValueError, self.dict.normalize_for_versions, ['name1']) self.assertEquals( str(exception), 'Unrecognized version "nosuchversion".') def test_normalize_for_versions_rejects_duplicate_versions(self): self.dict.push("name1") self.dict.push("name1") exception = self.assertRaises( ValueError, self.dict.normalize_for_versions, ['name1', 'name2']) self.assertEquals( str(exception), 'Duplicate definitions for version "name1".') def test_normalize_for_versions_rejects_misordered_versions(self): self.dict.push("name2") self.dict.push("name1") exception = self.assertRaises( ValueError, self.dict.normalize_for_versions, ['name1', 'name2']) self.assertEquals( str(exception), 'Version "name1" defined after the later version "name2".') def test_error_prefix_prepended_to_exception(self): self.dict.push("nosuchversion") exception = self.assertRaises( ValueError, self.dict.normalize_for_versions, ['name1'], error_prefix='Error test: ') self.assertEquals( str(exception), 'Error test: Unrecognized version "nosuchversion".') class TestParseAcceptStyleHeader(unittest.TestCase): def test_single_value(self): self.assertEquals(parse_accept_style_header("foo"), ["foo"]) def test_multiple_unodered_values(self): self.assertEquals( parse_accept_style_header("foo, bar"), ["foo", "bar"]) self.assertEquals( parse_accept_style_header("foo, bar,baz"), ["foo", "bar", "baz"]) def test_highest_quality_parameter_wins(self): self.assertEquals( parse_accept_style_header("foo;q=0.001, bar;q=0.05, baz;q=0.1"), ["baz", "bar", "foo"]) def test_quality_zero_is_omitted(self): self.assertEquals( parse_accept_style_header("foo;q=0, bar;q=0.5"), ["bar"]) def test_duplicate_values_are_collapsed(self): self.assertEquals( parse_accept_style_header("foo;q=0.1, foo;q=0.5, bar;q=0.3"), ["foo", "bar"]) def test_no_quality_parameter_is_implicit_one_point_zero(self): self.assertEquals( parse_accept_style_header("foo;q=0.5, bar"), ["bar", "foo"]) def test_standalone_parameter_is_untouched(self): self.assertEquals( parse_accept_style_header("foo;a=0.5"), ["foo;a=0.5"]) def test_quality_parameter_is_removed_next_parameter_is_untouched(self): self.assertEquals( parse_accept_style_header("foo;a=bar;q=0.5"), ["foo;a=bar"]) def test_quality_parameter_is_removed_earlier_parameter_is_untouched(self): self.assertEquals( parse_accept_style_header("foo;q=0.5;a=bar"), ["foo;a=bar"]) def test_quality_parameter_is_removed_surrounding_parameters_are_untouched(self): self.assertEquals( parse_accept_style_header("foo;a=bar;q=0.5;b=baz"), ["foo;a=bar;b=baz"]) lazr.restful-0.19.3/src/lazr/restful/tests/test_declarations.py0000644000175000017500000012016211631755356025123 0ustar benjibenji00000000000000# Copyright 2008-2011 Canonical Ltd. All rights reserved. """Unit tests for the conversion of interfaces into a web service.""" from zope.configuration.config import ConfigurationExecutionError from zope.component import ( adapts, getMultiAdapter, getSiteManager, getUtility, ) from zope.component.interfaces import ComponentLookupError from zope.interface import ( Attribute, implements, Interface, ) from zope.publisher.interfaces.http import IHTTPRequest from zope.schema import ( Int, TextLine, ) from zope.security.checker import ( MultiChecker, ProxyFactory, ) from zope.security.management import ( endInteraction, newInteraction, ) from lazr.restful.declarations import ( accessor_for, call_with, export_as_webservice_entry, exported, export_read_operation, export_write_operation, generate_entry_interfaces, generate_operation_adapter, LAZR_WEBSERVICE_NAME, mutator_for, operation_for_version, operation_parameters, operation_returns_collection_of, operation_returns_entry, ) from lazr.restful.fields import ( CollectionField, Reference, ) from lazr.restful.interfaces import ( IEntry, IResourceGETOperation, IWebServiceConfiguration, ) from lazr.restful.marshallers import SimpleFieldMarshaller from lazr.restful.metazcml import ( AttemptToContributeToNonExportedInterface, ConflictInContributingInterfaces, find_interfaces_and_contributors, generate_and_register_webservice_operations, ) from lazr.restful._resource import ( EntryAdapterUtility, EntryResource, ) from lazr.restful.testing.webservice import TestCaseWithWebServiceFixtures from lazr.restful.testing.helpers import ( create_test_module, register_test_module, ) from lazr.restful.utils import VersionedObject class ContributingInterfacesTestCase(TestCaseWithWebServiceFixtures): """Tests for interfaces that contribute fields/operations to others.""" def setUp(self): super(ContributingInterfacesTestCase, self).setUp() sm = getSiteManager() sm.registerAdapter(ProductToHasBugsAdapter) sm.registerAdapter(ProjectToHasBugsAdapter) sm.registerAdapter(ProductToHasBranchesAdapter) sm.registerAdapter(DummyFieldMarshaller) self.one_zero_request = self.fake_request('1.0') self.two_zero_request = self.fake_request('2.0') self.product = Product() self.project = Project() def test_attributes(self): # The bug_count field comes from IHasBugs (which IProduct does not # provide, although it can be adapted into) but that field is # available in the webservice (IEntry) adapter for IProduct, and that # adapter knows it needs to adapt the product into an IHasBugs to # access .bug_count. self.product._bug_count = 10 register_test_module('testmod', IProduct, IHasBugs) adapter = getMultiAdapter((self.product, self.one_zero_request), IEntry) self.assertEqual(adapter.bug_count, 10) def test_operations(self): # Although getBugsCount() is not provided by IProduct, it is available # on the webservice adapter as IHasBugs contributes it to IProduct. self.product._bug_count = 10 register_test_module('testmod', IProduct, IHasBugs) adapter = getMultiAdapter( (self.product, self.one_zero_request), IResourceGETOperation, name='getBugsCount') self.assertEqual(adapter(), '10') def test_accessor_for(self): # The accessor_for annotator can be used to mark a method as an # accessor for an attribute. This allows methods with bound # parameters to be used in place of properties. self.product._branches = [ Branch('A branch'), Branch('Another branch')] register_test_module('testmod', IBranch, IProduct, IHasBranches) adapter = getMultiAdapter( (self.product, self.one_zero_request), IEntry) self.assertEqual(adapter.branches, self.product._branches) def test_accessor_for_at_n_included_at_n_plus_1(self): # An accessor_for, when defined for version N, will also appear # in version N+1. self.product._branches = [ Branch('A branch'), Branch('Another branch')] register_test_module('testmod', IBranch, IProduct, IHasBranches) adapter_10 = getMultiAdapter( (self.product, self.one_zero_request), IEntry) adapter_20 = getMultiAdapter( (self.product, self.two_zero_request), IEntry) self.assertEqual(adapter_10.branches, adapter_20.branches) def test_only_one_accessor_for_version(self): # There can only be one accessor defined for a given attribute # at a given version. def declare_too_many_accessors(): class IHasTooManyAccessors(Interface): export_as_webservice_entry(contributes_to=[IProduct]) needs_an_accessor = exported( TextLine(title=u'This needs an accessor', readonly=True)) @accessor_for(needs_an_accessor) def get_whatever_it_is(self): pass @accessor_for(needs_an_accessor) def get_whatever_it_is_again(self): pass self.assertRaises(TypeError, declare_too_many_accessors) def test_accessors_can_be_superseded(self): # Separate accessors can be declared for different versions of # an interface. self.product._bug_target_name = "Bug target name" register_test_module('testmod', IProduct, IHasBugs) adapter_10 = getMultiAdapter( (self.product, self.one_zero_request), IEntry) adapter_20 = getMultiAdapter( (self.product, self.two_zero_request), IEntry) self.assertEqual( adapter_10.bug_target_name, self.product._bug_target_name) self.assertEqual( adapter_20.bug_target_name, self.product._bug_target_name + " version 2.0") def test_accessor_and_mutator_together(self): # accessor_for and mutator_for can be used together on a field # to present a field that can be manipulated as though it's an # attribute whilst using local method calls under-the-hood. register_test_module('testmod', IProduct, IHasBugs) adapter = getMultiAdapter( (self.product, self.one_zero_request), IEntry) adapter.bug_target_name = "A bug target name!" self.assertEqual( "A bug target name!", adapter.context._bug_target_name) self.assertEqual("A bug target name!", adapter.bug_target_name) def test_contributing_interface_with_differences_between_versions(self): # In the '1.0' version, IHasBranches.development_branches is exported # with its original name whereas for the '2.0' version it's exported # as 'development_branch_20'. self.product._dev_branch = Branch('A product branch') register_test_module('testmod', IBranch, IProduct, IHasBranches) adapter = getMultiAdapter((self.product, self.one_zero_request), IEntry) self.assertEqual(adapter.development_branch, self.product._dev_branch) adapter = getMultiAdapter( (self.product, self.two_zero_request), IEntry) self.assertEqual( adapter.development_branch_20, self.product._dev_branch) def test_mutator_for_just_one_version(self): # On the '1.0' version, IHasBranches contributes a read only # development_branch field, but on version '2.0' that field can be # modified as we define a mutator for it. self.product._dev_branch = Branch('A product branch') register_test_module('testmod', IBranch, IProduct, IHasBranches) adapter = getMultiAdapter((self.product, self.one_zero_request), IEntry) try: adapter.development_branch = None except AttributeError: pass else: self.fail('IHasBranches.development_branch should be read-only ' 'on the 1.0 version') adapter = getMultiAdapter( (self.product, self.two_zero_request), IEntry) self.assertEqual( adapter.development_branch_20, self.product._dev_branch) adapter.development_branch_20 = None self.assertEqual( adapter.development_branch_20, None) def test_contributing_to_multiple_interfaces(self): # Check that the webservice adapter for both IProduct and IProject # have the IHasBugs attributes, as that interface contributes to them. self.product._bug_count = 10 self.project._bug_count = 100 register_test_module('testmod', IProduct, IProject, IHasBugs) adapter = getMultiAdapter((self.product, self.one_zero_request), IEntry) self.assertEqual(adapter.bug_count, 10) adapter = getMultiAdapter((self.project, self.one_zero_request), IEntry) self.assertEqual(adapter.bug_count, 100) def test_multiple_contributing_interfaces(self): # Check that the webservice adapter for IProduct has the attributes # from both IHasBugs and IHasBranches. self.product._bug_count = 10 self.product._dev_branch = Branch('A product branch') register_test_module( 'testmod', IBranch, IProduct, IHasBugs, IHasBranches) adapter = getMultiAdapter((self.product, self.one_zero_request), IEntry) self.assertEqual(adapter.bug_count, 10) self.assertEqual(adapter.development_branch, self.product._dev_branch) def test_redacted_fields_for_empty_entry(self): # An entry which doesn't export any fields/operations will have no # redacted fields. class IEmpty(Interface): export_as_webservice_entry() class Empty: implements(IEmpty) register_test_module('testmod', IEmpty) entry_resource = EntryResource(Empty(), self.one_zero_request) self.assertEquals({}, entry_resource.entry._orig_interfaces) self.assertEquals([], entry_resource.redacted_fields) def test_redacted_fields_with_no_permission_checker(self): # When looking up an entry's redacted_fields, we take into account the # interface where the field is defined and adapt the context to that # interface before accessing that field. register_test_module( 'testmod', IBranch, IProduct, IHasBugs, IHasBranches) entry_resource = EntryResource(self.product, self.one_zero_request) self.assertEquals([], entry_resource.redacted_fields) def test_redacted_fields_with_permission_checker(self): # When looking up an entry's redacted_fields for an object which is # security proxied, we use the security checker for the interface # where the field is defined. register_test_module( 'testmod', IBranch, IProduct, IHasBugs, IHasBranches) newInteraction() try: secure_product = ProxyFactory( self.product, checker=MultiChecker([(IProduct, 'zope.Public')])) entry_resource = EntryResource(secure_product, self.one_zero_request) self.assertEquals([], entry_resource.redacted_fields) finally: endInteraction() def test_duplicate_contributed_attributes(self): # We do not allow a given attribute to be contributed to a given # interface by more than one contributing interface. testmod = create_test_module( 'testmod', IBranch, IProduct, IHasBugs, IHasBugs2) self.assertRaises( ConflictInContributingInterfaces, find_interfaces_and_contributors, testmod) def test_contributing_interface_not_exported(self): # Contributing interfaces are not exported by themselves -- they only # contribute their exported fields/operations to other entries. class DummyHasBranches: implements(IHasBranches) dummy = DummyHasBranches() register_test_module('testmod', IBranch, IProduct, IHasBranches) self.assertRaises( ComponentLookupError, getMultiAdapter, (dummy, self.one_zero_request), IEntry) def test_cannot_contribute_to_non_exported_interface(self): # A contributing interface can only contribute to exported interfaces. class INotExported(Interface): pass class IContributor(Interface): export_as_webservice_entry(contributes_to=[INotExported]) title = exported(TextLine(title=u'The project title')) testmod = create_test_module('testmod', IContributor, INotExported) self.assertRaises( AttemptToContributeToNonExportedInterface, find_interfaces_and_contributors, testmod) def test_duplicate_contributed_methods(self): # We do not allow a given method to be contributed to a given # interface by more than one contributing interface. testmod = create_test_module('testmod', IProduct, IHasBugs, IHasBugs3) self.assertRaises( ConflictInContributingInterfaces, find_interfaces_and_contributors, testmod) def test_ConflictInContributingInterfaces(self): # The ConflictInContributingInterfaces exception states what are the # contributing interfaces that caused the conflict. e = ConflictInContributingInterfaces('foo', [IHasBugs, IHasBugs2]) expected_msg = ("'foo' is exported in more than one contributing " "interface: IHasBugs, IHasBugs2") self.assertEquals(str(e), expected_msg) def test_type_name(self): # Even though the generated adapters will contain stuff from various # different adapters, its type name is that of the main interface and # not one of its contributors. register_test_module('testmod', IProduct, IHasBugs) adapter = getMultiAdapter((self.product, self.one_zero_request), IEntry) self.assertEqual( 'product', EntryAdapterUtility(adapter.__class__).singular_type) class IProduct(Interface): export_as_webservice_entry() title = exported(TextLine(title=u'The product title')) # Need to define the three attributes below because we have a test which # wraps a Product object with a security proxy and later uses adapters # that access _dev_branch, _branches and _bug_count. _dev_branch = Attribute('dev branch') _bug_count = Attribute('bug count') _branches = Attribute('branches') _bug_target_name = Attribute('bug_target_name') class Product(object): implements(IProduct) title = 'A product' _bug_count = 0 _dev_branch = None _branches = [] _bug_target_name = None class IProject(Interface): export_as_webservice_entry() title = exported(TextLine(title=u'The project title')) class Project(object): implements(IProject) title = 'A project' _bug_count = 0 class IHasBugs(Interface): export_as_webservice_entry(contributes_to=[IProduct, IProject]) bug_count = exported(Int(title=u'Number of bugs')) not_exported = TextLine(title=u'Not exported') bug_target_name = exported( TextLine(title=u"The bug target name of this object.", readonly=True)) @export_read_operation() def getBugsCount(): pass @mutator_for(bug_target_name) @export_write_operation() @call_with(bound_variable="A string") @operation_parameters( bound_variable=TextLine(), new_value=TextLine()) @operation_for_version('1.0') def set_bug_target_name(new_value, bound_variable): pass @accessor_for(bug_target_name) @call_with(bound_value="A string") @export_read_operation() @operation_parameters(bound_value=TextLine()) @operation_for_version('1.0') def get_bug_target_name(bound_value): pass @accessor_for(bug_target_name) @call_with(bound_value="A string") @export_read_operation() @operation_parameters(bound_value=TextLine()) @operation_for_version('2.0') def get_bug_target_name_20(bound_value): pass class IHasBugs2(Interface): export_as_webservice_entry(contributes_to=[IProduct]) bug_count = exported(Int(title=u'Number of bugs')) not_exported = TextLine(title=u'Not exported') class IHasBugs3(Interface): export_as_webservice_entry(contributes_to=[IProduct]) not_exported = TextLine(title=u'Not exported') @export_read_operation() def getBugsCount(): pass class ProductToHasBugsAdapter(object): adapts(IProduct) implements(IHasBugs) def __init__(self, context): self.context = context self.bug_count = context._bug_count def getBugsCount(self): return self.bug_count def get_bug_target_name(self, bound_value): return self.context._bug_target_name def get_bug_target_name_20(self, bound_value): return self.context._bug_target_name + " version 2.0" def set_bug_target_name(self, value, bound_variable): self.context._bug_target_name = value class ProjectToHasBugsAdapter(ProductToHasBugsAdapter): adapts(IProject) class IBranch(Interface): export_as_webservice_entry() name = TextLine(title=u'The branch name') class Branch(object): implements(IBranch) def __init__(self, name): self.name = name class IHasBranches(Interface): export_as_webservice_entry(contributes_to=[IProduct]) not_exported = TextLine(title=u'Not exported') development_branch = exported( Reference(schema=IBranch, readonly=True), ('2.0', dict(exported_as='development_branch_20')), ('1.0', dict(exported_as='development_branch'))) branches = exported( CollectionField( value_type=Reference(schema=IBranch, readonly=True))) @mutator_for(development_branch) @export_write_operation() @operation_parameters(value=TextLine()) @operation_for_version('2.0') def set_dev_branch(value): pass @accessor_for(branches) @call_with(value="A string") @export_read_operation() @operation_parameters(value=TextLine()) def get_branches(value): pass class ProductToHasBranchesAdapter(object): adapts(IProduct) implements(IHasBranches) def __init__(self, context): self.context = context @property def development_branch(self): return self.context._dev_branch def set_dev_branch(self, value): self.context._dev_branch = value def get_branches(self, value): return self.context._branches # One of our tests will try to unmarshall some entries, but even though we # don't care about the unmarshalling itself, we need to register a generic # marshaller so that the adapter lookup doesn't fail and cause an error on the # test. class DummyFieldMarshaller(SimpleFieldMarshaller): adapts(Interface, IHTTPRequest) # Classes for TestEntryMultiversion. class INotInitiallyExported(Interface): # An entry that's not exported in the first version of the web # service. export_as_webservice_entry(as_of="2.0") class INotPresentInLaterVersion(Interface): # An entry that's only exported in the first version of the web # service. export_as_webservice_entry( versioned_annotations=[('2.0', dict(exported=False))]) class IHasDifferentNamesInDifferentVersions(Interface): # An entry that has different names in different versions. export_as_webservice_entry( singular_name="octopus", plural_name="octopi", versioned_annotations=[ ('2.0', dict(singular_name="fish", plural_name="fishes"))]) class IHasDifferentSingularNamesInDifferentVersions(Interface): # An entry that has different names in different versions. export_as_webservice_entry( singular_name="frog", versioned_annotations=[('2.0', dict(singular_name="toad"))]) class TestEntryMultiversion(TestCaseWithWebServiceFixtures): """Test the ability to export an entry only in certain versions.""" def test_not_initially_exported(self): # INotInitiallyExported is published in version 2.0 but not in 1.0. interfaces = generate_entry_interfaces( INotInitiallyExported, [], *getUtility(IWebServiceConfiguration).active_versions) self.assertEquals(len(interfaces), 1) self.assertEquals(interfaces[0].version, '2.0') def test_not_exported_in_later_version(self): # INotPresentInLaterVersion is published in version 1.0 but # not in 2.0. interfaces = generate_entry_interfaces( INotPresentInLaterVersion, [], *getUtility(IWebServiceConfiguration).active_versions) self.assertEquals(len(interfaces), 1) tags = interfaces[0][1].getTaggedValue(LAZR_WEBSERVICE_NAME) self.assertEquals(interfaces[0].version, '1.0') def test_different_names_in_different_versions(self): # IHasDifferentNamesInDifferentVersions is called # octopus/octopi in 1.0, and fish/fishes in 2.0. interfaces = generate_entry_interfaces( IHasDifferentNamesInDifferentVersions, [], *getUtility(IWebServiceConfiguration).active_versions) interface_10 = interfaces[0].object tags_10 = interface_10.getTaggedValue(LAZR_WEBSERVICE_NAME) self.assertEquals('octopus', tags_10['singular']) self.assertEquals('octopi', tags_10['plural']) interface_20 = interfaces[1].object tags_20 = interface_20.getTaggedValue(LAZR_WEBSERVICE_NAME) self.assertEquals('fish', tags_20['singular']) self.assertEquals('fishes', tags_20['plural']) def test_nonexistent_annotation_fails(self): # You can't define an entry class that includes an unrecognized # annotation in its versioned_annotations. try: class IUsesNonexistentAnnotation(Interface): export_as_webservice_entry( versioned_annotations=[ ('2.0', dict(no_such_annotation=True))]) self.fail("Expected ValueError.") except ValueError, exception: self.assertEquals( str(exception), 'Unrecognized annotation for version "2.0": ' '"no_such_annotation"') def test_changing_singular_also_changes_plural(self): # IHasDifferentSingularNamesInDifferentVersions defines the # singular name 'frog' in 1.0, and 'toad' in 2.0. This test # makes sure that the plural of 'toad' is not 'frogs'. interfaces = generate_entry_interfaces( IHasDifferentSingularNamesInDifferentVersions, [], *getUtility(IWebServiceConfiguration).active_versions) interface_10 = interfaces[0].object tags_10 = interface_10.getTaggedValue(LAZR_WEBSERVICE_NAME) self.assertEquals('frog', tags_10['singular']) self.assertEquals('frogs', tags_10['plural']) interface_20 = interfaces[1].object tags_20 = interface_20.getTaggedValue(LAZR_WEBSERVICE_NAME) self.assertEquals('toad', tags_20['singular']) self.assertEquals('toads', tags_20['plural']) # Classes for TestReqireExplicitVersions class IEntryExportedWithoutAsOf(Interface): export_as_webservice_entry() class IEntryExportedWithAsOf(Interface): export_as_webservice_entry(as_of="1.0") class IEntryExportedAsOfLaterVersion(Interface): export_as_webservice_entry(as_of="2.0") class IFieldExportedWithoutAsOf(Interface): export_as_webservice_entry(as_of="1.0") field = exported(TextLine(), exported=True) class IFieldExportedToEarliestVersionUsingAsOf(Interface): export_as_webservice_entry(as_of="1.0") field = exported(TextLine(), as_of='1.0') class IFieldExportedToLatestVersionUsingAsOf(Interface): export_as_webservice_entry(as_of="1.0") field = exported(TextLine(), as_of='2.0') class IFieldDefiningAttributesBeforeAsOf(Interface): export_as_webservice_entry(as_of="1.0") field = exported(TextLine(), ('1.0', dict(exported=True)), as_of='2.0') class IFieldAsOfNonexistentVersion(Interface): export_as_webservice_entry(as_of="1.0") field = exported(TextLine(), as_of='nosuchversion') class IFieldDoubleDefinition(Interface): export_as_webservice_entry(as_of="1.0") field = exported(TextLine(), ('2.0', dict(exported_as='name2')), exported_as='name2', as_of='2.0') class IFieldImplicitOperationDefinition(Interface): export_as_webservice_entry(as_of="1.0") @call_with(value="2.0") @operation_for_version('2.0') @call_with(value="1.0") @export_read_operation() def implicitly_in_10(value): pass class IFieldExplicitOperationDefinition(Interface): export_as_webservice_entry(as_of="1.0") @call_with(value="2.0") @operation_for_version('2.0') @call_with(value="1.0") @export_read_operation() @operation_for_version('1.0') def explicitly_in_10(value): pass class FieldExplicitOperationDefinition(object): implements(IFieldExplicitOperationDefinition) class TestRequireExplicitVersions(TestCaseWithWebServiceFixtures): """Test behavior when require_explicit_versions is True.""" def setUp(self): super(TestRequireExplicitVersions, self).setUp() self.utility = getUtility(IWebServiceConfiguration) self.utility.require_explicit_versions = True def test_entry_exported_with_as_of_succeeds(self): # An entry exported using as_of is present in the as_of_version # and in subsequent versions. interfaces = generate_entry_interfaces( IEntryExportedWithAsOf, [], *self.utility.active_versions) self.assertEquals(len(interfaces), 2) self.assertEquals(interfaces[0].version, '1.0') self.assertEquals(interfaces[1].version, '2.0') def test_entry_exported_as_of_later_version_succeeds(self): # An entry exported as_of a later version is not present in # earlier versions. interfaces = generate_entry_interfaces( IEntryExportedAsOfLaterVersion, [], *self.utility.active_versions) self.assertEquals(len(interfaces), 1) self.assertEquals(interfaces[0].version, '2.0') def test_entry_exported_without_as_of_fails(self): exception = self.assertRaises( ValueError, generate_entry_interfaces, IEntryExportedWithoutAsOf, [], *self.utility.active_versions) self.assertEquals( str(exception), 'Entry "IEntryExportedWithoutAsOf": Exported in version 1.0, ' 'but not by using as_of. The service configuration requires ' 'that you use as_of.') def test_field_exported_as_of_earlier_version_is_exported_in_subsequent_versions(self): # If you export a field as_of version 1.0, it's present in # 1.0's version of the entry and in all subsequent versions. interfaces = generate_entry_interfaces( IFieldExportedToEarliestVersionUsingAsOf, [], *self.utility.active_versions) interface_10 = interfaces[0].object interface_20 = interfaces[1].object self.assertEquals(interface_10.names(), ['field']) self.assertEquals(interface_20.names(), ['field']) def test_field_exported_as_of_later_version_is_not_exported_in_earlier_versions(self): # If you export a field as_of version 2.0, it's not present in # 1.0's version of the entry. interfaces = generate_entry_interfaces( IFieldExportedToLatestVersionUsingAsOf, [], *self.utility.active_versions) interface_10 = interfaces[0].object interface_20 = interfaces[1].object self.assertEquals(interface_10.names(), []) self.assertEquals(interface_20.names(), ['field']) def test_field_not_exported_using_as_of_fails(self): # If you export a field without specifying as_of, you get an # error. exception = self.assertRaises( ValueError, generate_entry_interfaces, IFieldExportedWithoutAsOf, [], *self.utility.active_versions) self.assertEquals( str(exception), ('Field "field" in interface "IFieldExportedWithoutAsOf": ' 'Exported in version 1.0, but not by using as_of. ' 'The service configuration requires that you use as_of.') ) def test_field_cannot_be_both_exported_and_not_exported(self): # If you use the as_of keyword argument, you can't also set # the exported keyword argument to False. exception = self.assertRaises( ValueError, exported, TextLine(), as_of='1.0', exported=False) self.assertEquals( str(exception), ('as_of=1.0 says to export TextLine, but exported=False ' 'says not to.')) def test_field_exported_as_of_nonexistent_version_fails(self): # You can't export a field as_of a nonexistent version. exception = self.assertRaises( ValueError, generate_entry_interfaces, IFieldAsOfNonexistentVersion, [], *self.utility.active_versions) self.assertEquals( str(exception), ('Field "field" in interface "IFieldAsOfNonexistentVersion": ' 'Unrecognized version "nosuchversion".')) def test_field_exported_with_duplicate_attributes_fails(self): # You can't provide a dictionary of attributes for the # version specified in as_of. exception = self.assertRaises( ValueError, generate_entry_interfaces, IFieldDoubleDefinition, [], *self.utility.active_versions) self.assertEquals( str(exception), ('Field "field" in interface "IFieldDoubleDefinition": ' 'Duplicate definitions for version "2.0".')) def test_field_with_annotations_that_precede_as_of_fails(self): # You can't provide a dictionary of attributes for a version # preceding the version specified in as_of. exception = self.assertRaises( ValueError, generate_entry_interfaces, IFieldDefiningAttributesBeforeAsOf, [], *self.utility.active_versions) self.assertEquals( str(exception), ('Field "field" in interface ' '"IFieldDefiningAttributesBeforeAsOf": Version "1.0" defined ' 'after the later version "2.0".')) def test_generate_operation_adapter_for_none_fails(self): # You can't pass None as the version to # generate_operation_adapter(). This doesn't happen in normal # usage because the metazcml will always pass in a real # version number, but we test it just to make sure it'll catch # the problem. method = [method for name, method in IFieldImplicitOperationDefinition.namesAndDescriptions() if name == 'implicitly_in_10'][0] exception = self.assertRaises( ValueError, generate_operation_adapter, method, None) self.assertEquals( str(exception), '"implicitly_in_10" is implicitly tagged for export to web ' 'service version "1.0", but the service configuration requires ' 'all version declarations to be explicit. You should add ' '@operation_for_version("1.0") to the bottom of the ' 'annotation stack.') def test_operation_implicitly_exported_in_earliest_version_fails(self): # You can't implicitly define an operation for the earliest version. exception = self.assertRaises( ValueError, generate_and_register_webservice_operations, None, IFieldImplicitOperationDefinition, []) self.assertEquals( str(exception), ('IFieldImplicitOperationDefinition.implicitly_in_10: ' 'Implicitly tagged for export to web service version "1.0", ' 'but the service configuration requires all version ' 'declarations to be explicit. You should add ' '@operation_for_version("1.0") to the bottom of the ' 'annotation stack.')) def test_operation_explicitly_exported_in_earliest_version_succeeds(self): # You can explicitly define an operation for the earliest version. # Unlike the previous test, we can't just call # generate_and_register_webservice_operations: we need to have # a ZCML context. register_test_module('testmod', IFieldExplicitOperationDefinition) context = FieldExplicitOperationDefinition() adapter = getMultiAdapter( (context, self.fake_request('1.0')), IResourceGETOperation, name='explicitly_in_10') self.assertEquals( adapter.__class__.__name__, 'GET_IFieldExplicitOperationDefinition_explicitly_in_10_1_0') # Classes for TestSanityChecking class INotPublished(Interface): pass class IReferencesNotPublished(Interface): export_as_webservice_entry() field = exported(Reference(schema=INotPublished)) class IReferencesCollectionOfNotPublished(Interface): export_as_webservice_entry() field = exported( CollectionField(value_type=Reference(schema=INotPublished))) class IOperationReturnsNotPublished(Interface): export_as_webservice_entry() @operation_returns_entry(INotPublished) @export_read_operation() def get_impossible_object(): pass class IOperationReturnsNotPublishedCollection(Interface): export_as_webservice_entry() @operation_returns_collection_of(INotPublished) @export_read_operation() def get_impossible_objects(): pass class IOperationAcceptsNotPublished(Interface): export_as_webservice_entry() @operation_parameters(arg=Reference(schema=INotPublished)) @export_write_operation() def use_impossible_object(arg): pass class IOperationAcceptsCollectionOfNotPublished(Interface): export_as_webservice_entry() @operation_parameters( arg=CollectionField(value_type=Reference(schema=INotPublished))) @export_write_operation() def use_impossible_objects(arg): pass class IPublishedTooLate(Interface): export_as_webservice_entry(as_of='2.0') class IReferencesPublishedTooLate(Interface): export_as_webservice_entry(as_of='1.0') field = exported(Reference(schema=IPublishedTooLate)) class IPublishedEarly(Interface): export_as_webservice_entry(as_of='1.0') class IPublishedLate(Interface): export_as_webservice_entry(as_of='2.0') field = exported(Reference(schema=IPublishedEarly)) class IPublishedAndThenRemoved(Interface): export_as_webservice_entry( as_of='1.0', versioned_annotations=[ VersionedObject('2.0', dict(exported=False))]) class IReferencesPublishedAndThenRemoved(Interface): export_as_webservice_entry(as_of='1.0') field = exported(Reference(schema=IPublishedAndThenRemoved)) class TestSanityChecking(TestCaseWithWebServiceFixtures): """Test lazr.restful's sanity checking upon web service registration.""" def _test_fails_sanity_check( self, expect_failure_in_version, expect_failure_for_reason, expect_failure_due_to_interface, *classes): """Verify that the given interfaces can't become a web service. The given set of interfaces are expected to fail the sanity check because they include an annotation that makes some version of the web service reference an entry not defined in that version (or at all). :param expect_failure_in_version: Which version of the web service will fail the sanity check. :param expect_failure_for_reason: The reason that will be given for failing the sanity check. :param expect_failure_due_to_interface: The interface that will cause the failure due to not being published in `expect_failure_in_version`. :param classes: The interfaces to attempt to publish as a web service. `expect_failure_due_to_interface` will be added to this list, so there's no need to specify it again. """ exception = self.assertRaises( ValueError, register_test_module, 'testmod', *(list(classes) + [expect_failure_due_to_interface])) expected_message = ( "In version %(version)s, %(reason)s, but version %(version)s " "of the web service does not publish %(interface)s as an entry. " "(It may not be published at all.)" % dict( version=expect_failure_in_version, reason=expect_failure_for_reason, interface=expect_failure_due_to_interface.__name__)) self.assertEquals(str(exception), expected_message) def test_reference_to_unpublished_object_fails(self): self._test_fails_sanity_check( '1.0', ("IReferencesNotPublishedEntry_1_0.field is INotPublished"), INotPublished, IReferencesNotPublished) def test_reference_to_object_published_later_fails(self): self._test_fails_sanity_check( '1.0', ("IReferencesPublishedTooLateEntry_1_0.field is " "IPublishedTooLate"), IPublishedTooLate, IReferencesPublishedTooLate) def test_reference_to_object_published_and_then_removed_fails(self): # This setup is acceptable in version 1.0, but in version 2.0 # IPublishedAndThenRemoved is gone. This puts # IReferencesPublishedAndThenRemoved in violation of the # sanity check in 2.0, even though it hasn't changed since 1.0. self._test_fails_sanity_check( '2.0', ("IReferencesPublishedAndThenRemovedEntry_2_0.field is " "IPublishedAndThenRemoved"), IPublishedAndThenRemoved, IReferencesPublishedAndThenRemoved) def test_reference_to_object_published_earlier_succeeds(self): # It's okay for an object defined in 2.0 to reference an # object first defined in 1.0, so long as the referenced # object is also present in 2.0. # We'll call this test a success if it doesn't raise an exception. module = register_test_module( 'testmod', IPublishedEarly, IPublishedLate) # At this point we've tested all the ways entries might not be # published in a given version: they might never be published, # they might be published later, or they might be published # earlier and then removed. # Now we need to test all the places an unpublished entry might # show up, not counting 'as the referent of a Reference field', # which we've already tested. def test_reference_to_collection_of_unpublished_objects_fails(self): # An entry may define a field that's a scoped collection of # unpublished entries. self._test_fails_sanity_check( '1.0', ("IReferencesCollectionOfNotPublishedEntry_1_0.field is a " "collection of INotPublished"), INotPublished, IReferencesCollectionOfNotPublished) def test_operation_returning_unpublished_object_fails(self): # An operation may return an unpublished entry. self._test_fails_sanity_check( '1.0', ("named operation get_impossible_object returns INotPublished"), INotPublished, IOperationReturnsNotPublished) def test_operation_returning_collection_of_unpublished_object_fails(self): # An operation may return a collection of unpublished entries. self._test_fails_sanity_check( '1.0', ("named operation get_impossible_objects returns a collection " "of INotPublished"), INotPublished, IOperationReturnsNotPublishedCollection) def test_operation_taking_unpublished_argument_fails(self): # An operation may take an unpublished entry as an argument. self._test_fails_sanity_check( '1.0', ("named operation use_impossible_object accepts INotPublished"), INotPublished, IOperationAcceptsNotPublished) def test_operation_taking_unpublished_collection_argument_fails(self): # An operation may take a collection of unpublished entries as # an argument. self._test_fails_sanity_check( '1.0', ("named operation use_impossible_objects accepts a collection " "of INotPublished"), INotPublished, IOperationAcceptsCollectionOfNotPublished) lazr.restful-0.19.3/src/lazr/restful/tests/test_error.py0000644000175000017500000002337711636132167023610 0ustar benjibenji00000000000000# Copyright 2008 Canonical Ltd. All rights reserved. """Tests of lazr.restful navigation.""" __metaclass__ = type from pkg_resources import resource_filename import os import sys import traceback import unittest from van.testing.layer import zcml_layer from zope.component import ( getGlobalSiteManager, getMultiAdapter, ) from zope.interface import Interface from lazr.restful.declarations import error_status from lazr.restful._resource import ReadWriteResource, UnknownEntryAdapter from lazr.restful.error import expose, ClientErrorView, SystemErrorView from lazr.restful.interfaces import IWebServiceLayer from lazr.restful.testing.webservice import FakeRequest class TestResource(ReadWriteResource): def __init__(self, callable, request): self.callable = callable super(TestResource, self).__init__(context=None, request=request) def do_GET(self): return self.callable() class ExposeTestCase(unittest.TestCase): # Test the behavior of the expose() method. def test_exposed_does_not_work_on_class(self): self.assertRaises(ValueError, expose, Exception) def test_default_exposed_status_is_400(self): exception = expose(ValueError()) self.assertEquals(exception.__lazr_webservice_error__, 400) def test_expose_can_specify_status(self): exception = expose(ValueError(), 409) self.assertEquals(exception.__lazr_webservice_error__, 409) class ErrorsTestCase(unittest.TestCase): def setUp(self): class MyException(Exception): pass self.exception_class = MyException super(ErrorsTestCase, self).setUp() def test_decorated_exception_class_becomes_error_view(self): # An exposed exception, when raised, will set the response # status code appropriately, and set the response text to the # exception body. def broken(): @error_status(404) class MyException(Exception): pass raise MyException("something broke") request = FakeRequest(version='trunk') resource = TestResource(broken, request) result = resource() self.assertEquals(request.response.status, 404) self.assert_(result.startswith('something broke')) def test_decorated_exception_instance_becomes_error_view(self): # An exposed exception, when raised, will set the response # status code appropriately, and set the response text to the # exception body. def broken(): error = RuntimeError('something broke') error = expose(error, 404) raise error request = FakeRequest(version='trunk') resource = TestResource(broken, request) result = resource() self.assertEquals(request.response.status, 404) self.assert_(result.startswith('something broke')) def test_undecorated_exception_is_propagated(self): # If an undecorated exception (a regular Python exception) is # generated during a request the exception percolates all the way out # of the resource (to be handled by the publisher). def broken(): raise RuntimeError('something broke') request = FakeRequest(version='trunk') resource = TestResource(broken, request) self.assertRaises(RuntimeError, resource) def test_exception_decorated_as_system_error_is_propagated(self): # If an exception is decorated with a response code that # indicates a server-side problem, the exception percolates # all the way out of the resource (to be handled by the # publisher). def broken(): raise expose(RuntimeError('something broke'), 503) request = FakeRequest(version='trunk') resource = TestResource(broken, request) self.assertRaises(RuntimeError, resource) self.assertEquals(request.response.getStatus(), 503) def test_exception_decorated_as_nonerror_is_propagated(self): # If an exception is decorated with a response code that # indicates a non-error condition, the exception percolates # all the way out of the resource (to be handled by the # publisher). def if_you_say_so(): raise expose(RuntimeError("everything's fine"), 200) request = FakeRequest(version='trunk') resource = TestResource(if_you_say_so, request) self.assertRaises(RuntimeError, resource) self.assertEquals(request.response.getStatus(), 200) def _setup_non_web_service_exception(self, exception_class=None): if exception_class is None: exception_class = self.exception_class # Define a method that raises the exception. def has_a_view(): raise exception_class() # Register a view for the exception that does not subclass # WebServiceExceptionView and provides no information about # which HTTP status code should be used. class ViewHasNoStatus(object): def __init__(*args): pass getGlobalSiteManager().registerAdapter( ViewHasNoStatus, (exception_class, IWebServiceLayer), Interface, name="index.html") # The exception is re-raised, even though a view is registered # for it, because the view doesn't give lazr.restful the # information it needs to see if it can handle the exception # itself. request = FakeRequest() resource = TestResource(has_a_view, request) return resource def test_non_web_service_exception_is_reraised(self): request = FakeRequest() resource = self._setup_non_web_service_exception() self.assertRaises(self.exception_class, resource) # lazr.restful leaves the status code completely # untouched. Handling a view other than a # WebServiceExceptionView is the publisher's job. self.assertEquals(request.response.getStatus(), 599) def test_non_web_service_exception_includes_original_traceback(self): # If an exception occurs and it has a view, the original traceback is # still shown even though the internals of lazr.restful catch and # reraise the exception. resource = self._setup_non_web_service_exception() try: resource() except: pass self.assertTrue('raise exception_class()' in traceback.format_exc()) def test_passing_bad_things_to_expose(self): # The expose function only accepts instances of exceptions. It # generates errors otherwise. self.assertRaises(ValueError, expose, 1) self.assertRaises(ValueError, expose, 'x') self.assertRaises(ValueError, expose, RuntimeError) def test_missing_adapter(self): # If there is no multi-adapter from the entry interface (IMyEntry) and # a request to IEntry an exception is raised. class IMyEntry(Interface): pass # Since the test wants to inspect the exception message (below) we're # not using self.assertRaises). try: EntryAdapterUtility.forSchemaInterface( IMyEntry, self.beta_request) except Exception, e: pass self.assert_(isinstance(e, Exception)) # The exception's message explains what went wrong. self.assertTrue(str(e), 'No IEntry adapter found for IMyEntry (web service version: beta).') def test_missing_adapter_whence(self): # The UnknownEntryAdapter exception has a "whence" attribute that # higher-level code can set to give the reader of the exeption message # a hint about where in the code the mistake was made that triggered # the exception. exception = UnknownEntryAdapter('IInterfaceInQuestion', '2.0') exception.whence = 'Encounterd as a result of badness in foo.py.' self.assertEquals(str(exception), 'No IEntry adapter found for IInterfaceInQuestion (web service ' 'version: 2.0). Encounterd as a result of badness in foo.py.') def test_original_traceback_reported_when_undecorated(self): # When something goes wrong the original traceback should be # displayed, not a traceback representing a place where the exception # was caught and reraised. def broken(): raise RuntimeError('something broke') request = FakeRequest(version='trunk') resource = TestResource(broken, request) try: resource() except: pass self.assertTrue( "raise RuntimeError('something broke')" in traceback.format_exc()) def test_reporting_original_exception_with_no_trackeback(self): # Sometimes an exception won't have an __traceback__ attribute. The # re-raising should still work (bug 854695). class TracebacklessException(Exception): pass resource = self._setup_non_web_service_exception( exception_class=TracebacklessException) try: resource() except AttributeError: self.fail( 'The resource should not have generated an AttributeError. ' 'This is probably because something was expecting the ' 'exception to have a __traceback__ attribute.') except (MemoryError, KeyboardInterrupt, SystemExit): raise except: pass self.assertTrue("TracebacklessException" in traceback.format_exc()) class FunctionalLayer: allow_teardown = False zcml = os.path.abspath(resource_filename('lazr.restful', 'ftesting.zcml')) zcml_layer(FunctionalLayer) def additional_tests(): """See `zope.testing.testrunner`.""" suite = unittest.TestLoader().loadTestsFromName(__name__) suite.layer = FunctionalLayer return suite lazr.restful-0.19.3/src/lazr/restful/tests/test_etag.py0000644000175000017500000002356511631755356023404 0ustar benjibenji00000000000000# Copyright 2008 Canonical Ltd. All rights reserved. """Tests for ETag generation.""" __metaclass__ = type import unittest from zope.component import provideUtility from lazr.restful.interfaces import IWebServiceConfiguration from lazr.restful.testing.helpers import TestWebServiceConfiguration from lazr.restful.testing.webservice import create_web_service_request from lazr.restful._resource import ( EntryFieldResource, EntryResource, HTTPResource, ServiceRootResource, make_entry_etag_cores, ) class TestEntryResourceETags(unittest.TestCase): # The EntryResource uses the field values that can be written or might # othwerise change as the basis for its ETags. The make_entry_etag_cores # function is passed the data about the fields and returns the read and # write cores. def test_no_field_details(self): # If make_entry_etag_cores is given no field details (because no # fields exist), the resulting cores empty strings. self.assertEquals(make_entry_etag_cores([]), ['', '']) def test_writable_fields(self): # If there are writable fields, their values are incorporated into the # writable portion of the cores. field_details = [ ('first_field', {'writable': True, 'value': 'first'}), ('second_field', {'writable': True, 'value': 'second'}), ] self.assertEquals( make_entry_etag_cores(field_details), ['', 'first\0second']) def test_unchanging_fields(self): # If there are fields that are not writable their values are still # reflected in the generated cores because we want and addition or # removal of read-only fields to trigger a new ETag. field_details = [ ('first_field', {'writable': False, 'value': 'the value'}), ] self.assertEquals( make_entry_etag_cores(field_details), ['the value', '']) def test_combinations_of_fields(self): # If there are a combination of writable, changable, and unchanable # fields, their values are reflected in the resulting cores. field_details = [ ('first_writable', {'writable': True, 'value': 'first-writable'}), ('second_writable', {'writable': True, 'value': 'second-writable'}), ('first_non_writable', {'writable': False, 'value': 'first-not-writable'}), ('second_non_writable', {'writable': False, 'value': 'second-not-writable'}), ] self.assertEquals( make_entry_etag_cores(field_details), ['first-not-writable\x00second-not-writable', 'first-writable\x00second-writable']) class TestHTTPResourceETags(unittest.TestCase): def test_getETag_is_a_noop(self): # The HTTPResource class implements a do-nothing _getETagCores in order to # be conservative (because it's not aware of the nature of all possible # subclasses). self.assertEquals(HTTPResource(None, None)._getETagCores(), None) class TestHTTPResourceETags(unittest.TestCase): def test_getETag_is_a_noop(self): # The HTTPResource class implements a do-nothing _getETagCores in order to # be conservative (because it's not aware of the nature of all possible # subclasses). self.assertEquals(HTTPResource(None, None)._getETagCores(), None) class FauxEntryField: entry = None name = 'field_name' field = None class EntryFieldResourceTests(unittest.TestCase): # Tests for ETags of EntryFieldResource objects. # Because the ETag generation only takes into account the field value and # the web service revision number (and not whether the field is read-write # or read-only) these tests don't mention the read-write/read-only nature # of the field in question. def setUp(self): self.config = TestWebServiceConfiguration() provideUtility(self.config, IWebServiceConfiguration) self.resource = EntryFieldResource(FauxEntryField(), None) def set_field_value(self, value): """Set the value of the fake field the EntryFieldResource references. """ self.resource._unmarshalled_field_cache['field_name'] = ( 'field_name', value) # We have to clear the etag cache for a new value to be generated. # XXX benji 2010-09-30 [bug=652459] Does this mean there is an error # condition that occurs when something other than applyChanges (which # invalidates the cache) modifies a field's value? self.resource.etags_by_media_type = {} def test_cores_change_with_revno(self): # The ETag cores should change if the revision (not the version) of # the web service change. self.set_field_value('this is the field value') # Find the cores generated with a given revision... self.config.code_revision = u'42' first_cores = self.resource._getETagCores(self.resource.JSON_TYPE) # ...find the cores generated with a different revision. self.config.code_revision = u'99' second_cores = self.resource._getETagCores(self.resource.JSON_TYPE) # The cores should be different. self.assertNotEqual(first_cores, second_cores) # In particular, the read core should be the same between the two, but # the write core should be different. self.assertEqual(first_cores[1], second_cores[1]) self.assertNotEqual(first_cores[0], second_cores[0]) def test_cores_change_with_value(self): # The ETag cores should change if the value of the field change. # Find the cores generated with a given value... self.set_field_value('first value') first_cores = self.resource._getETagCores(self.resource.JSON_TYPE) # ...find the cores generated with a different value. self.set_field_value('second value') second_cores = self.resource._getETagCores(self.resource.JSON_TYPE) # The cores should be different. self.assertNotEqual(first_cores, second_cores) # In particular, the read core should be different between the two, # but the write core should be the same. self.assertNotEqual(first_cores[1], second_cores[1]) self.assertEqual(first_cores[0], second_cores[0]) class ServiceRootResourceTests(unittest.TestCase): # Tests for ETags of EntryFieldResource objects. def setUp(self): self.config = TestWebServiceConfiguration() provideUtility(self.config, IWebServiceConfiguration) self.resource = ServiceRootResource() def test_cores_change_with_revno(self): # The ETag core should change if the revision (not the version) of the # web service change. # Find the cores generated with a given revision... self.config.code_revision = u'42' first_cores = self.resource._getETagCores(self.resource.JSON_TYPE) # ...find the cores generated with a different revision. self.config.code_revision = u'99' second_cores = self.resource._getETagCores(self.resource.JSON_TYPE) # The cores should be different. self.assertNotEqual(first_cores, second_cores) class TestableHTTPResource(HTTPResource): """A HTTPResource that lest us set the ETags from the outside.""" def _parseETags(self, *args): return self.incoming_etags def getETag(self, *args): return self.existing_etag class TestConditionalGet(unittest.TestCase): def setUp(self): self.config = TestWebServiceConfiguration() provideUtility(self.config, IWebServiceConfiguration) self.request = create_web_service_request('/1.0') self.resource = TestableHTTPResource(None, self.request) def test_etags_are_the_same(self): # If one of the ETags present in an incoming request is the same as # the ETag that represents the current object's state, then # a conditional GET should return "Not Modified" (304). self.resource.incoming_etags = ['1', '2', '3'] self.resource.existing_etag = '2' self.assertEquals(self.resource.handleConditionalGET(), None) self.assertEquals(self.request.response.getStatus(), 304) def test_etags_differ(self): # If none of the ETags present in an incoming request is the same as # the ETag that represents the current object's state, then a # conditional GET should result in a new representation of the object # being returned. self.resource.incoming_etags = ['1', '2', '3'] self.resource.existing_etag = '99' self.assertNotEquals(self.resource.handleConditionalGET(), None) class TestConditionalWrite(unittest.TestCase): def setUp(self): self.config = TestWebServiceConfiguration() provideUtility(self.config, IWebServiceConfiguration) self.request = create_web_service_request('/1.0') self.resource = TestableHTTPResource(None, self.request) def test_etags_are_the_same(self): # If one of the ETags present in an incoming request is the same as # the ETag that represents the current object's state, then # the write should be applied. self.resource.incoming_etags = ['1', '2', '3'] self.resource.existing_etag = '2' self.assertNotEquals(self.resource.handleConditionalWrite(), None) def test_etags_differ(self): # If one of the ETags present in an incoming request is the same as # the ETag that represents the current object's state, then # the write should fail. self.resource.incoming_etags = ['1', '2', '3'] self.resource.existing_etag = '99' self.assertEquals(self.resource.handleConditionalWrite(), None) self.assertEquals(self.request.response.getStatus(), 412) lazr.restful-0.19.3/src/lazr/restful/tests/test_navigation.py0000644000175000017500000000572711631755356024623 0ustar benjibenji00000000000000# Copyright 2008 Canonical Ltd. All rights reserved. """Tests of lazr.restful navigation.""" __metaclass__ = type import unittest from zope.component import getSiteManager from zope.interface import Interface, implements from zope.publisher.interfaces import NotFound from zope.schema import Text from zope.testing.cleanup import cleanUp from lazr.restful.fields import Reference from lazr.restful.interfaces import ( IEntry, IWebServiceClientRequest, ) from lazr.restful.simple import Publication from lazr.restful.testing.webservice import FakeRequest class IChild(Interface): """Interface for a simple entry.""" one = Text(title=u'One') two = Text(title=u'Two') class IParent(Interface): """Interface for a simple entry that contains another entry.""" three = Text(title=u'Three') child = Reference(schema=IChild) class Child: """A simple implementation of IChild.""" implements(IChild) one = u'one' two = u'two' class ChildEntry: """Implementation of an entry wrapping a Child.""" schema = IChild def __init__(self, context, request): self.context = context class Parent: """A simple implementation of IParent.""" implements(IParent) three = u'three' child = Child() class ParentEntry: """Implementation of an entry wrapping a Parent, containing a Child.""" schema = IParent def __init__(self, context, request): self.context = context @property def child(self): return self.context.child class FakeRequestWithEmptyTraversalStack(FakeRequest): """A fake request satisfying `traverseName()`.""" def getTraversalStack(self): return () class NavigationTestCase(unittest.TestCase): def setUp(self): # Register ChildEntry as the IEntry implementation for IChild. sm = getSiteManager() sm.registerAdapter( ChildEntry, [IChild, IWebServiceClientRequest], provided=IEntry) # Register ParentEntry as the IEntry implementation for IParent. sm.registerAdapter( ParentEntry, [IParent, IWebServiceClientRequest], provided=IEntry) def tearDown(self): cleanUp() def test_toplevel_navigation(self): # Test that publication can reach sub-entries. publication = Publication(None) request = FakeRequestWithEmptyTraversalStack(version='trunk') obj = publication.traverseName(request, Parent(), 'child') self.assertEqual(obj.one, 'one') def test_toplevel_navigation_without_subentry(self): # Test that publication raises NotFound when subentry attribute # returns None. request = FakeRequestWithEmptyTraversalStack(version='trunk') parent = Parent() parent.child = None publication = Publication(None) self.assertRaises( NotFound, publication.traverseName, request, parent, 'child') def additional_tests(): return unittest.TestLoader().loadTestsFromName(__name__) lazr.restful-0.19.3/src/lazr/restful/tests/test_webservice.py0000644000175000017500000011630211632224000024564 0ustar benjibenji00000000000000# Copyright 2008 Canonical Ltd. All rights reserved. """Test for the WADL generation.""" __metaclass__ = type from contextlib import contextmanager from cStringIO import StringIO from lxml import etree from operator import attrgetter from textwrap import dedent import collections import logging import random import re import simplejson import unittest from zope.component import ( eventtesting, getGlobalSiteManager, getUtility, ) from zope.interface import implements, Interface from zope.publisher.browser import TestRequest from zope.schema import Choice, Date, Datetime, TextLine from zope.schema.interfaces import ITextLine from zope.security.management import ( endInteraction, newInteraction, queryInteraction, ) from zope.traversing.browser.interfaces import IAbsoluteURL from lazr.enum import EnumeratedType, Item from lazr.lifecycle.interfaces import IObjectModifiedEvent from lazr.restful import ( EntryField, ResourceOperation, ) from lazr.restful.fields import Reference from lazr.restful.interfaces import ( ICollection, IEntry, IFieldHTMLRenderer, INotificationsProvider, IResourceGETOperation, IServiceRootResource, IWebBrowserOriginatingRequest, IWebServiceConfiguration, IWebServiceClientRequest, IWebServiceVersion, ) from lazr.restful import ( EntryFieldResource, EntryResource, ResourceGETOperation, ) from lazr.restful.declarations import ( exported, export_as_webservice_entry, LAZR_WEBSERVICE_NAME, annotate_exported_methods) from lazr.restful.testing.webservice import ( create_web_service_request, DummyAbsoluteURL, IGenericCollection, IGenericEntry, simple_renderer, WebServiceTestCase, ) from lazr.restful.testing.tales import test_tales from lazr.restful.utils import ( get_current_browser_request, get_current_web_service_request, tag_request_with_version_name, sorted_named_things) from lazr.restful._resource import CollectionResource, BatchingResourceMixin def get_resource_factory(model_interface, resource_interface): """Return the autogenerated adapter class for a model_interface. :param model_interface: the annnotated interface for which we are looking for the web service resource adapter :param resource_interface: the method provided by the resource, usually `IEntry` or `ICollection`. :return: the resource factory (the autogenerated adapter class. """ request_interface = getUtility(IWebServiceVersion, name='2.0') return getGlobalSiteManager().adapters.lookup( (model_interface, request_interface), resource_interface) def get_operation_factory(model_interface, name): """Find the factory for a GET operation adapter. :param model_interface: the model interface on which the operation is defined. :param name: the name of the exported method. :return: the factory (autogenerated class) that implements the operation on the webservice. """ request_interface = getUtility(IWebServiceVersion, name='2.0') return getGlobalSiteManager().adapters.lookup( (model_interface, request_interface), IResourceGETOperation, name=name) class IHas_getitem(Interface): pass class Has_getitem: implements(IHas_getitem) def __getitem__(self, item): return "wibble" class ResourceOperationTestCase(unittest.TestCase): """A test case for resource operations.""" def test_object_with_getitem_should_not_batch(self): """Test ResourceOperation.should_batch(). Custom operations returning a Reference to objects that implement __getitem__ should not batch the results (iter() on such objects does not fail). """ return_type = Reference(IHas_getitem) result = Has_getitem() operation = ResourceGETOperation("fake context", "fake request") operation.return_type = return_type self.assertFalse( operation.should_batch(result), "Batching should not happen for Reference return types.") class EntryTestCase(WebServiceTestCase): """A test suite that defines an entry class.""" WADL_NS = "{http://research.sun.com/wadl/2006/10}" default_media_type = "application/json" class DummyWebsiteRequest: """A request to the website, as opposed to the web service.""" implements(IWebBrowserOriginatingRequest) class DummyWebsiteURL(DummyAbsoluteURL): """A web-centric implementation of the dummy URL.""" URL = 'http://www.website.url/' @contextmanager def request(self, media_type=None): media_type = media_type or self.default_media_type request = getUtility(IWebServiceConfiguration).createRequest( StringIO(""), {'HTTP_ACCEPT' : media_type}) newInteraction(request) yield request endInteraction() @property def wadl(self): """Get a parsed WADL description of the web service.""" with self.request() as request: return request.publication.application.toWADL().encode('utf-8') @contextmanager def entry_resource(self, entry_interface, entry_implementation, *implementation_args): """Create a request to an entry resource, and yield the resource.""" entry_class = get_resource_factory(entry_interface, IEntry) data_object = entry_implementation(*implementation_args) with self.request() as request: entry = entry_class(data_object, request) resource = EntryResource(data_object, request) yield resource @contextmanager def entry_field_resource(self, entry_interface, entry_implementation, field_name, *implementation_args): entry_class = get_resource_factory(entry_interface, IEntry) data_object = entry_implementation(*implementation_args) with self.request() as request: entry = entry_class(data_object, request) field = entry.schema.get(field_name) entry_field = EntryField(entry, field, field_name) resource = EntryFieldResource(entry_field, request) yield resource def register_html_field_renderer(self, entry_interface, field_interface, render_function, name=''): """Register an HTML representation for a field or class of field.""" def renderer(context, field, request): return render_function getGlobalSiteManager().registerAdapter( renderer, (entry_interface, field_interface, IWebServiceClientRequest), IFieldHTMLRenderer, name=name) def _register_url_adapter(self, entry_interface): """Register an IAbsoluteURL implementation for an interface.""" getGlobalSiteManager().registerAdapter( DummyAbsoluteURL, [entry_interface, IWebServiceClientRequest], IAbsoluteURL) def _register_website_url_space(self, entry_interface): """Simulates a service where an entry corresponds to a web page.""" self._register_url_adapter(entry_interface) # First, create a converter from web service requests to # web page requests. def web_service_request_to_website_request(service_request): """Create a corresponding request to the website.""" return self.DummyWebsiteRequest() getGlobalSiteManager().registerAdapter( web_service_request_to_website_request, [IWebServiceClientRequest], IWebBrowserOriginatingRequest) # Next, set up a distinctive URL, and register it as the # website URL for the given entry interface. getGlobalSiteManager().registerAdapter( self.DummyWebsiteURL, [entry_interface, IWebBrowserOriginatingRequest], IAbsoluteURL) class IHasOneField(Interface): """An entry with a single field.""" export_as_webservice_entry() a_field = exported(TextLine(title=u"A field.")) class HasOneField: """An implementation of IHasOneField.""" implements(IHasOneField) def __init__(self, value): self.a_field = value class IHasTwoFields(Interface): """An entry with two fields.""" export_as_webservice_entry() a_field = exported(TextLine(title=u"A field.")) another_field = exported(TextLine(title=u"Another field.")) class HasTwoFields: """An implementation of IHasTwoFields.""" implements(IHasTwoFields) def __init__(self, value1, value2): self.a_field = value1 self.another_field = value2 class TestEntryWebLink(EntryTestCase): testmodule_objects = [HasOneField, IHasOneField] def test_entry_includes_web_link_when_available(self): # If a web service request can be adapted to a web*site* request, # the representation of an entry will include a link to the # corresponding entry on the website. # # This is useful when each entry published by the web service # has a human-readable page on some corresponding website. The # web service can publish links to the website for use by Ajax # clients or for other human-interaction purposes. self._register_website_url_space(IHasOneField) # Now a representation of IHasOneField includes a # 'web_link'. with self.entry_resource(IHasOneField, HasOneField, "") as resource: representation = resource.toDataForJSON() self.assertEquals(representation['self_link'], DummyAbsoluteURL.URL) self.assertEquals( representation['web_link'], self.DummyWebsiteURL.URL) def test_wadl_includes_web_link_when_available(self): # If an entry includes a web_link, this information will # show up in the WADL description of the entry. service_root = "https://webservice_test/2.0/" self._register_website_url_space(IHasOneField) doc = etree.parse(StringIO(self.wadl)) # Verify that the 'has_one_field-full' representation includes # a 'web_link' param. representation = [ rep for rep in doc.findall('%srepresentation' % self.WADL_NS) if rep.get('id') == 'has_one_field-full'][0] param = [ param for param in representation.findall( '%sparam' % self.WADL_NS) if param.get('name') == 'web_link'][0] # Verify that the 'web_link' param includes a 'link' tag. self.assertFalse(param.find('%slink' % self.WADL_NS) is None) def test_entry_omits_web_link_when_not_available(self): # When there is no way of turning a webservice request into a # website request, the 'web_link' attribute is missing from # entry representations. self._register_url_adapter(IHasOneField) with self.entry_resource(IHasOneField, HasOneField, "") as resource: representation = resource.toDataForJSON() self.assertEquals( representation['self_link'], DummyAbsoluteURL.URL) self.assertFalse('web_link' in representation) def test_wadl_omits_web_link_when_not_available(self): # When there is no way of turning a webservice request into a # website request, the 'web_link' attribute is missing from # WADL descriptions of entries. self._register_url_adapter(IHasOneField) self.assertFalse('web_link' in self.wadl) class IHasNoWebLink(Interface): """An entry that does not publish a web_link.""" export_as_webservice_entry(publish_web_link=False) a_field = exported(TextLine(title=u"A field.")) class HasNoWebLink: """An implementation of IHasNoWebLink.""" implements(IHasNoWebLink) def __init__(self, value): self.a_field = value class TestSuppressWebLink(EntryTestCase): """Test the ability to suppress web_link on a per-entry basis.""" testmodule_objects = [IHasNoWebLink, HasNoWebLink] def test_entry_omits_web_link_when_suppressed(self): self._register_website_url_space(IHasNoWebLink) with self.entry_resource(IHasNoWebLink, HasNoWebLink, "") as ( resource): representation = resource.toDataForJSON() self.assertEquals( representation['self_link'], DummyAbsoluteURL.URL) self.assertFalse('web_link' in representation) class InterfaceRestrictedField(TextLine): """A field that must be exported from one kind of interface.""" def __init__(self, restrict_to_interface, *args, **kwargs): self.restrict_to_interface = restrict_to_interface super(InterfaceRestrictedField, self).__init__(*args, **kwargs) def bind(self, context): if not self.restrict_to_interface.providedBy(context): raise AssertionError( "InterfaceRestrictedField can only be used with %s" % self.restrict_to_interface.__name__) return super(InterfaceRestrictedField, self).bind(context) class IHasRestrictedField(Interface): """An entry with an InterfaceRestrictedField.""" export_as_webservice_entry() a_field = exported(InterfaceRestrictedField(Interface)) class HasRestrictedField: """An implementation of IHasRestrictedField.""" implements(IHasRestrictedField) def __init__(self, value): self.a_field = value class TestEntryWrite(EntryTestCase): testmodule_objects = [IHasOneField, HasOneField] def test_applyChanges_rejects_nonexistent_web_link(self): # If web_link is not published, applyChanges rejects a request # that references it. with self.entry_resource(IHasOneField, HasOneField, "") as resource: errors = resource.applyChanges({'web_link': u'some_value'}) self.assertEquals( errors, 'web_link: You tried to modify a nonexistent attribute.') def test_applyChanges_rejects_changed_web_link(self): """applyChanges rejects an attempt to change web_link .""" self._register_website_url_space(IHasOneField) with self.entry_resource(IHasOneField, HasOneField, "") as resource: errors = resource.applyChanges({'web_link': u'some_value'}) self.assertEquals( errors, 'web_link: You tried to modify a read-only attribute.') def test_applyChanges_accepts_unchanged_web_link(self): # applyChanges accepts a reference to web_link, as long as the # value isn't actually being changed. self._register_website_url_space(IHasOneField) with self.entry_resource(IHasOneField, HasOneField, "") as resource: existing_web_link = resource.toDataForJSON()['web_link'] representation = simplejson.loads( resource.applyChanges({'web_link': existing_web_link})) self.assertEquals(representation['web_link'], existing_web_link) def test_applyChanges_returns_representation_on_empty_changeset(self): # Even if the changeset is empty, applyChanges returns a # representation of the (unchanged) resource. self._register_website_url_space(IHasOneField) with self.entry_resource(IHasOneField, HasOneField, "") as resource: existing_representation = resource.toDataForJSON() representation = simplejson.loads(resource.applyChanges({})) self.assertEquals(representation, existing_representation) class TestEntryWriteForRestrictedField(EntryTestCase): testmodule_objects = [IHasRestrictedField, HasRestrictedField] def test_applyChanges_binds_to_resource_context(self): """Make sure applyChanges binds fields to the resource context. This case verifies that applyChanges binds fields to the entry resource's context, not the resource itself. If an InterfaceRestrictedField is bound to an object that doesn't expose the right interface, it will raise an exception. """ self._register_url_adapter(IHasRestrictedField) with self.entry_resource( IHasRestrictedField, HasRestrictedField, "") as resource: entry = resource.entry entry.schema['a_field'].restrict_to_interface = IHasRestrictedField self.assertEquals(entry.a_field, '') resource.applyChanges({'a_field': u'a_value'}) self.assertEquals(entry.a_field, 'a_value') # Make sure that IHasRestrictedField itself works correctly. class IOtherInterface(Interface): """An interface not provided by IHasRestrictedField.""" pass entry.schema['a_field'].restrict_to_interface = IOtherInterface self.assertRaises(AssertionError, resource.applyChanges, {'a_field': u'a_new_value'}) self.assertEquals(resource.entry.a_field, 'a_value') class HTMLRepresentationTest(EntryTestCase): testmodule_objects = [HasOneField, IHasOneField] default_media_type = "application/xhtml+xml" def setUp(self): super(HTMLRepresentationTest, self).setUp() self._register_url_adapter(IHasOneField) self.unicode_message = u"Hello from a \N{SNOWMAN}" self.utf8_message = self.unicode_message.encode('utf-8') def test_entry_html_representation_is_utf8(self): with self.entry_resource( IHasOneField, HasOneField, self.unicode_message) as resource: html = resource.do_GET() self.assertTrue(self.utf8_message in html) def test_field_html_representation_is_utf8(self): with self.entry_field_resource( IHasOneField, HasOneField, "a_field", self.unicode_message) as resource: html = resource.do_GET() self.assertTrue(html == self.utf8_message) class JSONPlusHTMLRepresentationTest(EntryTestCase): testmodule_objects = [HasTwoFields, IHasTwoFields] def setUp(self): super(JSONPlusHTMLRepresentationTest, self).setUp() self.default_media_type = "application/json;include=lp_html" self._register_url_adapter(IHasTwoFields) def register_html_field_renderer(self, name=''): """Simplify the register_html_field_renderer call.""" super(JSONPlusHTMLRepresentationTest, self).register_html_field_renderer( IHasTwoFields, ITextLine, simple_renderer, name) @contextmanager def resource(self, value_1="value 1", value_2="value 2"): """Simplify the entry_resource call.""" with self.entry_resource( IHasTwoFields, HasTwoFields, unicode(value_1), unicode(value_2)) as resource: yield resource def test_web_layer_json_representation_omits_lp_html(self): self.register_html_field_renderer() with self.resource() as resource: tales_string = test_tales( "entry/webservice:json", entry=resource.entry.context) self.assertFalse("lp_html" in tales_string) def test_normal_json_representation_omits_lp_html(self): self.default_media_type = "application/json" self.register_html_field_renderer() with self.resource() as resource: json = simplejson.loads(resource.do_GET()) self.assertFalse('lp_html' in json) def test_entry_with_no_html_renderers_omits_lp_html(self): with self.resource() as resource: json = simplejson.loads(resource.do_GET()) self.assertFalse('lp_html' in json) self.assertEquals( resource.request.response.getHeader("Content-Type"), "application/json") def test_field_specific_html_renderer_shows_up_in_lp_html(self): self.register_html_field_renderer("a_field") with self.resource() as resource: json = simplejson.loads(resource.do_GET()) html = json['lp_html'] self.assertEquals( html['a_field'], simple_renderer(resource.entry.a_field)) self.assertEquals( resource.request.response.getHeader("Content-Type"), resource.JSON_PLUS_XHTML_TYPE) def test_html_renderer_for_class_renders_all_fields_of_that_class(self): self.register_html_field_renderer() with self.resource() as resource: json = simplejson.loads(resource.do_GET()) html = json['lp_html'] self.assertEquals( html['a_field'], simple_renderer(resource.entry.a_field)) self.assertEquals( html['another_field'], simple_renderer(resource.entry.another_field)) def test_json_plus_html_etag_is_json_etag(self): self.register_html_field_renderer() with self.resource() as resource: etag_1 = resource.getETag(resource.JSON_TYPE) etag_2 = resource.getETag(resource.JSON_PLUS_XHTML_TYPE) self.assertEquals(etag_1, etag_2) def test_acceptchanges_ignores_lp_html_for_json_plus_html_type(self): # The lp_html portion of the representation is ignored during # writes. self.register_html_field_renderer() json = None with self.resource() as resource: json_plus_xhtml = resource.JSON_PLUS_XHTML_TYPE json = simplejson.loads(unicode( resource._representation(json_plus_xhtml))) resource.applyChanges(json, json_plus_xhtml) self.assertEquals(resource.request.response.getStatus(), 209) def test_acceptchanges_does_not_ignore_lp_html_for_bare_json_type(self): self.register_html_field_renderer() json = None with self.resource() as resource: json = simplejson.loads(unicode( resource._representation(resource.JSON_PLUS_XHTML_TYPE))) resource.applyChanges(json, resource.JSON_TYPE) self.assertEquals(resource.request.response.getStatus(), 400) class UnicodeChoice(EnumeratedType): """A choice between an ASCII value and a Unicode value.""" ASCII = Item("Ascii", "Ascii choice") UNICODE = Item(u"Uni\u00e7ode", "Uni\u00e7ode choice") class ICanBeSetToUnicodeValue(Interface): """An entry with an InterfaceRestrictedField.""" export_as_webservice_entry() a_field = exported(Choice( vocabulary=UnicodeChoice, title=u"A value that might be ASCII or Unicode.", required=False, default=None)) class CanBeSetToUnicodeValue: """An implementation of ICanBeSetToUnicodeValue.""" implements(ICanBeSetToUnicodeValue) def __init__(self, value): self.a_field = value class UnicodeErrorTestCase(EntryTestCase): """Test that Unicode error strings are properly passed through.""" testmodule_objects = [CanBeSetToUnicodeValue, ICanBeSetToUnicodeValue] def setUp(self): super(UnicodeErrorTestCase, self).setUp() self._register_url_adapter(ICanBeSetToUnicodeValue) def test_unicode_error(self): with self.entry_resource( ICanBeSetToUnicodeValue, CanBeSetToUnicodeValue, "") as resource: # This will raise an exception, which will cause the request # to fail with a 400 error code. error = resource.applyChanges({'a_field': u'No such value'}) self.assertEqual(resource.request.response.getStatus(), 400) # The error message is a Unicode string which mentions both # the ASCII value and the Unicode value, expected_error = ( u'a_field: Invalid value "No such value". Acceptable values ' u'are: Ascii, Uni\u00e7ode') self.assertEquals(error, expected_error) class WadlAPITestCase(WebServiceTestCase): """Test the docstring generation.""" # This one is used to test when docstrings are missing. class IUndocumentedEntry(Interface): export_as_webservice_entry() a_field = exported(TextLine()) testmodule_objects = [ IGenericEntry, IGenericCollection, IUndocumentedEntry] def test_wadl_field_type(self): """Test the generated XSD field types for various fields.""" self.assertEquals(test_tales("field/wadl:type", field=TextLine()), None) self.assertEquals(test_tales("field/wadl:type", field=Date()), "xsd:date") self.assertEquals(test_tales("field/wadl:type", field=Datetime()), "xsd:dateTime") def test_wadl_entry_doc(self): """Test the wadl:doc generated for an entry adapter.""" entry = get_resource_factory(IGenericEntry, IEntry) doclines = test_tales( 'entry/wadl_entry:doc', entry=entry).splitlines() self.assertEquals([ '', '

A simple, reusable entry interface for use in tests.

', '

The entry publishes one field and one named operation.

', '', '
'], doclines) def test_empty_wadl_entry_doc(self): """Test that no docstring on an entry results in no wadl:doc.""" entry = get_resource_factory(self.IUndocumentedEntry, IEntry) self.assertEquals( None, test_tales('entry/wadl_entry:doc', entry=entry)) def test_wadl_collection_doc(self): """Test the wadl:doc generated for a collection adapter.""" collection = get_resource_factory(IGenericCollection, ICollection) doclines = test_tales( 'collection/wadl_collection:doc', collection=collection ).splitlines() self.assertEquals([ '', 'A simple collection containing IGenericEntry, for use in tests.', ''], doclines) def test_field_wadl_doc (self): """Test the wadl:doc generated for an exported field.""" entry = get_resource_factory(IGenericEntry, IEntry) field = entry.schema['a_field'] doclines = test_tales( 'field/wadl:doc', field=field).splitlines() self.assertEquals([ '', '

A "field"

', '

The only field that can be <> 0 in the entry.

', '', '
'], doclines) def test_field_empty_wadl_doc(self): """Test that no docstring on a collection results in no wadl:doc.""" entry = get_resource_factory(self.IUndocumentedEntry, IEntry) field = entry.schema['a_field'] self.assertEquals(None, test_tales('field/wadl:doc', field=field)) def test_wadl_operation_doc(self): """Test the wadl:doc generated for an operation adapter.""" operation = get_operation_factory(IGenericEntry, 'greet') doclines = test_tales( 'operation/wadl_operation:doc', operation=operation).splitlines() # Only compare the first 2 lines and the last one. # we dont care about the formatting of the parameters table. self.assertEquals([ '', '

Print an appropriate greeting based on the message.

',], doclines[0:2]) self.assertEquals('
', doclines[-1]) self.failUnless(len(doclines) > 3, 'Missing the parameter table: %s' % "\n".join(doclines)) class DuplicateNameTestCase(WebServiceTestCase): """Test AssertionError when two resources expose the same name. This class contains no tests of its own. It's up to the subclass to define IDuplicate and call doDuplicateTest(). """ def doDuplicateTest(self, expected_error_message): """Try to generate a WADL representation of the root. This will fail due to a name conflict. """ resource = getUtility(IServiceRootResource) request = create_web_service_request('/2.0') request.traverse(resource) try: resource.toWADL() self.fail('Expected toWADL to fail with an AssertionError') except AssertionError, e: self.assertEquals(str(e), expected_error_message) def make_entry(name): """Make an entity with some attibutes to expose as a web service.""" code = """ class %(name)s(Interface): export_as_webservice_entry(singular_name='%(name)s') """ % locals() for letter in 'rstuvwxyz': code += """ %(letter)s_field = exported(TextLine(title=u'Field %(letter)s')) """ % locals() exec dedent(code) return locals()[name] class TestWadlDeterminism(WebServiceTestCase): """We want the WADL generation to be consistent for a given input.""" def __init__(self, *args, **kwargs): # make some -- randomly ordered -- objects to use to build the WADL self.testmodule_objects = [make_entry(name) for name in 'abcdefghijk'] random.shuffle(self.testmodule_objects) super(TestWadlDeterminism, self).__init__(*args, **kwargs) @property def wadl(self): resource = getUtility(IServiceRootResource) request = create_web_service_request('/2.0') request.traverse(resource) return resource.toWADL() def test_entity_order(self): # The entities should be listed in alphabetical order by class. self.assertEqual( re.findall(r'', self.wadl), ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k']) def test_attribute_order(self): # The individual entity attributes should be listed in alphabetical # order by class. self.assertEqual( re.findall(r']* name="(.)_field">', self.wadl)[:9], ['r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z']) class DuplicateSingularNameTestCase(DuplicateNameTestCase): """Test AssertionError when resource types share a singular name.""" class IDuplicate(Interface): """An entry that reuses the singular name of IGenericEntry.""" export_as_webservice_entry('generic_entry') testmodule_objects = [IGenericEntry, IDuplicate] def test_duplicate_singular(self): self.doDuplicateTest("Both IDuplicate and IGenericEntry expose the " "singular name 'generic_entry'.") class DuplicatePluralNameTestCase(DuplicateNameTestCase): """Test AssertionERror when resource types share a plural name.""" class IDuplicate(Interface): """An entry that reuses the plural name of IGenericEntry.""" export_as_webservice_entry(plural_name='generic_entrys') testmodule_objects = [IGenericEntry, IDuplicate] def test_duplicate_plural(self): self.doDuplicateTest("Both IDuplicate and IGenericEntry expose the " "plural name 'generic_entrys'.") class GetCurrentWebServiceRequestTestCase(WebServiceTestCase): """Test the get_current_web_service_request utility function.""" testmodule_objects = [IGenericEntry] def test_web_service_request_is_versioned(self): """Ensure the get_current_web_service_request() result is versioned. When a normal browser request is turned into a web service request, it needs to have a version associated with it. lazr.restful associates the new request with the latest version of the web service: in this case, version 2.0. """ # When there's no interaction setup, get_current_web_service_request() # returns None. self.assertEquals(None, queryInteraction()) self.assertEquals(None, get_current_web_service_request()) # Set up an interaction. request = TestRequest() newInteraction(request) # A normal web browser request isn't associated with any version. website_request = get_current_browser_request() self.assertRaises(AttributeError, attrgetter('version', website_request), None) # But the result of get_current_web_service_request() is # associated with version 2.0. webservice_request = get_current_web_service_request() self.assertEquals("2.0", webservice_request.version) marker_20 = getUtility(IWebServiceVersion, "2.0") self.assertTrue(marker_20.providedBy(webservice_request)) # We can use tag_request_with_version_name to change the # version of a request object. tag_request_with_version_name(webservice_request, '1.0') self.assertEquals("1.0", webservice_request.version) marker_10 = getUtility(IWebServiceVersion, "1.0") self.assertTrue(marker_10.providedBy(webservice_request)) tag_request_with_version_name(webservice_request, '2.0') self.assertEquals("2.0", webservice_request.version) self.assertTrue(marker_20.providedBy(webservice_request)) endInteraction() def additional_tests(): return unittest.TestLoader().loadTestsFromName(__name__) class ITestEntry(IEntry): """Interface for a test entry.""" export_as_webservice_entry() class TestEntry: implements(ITestEntry) def __init__(self, context, request): pass class BaseBatchingTest: """A base class which tests BatchingResourceMixin and subclasses.""" testmodule_objects = [HasRestrictedField, IHasRestrictedField] def setUp(self): super(BaseBatchingTest, self).setUp() # Register TestEntry as the IEntry implementation for ITestEntry. getGlobalSiteManager().registerAdapter( TestEntry, [ITestEntry, IWebServiceClientRequest], provided=IEntry) # Is doing this by hand the right way? ITestEntry.setTaggedValue( LAZR_WEBSERVICE_NAME, dict(singular='test_entity', plural='test_entities')) def make_instance(self, entries, request): raise NotImplementedError('You have to make your own instances.') def test_getting_a_batch(self): entries = [1, 2, 3] request = create_web_service_request('/devel') instance = self.make_instance(entries, request) total_size = instance.get_total_size(entries) self.assertEquals(total_size, '3') class TestBatchingResourceMixin(BaseBatchingTest, WebServiceTestCase): """Test that BatchingResourceMixin does batching correctly.""" def make_instance(self, entries, request): return BatchingResourceMixin() class TestCollectionResourceBatching(BaseBatchingTest, WebServiceTestCase): """Test that CollectionResource does batching correctly.""" def make_instance(self, entries, request): class Collection: implements(ICollection) entry_schema = ITestEntry def __init__(self, entries): self.entries = entries def find(self): return self.entries return CollectionResource(Collection(entries), request) class TestResourceOperationBatching(BaseBatchingTest, WebServiceTestCase): """Test that ResourceOperation does batching correctly.""" def make_instance(self, entries, request): # constructor parameters are ignored return ResourceOperation(None, request) Notification = collections.namedtuple('Notification', ['level', 'message']) class NotificationsProviderTest(EntryTestCase): """Test that notifcations are included in the response headers.""" testmodule_objects = [HasOneField, IHasOneField] class DummyWebsiteRequestWithNotifications: """A request to the website, as opposed to the web service.""" implements(INotificationsProvider) @property def notifications(self): return [Notification(logging.INFO, "Informational"), Notification(logging.WARNING, "Warning") ] def setUp(self): super(NotificationsProviderTest, self).setUp() self.default_media_type = "application/json;include=lp_html" self._register_website_url_space(IHasOneField) self._register_notification_adapter() def _register_notification_adapter(self): """Simulates a service where an entry corresponds to a web page.""" # First, create a converter from web service requests to # web service requests with notifications. def web_service_request_to_notification_request(service_request): """Create a corresponding request to the website.""" return self.DummyWebsiteRequestWithNotifications() getGlobalSiteManager().registerAdapter( web_service_request_to_notification_request, [IWebServiceClientRequest], INotificationsProvider) @contextmanager def resource(self): """Simplify the entry_resource call.""" with self.entry_resource(IHasOneField, HasOneField, "") as resource: yield resource def test_response_notifications(self): with self.resource() as resource: resource.request.publication.callObject( resource.request, resource) notifications = resource.request.response.getHeader( "X-Lazr-Notifications") self.assertFalse(notifications is None) notifications = simplejson.loads(notifications) expected_notifications = [ [logging.INFO, "Informational"], [logging.WARNING, "Warning"]] self.assertEquals(notifications, expected_notifications) class EventTestCase(EntryTestCase): testmodule_objects = [IHasOneField] def setUp(self): super(EventTestCase, self).setUp() self._register_url_adapter(IHasOneField) eventtesting.setUp() def test_event_fired_when_changeset_is_not_empty(self): # Passing in a non-empty changeset spawns an # IObjectModifiedEvent. with self.entry_resource( IHasOneField, HasOneField, "") as resource: resource.applyChanges({'a_field': u'Some value'}) events = eventtesting.getEvents() self.assertEquals(len(events), 1) event = events[0] self.assertEquals(event.object_before_modification.a_field, "") self.assertEquals(event.object.a_field, "Some value") def test_event_not_fired_when_changeset_is_empty(self): # Passing in an empty changeset does not spawn an # IObjectModifiedEvent. with self.entry_resource( IHasOneField, HasOneField, "") as resource: resource.applyChanges({}) self.assertEquals(len(eventtesting.getEvents()), 0) class MalformedRequest(EntryTestCase): testmodule_objects = [HasOneField, IHasOneField] default_media_type = "application/xhtml+xml" def setUp(self): super(MalformedRequest, self).setUp() self._register_url_adapter(IHasOneField) self.unicode_message = u"Hello from a \N{SNOWMAN}" def test_multiple_named_operations_generate_error_on_GET(self): with self.entry_resource( IHasOneField, HasOneField, self.unicode_message) as resource: resource.request.form['ws.op'] = ['foo', 'bar'] result = resource.do_GET() self.assertEquals(resource.request.response.getStatus(), 400) self.assertEquals( result, "Expected a single operation: ['foo', 'bar']") def test_multiple_named_operations_generate_error_on_POST(self): with self.entry_resource( IHasOneField, HasOneField, self.unicode_message) as resource: resource.request.form['ws.op'] = ['foo', 'bar'] result = resource.do_POST() self.assertEquals(resource.request.response.getStatus(), 400) self.assertEquals( result, "Expected a single operation: ['foo', 'bar']") lazr.restful-0.19.3/src/lazr/restful/tests/__init__.py0000644000175000017500000000132711631755356023154 0ustar benjibenji00000000000000# Copyright 2009 Canonical Ltd. All rights reserved. # # This file is part of lazr.restful # # lazr.restful is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by # the Free Software Foundation, version 3 of the License. # # lazr.restful is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or # FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public # License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with lazr.restful. If not, see . "The lazr.restful tests." lazr.restful-0.19.3/src/lazr/restful/tests/test_docs.py0000644000175000017500000000361611631755356023407 0ustar benjibenji00000000000000# Copyright 2009 Canonical Ltd. All rights reserved. # # This file is part of lazr.restful # # lazr.restful is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by # the Free Software Foundation, version 3 of the License. # # lazr.restful is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or # FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public # License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with lazr.restful. If not, see . "Test harness for doctests." # pylint: disable-msg=E0611,W0142 __metaclass__ = type __all__ = [ 'additional_tests', ] import atexit import doctest import os from pkg_resources import ( resource_filename, resource_exists, resource_listdir, cleanup_resources) import unittest from zope.testing.cleanup import cleanUp DOCTEST_FLAGS = ( doctest.ELLIPSIS | doctest.NORMALIZE_WHITESPACE | doctest.REPORT_NDIFF) def tearDown(test): """Run registered clean-up function.""" cleanUp() def additional_tests(): "Run the doc tests (README.txt and docs/*, if any exist)" doctest_files = [ os.path.abspath(resource_filename('lazr.restful', 'README.txt'))] if resource_exists('lazr.restful', 'docs'): for name in resource_listdir('lazr.restful', 'docs'): if name.endswith('.txt'): doctest_files.append( os.path.abspath( resource_filename('lazr.restful', 'docs/%s' % name))) kwargs = dict(module_relative=False, optionflags=DOCTEST_FLAGS, tearDown=tearDown) atexit.register(cleanup_resources) return unittest.TestSuite(( doctest.DocFileSuite(*doctest_files, **kwargs))) lazr.restful-0.19.3/src/lazr/restful/declarations.py0000644000175000017500000020643111631755356022726 0ustar benjibenji00000000000000# Copyright 2008 Canonical Ltd. All rights reserved. """Declaration helpers to define a web service.""" __metaclass__ = type __all__ = [ 'COLLECTION_TYPE', 'ENTRY_TYPE', 'FIELD_TYPE', 'LAZR_WEBSERVICE_ACCESSORS', 'LAZR_WEBSERVICE_EXPORTED', 'LAZR_WEBSERVICE_MUTATORS', 'OPERATION_TYPES', 'REQUEST_USER', 'accessor_for', 'cache_for', 'call_with', 'collection_default_content', 'error_status', 'exported', 'export_as_webservice_collection', 'export_as_webservice_entry', 'export_destructor_operation', 'export_factory_operation', 'export_operation_as', 'export_read_operation', 'export_write_operation', 'generate_collection_adapter', 'generate_entry_adapters', 'generate_entry_interfaces', 'generate_operation_adapter', 'mutator_for', 'operation_for_version', 'operation_parameters', 'operation_removed_in_version', 'operation_returns_entry', 'operation_returns_collection_of', 'rename_parameters_as', 'webservice_error', ] import copy import itertools import sys from zope.component import getUtility, getGlobalSiteManager from zope.interface import classImplements from zope.interface.advice import addClassAdvisor from zope.interface.interface import fromFunction, InterfaceClass, TAGGED_DATA from zope.interface.interfaces import IInterface, IMethod from zope.schema import getFields from zope.schema.interfaces import ( IField, IObject, IText, ) from zope.security.checker import CheckerPublic from zope.traversing.browser import absoluteURL from lazr.delegates import Passthrough from lazr.restful.fields import ( CollectionField, Reference, ) from lazr.restful.interface import copy_field from lazr.restful.interfaces import ( ICollection, IEntry, IReference, IResourceDELETEOperation, IResourceGETOperation, IResourcePOSTOperation, IWebServiceConfiguration, IWebServiceVersion, LAZR_WEBSERVICE_NAME, LAZR_WEBSERVICE_NS, ) from lazr.restful import ( Collection, Entry, EntryAdapterUtility, ResourceOperation, ObjectLink, ) from lazr.restful.security import protect_schema from lazr.restful.utils import ( camelcase_to_underscore_separated, get_current_web_service_request, make_identifier_safe, VersionedDict, VersionedObject, is_total_size_link_active, ) LAZR_WEBSERVICE_ACCESSORS = '%s.exported.accessors' % LAZR_WEBSERVICE_NS LAZR_WEBSERVICE_EXPORTED = '%s.exported' % LAZR_WEBSERVICE_NS LAZR_WEBSERVICE_MUTATORS = '%s.exported.mutators' % LAZR_WEBSERVICE_NS COLLECTION_TYPE = 'collection' ENTRY_TYPE = 'entry' FIELD_TYPE = 'field' REMOVED_OPERATION_TYPE = 'removed_operation' OPERATION_TYPES = ( 'destructor', 'factory', 'read_operation', 'write_operation', REMOVED_OPERATION_TYPE) # These are the only valid keys to be found in an entry's # version-specific annotation dictionary. ENTRY_ANNOTATION_KEYS = set([ 'contributes_to', 'exported', 'plural_name', 'publish_web_link', 'singular_name', ]) class REQUEST_USER: """Marker class standing in for the user of the current request. This is passed in to annotations like @call_with. This is a class rather than an object because it's going to be run through copy.deepcopy, and we want 'is REQUEST_USER' to succeed on the copy. """ pass def _check_called_from_interface_def(name): """Make sure that the declaration was used from within a class definition. """ # 2 is our caller's caller. frame = sys._getframe(2) f_locals = frame.f_locals # Try to make sure we were called from a class def. if (f_locals is frame.f_globals) or ('__module__' not in f_locals): raise TypeError( "%s can only be used from within an interface definition." % name) def _check_interface(name, interface): """Check that interface provides IInterface or raise a TypeError.""" if not IInterface.providedBy(interface): raise TypeError("%s can only be used on an interface." % name) def _get_interface_tags(): """Retrieve the dictionary containing tagged values for the interface. This will create it, if it hasn't been defined yet. """ # Our caller is contained within the interface definition. f_locals = sys._getframe(2).f_locals return f_locals.setdefault(TAGGED_DATA, {}) def export_as_webservice_entry(singular_name=None, plural_name=None, contributes_to=None, publish_web_link=True, as_of=None, versioned_annotations=None): """Mark the content interface as exported on the web service as an entry. If contributes_to is a non-empty sequence of Interfaces, this entry will actually not be exported on its own but instead will contribute its attributes/methods to other exported entries. :param singular_name: The human-readable singular name of the entry, eg. "paintbrush". :param plural_name: The human-readable plural name of the entry, eg. "paintbrushes" :param contributes_to: An optional list of exported interfaces to which this interface contributes. :param publish_web_link: This parameter is ignored unless there is a correspondence between this web service's entries and the pages on some website. If that is so, and if this parameter is set to True, the representation of this entry will include a web_link pointing to the corresponding page on the website. If False, web_link will be omitted. :param as_of: The first version of the web service to feature this entry. :param versioned_annotations: A list of 2-tuples (version, {params}), with more recent web service versions earlier in the list and older versions later in the list. A 'params' dictionary may contain the key 'exported', which controls whether or not to publish the entry at all in the given version. It may also contain the keys 'singular_name', 'plural_name', 'contributes_to', or 'publish_web_link', which work just like the corresponding arguments to this method. """ _check_called_from_interface_def('export_as_webservice_entry()') def mark_entry(interface): """Class advisor that tags the interface once it is created.""" _check_interface('export_as_webservice_entry()', interface) annotation_stack = VersionedDict() if singular_name is None: # By convention, interfaces are called IWord1[Word2...]. The # default behavior assumes this convention and yields a # singular name of "word1_word2". my_singular_name = camelcase_to_underscore_separated( interface.__name__[1:]) else: my_singular_name = singular_name # Turn the named arguments into a dictionary for the first # exported version. initial_version = dict( type=ENTRY_TYPE, singular_name=my_singular_name, plural_name=plural_name, contributes_to=contributes_to, publish_web_link=publish_web_link, exported=True, _as_of_was_used=(not as_of is None)) afterwards = versioned_annotations or [] for version, annotations in itertools.chain( [(as_of, initial_version)], afterwards): annotation_stack.push(version) for key, value in annotations.items(): if annotations != initial_version: # Make sure that the 'annotations' dict # contains only recognized annotations. if key not in ENTRY_ANNOTATION_KEYS: raise ValueError( 'Unrecognized annotation for version "%s": ' '"%s"' % (version, key)) annotation_stack[key] = value # If this version provides a singular name but not a # plural name, apply the default pluralization rule. if (annotations.get('singular_name') is not None and annotations.get('plural_name') is None): annotation_stack['plural_name'] = ( annotations['singular_name'] + 's') interface.setTaggedValue(LAZR_WEBSERVICE_EXPORTED, annotation_stack) # Set the name of the fields that didn't specify it using the # 'export_as' parameter in exported(). This must be done here, # because the field's __name__ attribute is only set when the # interface is created. for name, field in getFields(interface).items(): tag_stack = field.queryTaggedValue(LAZR_WEBSERVICE_EXPORTED) if tag_stack is None or tag_stack.is_empty: continue if tag_stack['type'] != FIELD_TYPE: continue for version, tags in tag_stack.stack: # Set 'as' for every version in which the field is # published but no 'as' is specified. Also set # 'original_name' for every version in which the field # is published--this will help with performance # optimizations around permission checks. if tags.get('exported') != False: tags['original_name'] = name if tags.get('as') is None: tags['as'] = name annotate_exported_methods(interface) return interface addClassAdvisor(mark_entry) def exported(field, *versioned_annotations, **kwparams): """Mark the field as part of the entry data model. :param versioned_annotations: A list of (version, param) 2-tuples, with more recent web service versions earlier in the list and older versions later in the list. The 'param' objects can either be a string name for the field in the given version (None means to use the field's internal name), or a dictionary. The dictionary may contain the key 'exported', which controls whether or not to publish this field at all in the given version, and the key 'exported_as', which controls the name to use when publishing the field. exported_as=None means to use the field's internal name. :param as_of: The name of the earliest version to contain this field. :param exported_as: the name under which the field is published in the entry the first time it shows up (ie. in the 'as_of' version). By default, the field's internal name is used. :raises TypeError: if called on an object which doesn't provide IField. :returns: The field with an added tagged value. """ if not IField.providedBy(field): raise TypeError("exported() can only be used on IFields.") if IObject.providedBy(field) and not IReference.providedBy(field): raise TypeError("Object exported; use Reference instead.") # The first step is to turn the arguments into a VersionedDict # describing the different ways this field is exposed in different # versions. annotation_stack = VersionedDict() first_version_name = kwparams.pop('as_of', None) annotation_stack.push(first_version_name) annotation_stack['type'] = FIELD_TYPE if first_version_name is not None: # The user explicitly said to start publishing this field in a # particular version. annotation_stack['_as_of_was_used'] = True annotation_stack['exported'] = True annotation_key_for_argument_key = {'exported_as' : 'as', 'exported' : 'exported', 'readonly' : 'readonly'} # If keyword parameters are present, they define the field's # behavior for the first exposed version. Incorporate them into # the VersionedDict. for (key, annotation_key) in annotation_key_for_argument_key.items(): if key in kwparams: if (key == "exported" and kwparams[key] == False and first_version_name is not None): raise ValueError( ("as_of=%s says to export %s, but exported=False " "says not to.") % ( first_version_name, field.__class__.__name__)) annotation_stack[annotation_key] = kwparams.pop(key) # If any keywords are left over, raise an exception. if len(kwparams) > 0: raise TypeError("exported got an unexpected keyword " "argument '%s'" % kwparams.keys()[0]) # Now incorporate the list of named dicts into the VersionedDict. for version, annotations in reversed(versioned_annotations): # Push it onto the stack. annotation_stack.push(version) # Make sure that the 'annotations' dict contains only # recognized annotations. for key in annotations: if key not in annotation_key_for_argument_key: raise ValueError('Unrecognized annotation for version "%s": ' '"%s"' % (version, key)) annotation_stack[annotation_key_for_argument_key[key]] = ( annotations[key]) # Now we can annotate the field object with the VersionedDict. field.setTaggedValue(LAZR_WEBSERVICE_EXPORTED, annotation_stack) # We track the field's mutator and accessor information separately # because it's defined in the named operations, not in the fields. # The last thing we want to do is try to insert a foreign value into # an already created annotation stack. field.setTaggedValue(LAZR_WEBSERVICE_MUTATORS, {}) field.setTaggedValue(LAZR_WEBSERVICE_ACCESSORS, {}) return field def export_as_webservice_collection(entry_schema): """Mark the interface as exported on the web service as a collection. :raises TypeError: if the interface doesn't have a method decorated with @collection_default_content. """ _check_called_from_interface_def('export_as_webservice_collection()') if not IInterface.providedBy(entry_schema): raise TypeError("entry_schema must be an interface.") # Set the tags at this point, so that future declarations can # check it. tags = _get_interface_tags() tags[LAZR_WEBSERVICE_EXPORTED] = dict( type=COLLECTION_TYPE, collection_entry_schema=entry_schema) def mark_collection(interface): """Class advisor that tags the interface once it is created.""" _check_interface('export_as_webservice_collection()', interface) tag = interface.getTaggedValue(LAZR_WEBSERVICE_EXPORTED) if 'collection_default_content' not in tag: raise TypeError( "export_as_webservice_collection() is missing a method " "tagged with @collection_default_content.") annotate_exported_methods(interface) return interface addClassAdvisor(mark_collection) class collection_default_content: """Decorates the method that provides the default values of a collection. :raises TypeError: if not called from within an interface exported as a collection, or if used more than once in the same interface. """ def __init__(self, version=None, **params): """Create the decorator marking the default collection method. :param version: The first web service version that should use this method as the collection's default content. :param params: Optional parameter values to use when calling the method. This is to be used when the method has required parameters. """ _check_called_from_interface_def('@collection_default_content') tags = _get_interface_tags() tag = tags.get(LAZR_WEBSERVICE_EXPORTED) if tag is None or tag['type'] != COLLECTION_TYPE: raise TypeError( "@collection_default_content can only be used from within an " "interface exported as a collection.") default_content_methods = tag.setdefault( 'collection_default_content', {}) if version in default_content_methods: raise TypeError( "Only one method can be marked with " "@collection_default_content for version '%s'." % ( _version_name(version))) self.version = version self.params = params def __call__(self, f): """Annotates the collection with the name of the method to call.""" tag = _get_interface_tags()[LAZR_WEBSERVICE_EXPORTED] tag['collection_default_content'][self.version] = ( f.__name__, self.params) return f WEBSERVICE_ERROR = '__lazr_webservice_error__' def webservice_error(status): """Mark the exception with the HTTP status code to use. That status code will be used by the view used to handle that kind of exceptions on the web service. This is only effective when the exception is raised from within a published method. For example, if the exception is raised by the field's validation its specified status won't propagate to the response. """ frame = sys._getframe(1) f_locals = frame.f_locals # Try to make sure we were called from a class def. if (f_locals is frame.f_globals) or ('__module__' not in f_locals): raise TypeError( "webservice_error() can only be used from within an exception " "definition.") f_locals[WEBSERVICE_ERROR] = int(status) def error_status(status): """Make a Python 2.6 class decorator for the given status. Usage 1: @error_status(400) class FooBreakage(Exception): pass Usage 2 (legacy): class FooBreakage(Exception): pass error_status(400)(FooBreakage) That status code will be used by the view used to handle that kind of exceptions on the web service. This is only effective when the exception is raised from within a published method. For example, if the exception is raised by the field's validation, its specified status won't propagate to the response. """ status = int(status) def func(value): if not issubclass(value, Exception): raise TypeError('Annotated value must be an exception class.') old = getattr(value, WEBSERVICE_ERROR, None) if old is not None and old != status: raise ValueError('Exception already has an error status', old) setattr(value, WEBSERVICE_ERROR, status) return value return func class _method_annotator: """Base class for decorators annotating a method. The actual method will be wrapped in an IMethod specification once the Interface is complete. So we save the annotations in an attribute of the method, and the class advisor invoked by export_as_webservice_entry() and export_as_webservice_collection() will do the final tagging. """ def __call__(self, method): """Annotates the function with the fixed arguments.""" # Everything in the function dictionary ends up as tagged value # in the interface method specification. annotations = method.__dict__.get(LAZR_WEBSERVICE_EXPORTED, None) if annotations is None: # Create a new versioned dict which associates # annotation data with the earliest active version of the # web service. Future @webservice_version annotations will # push later versions onto the VersionedDict, allowing # new versions to specify annotation data that conflicts # with old versions. # # Because we don't know the name of the earliest version # published by the web service (we won't know this until # runtime), we'll use None as the name of the earliest # version. annotations = VersionedDict() annotations.push(None) # The initial presumption is that an operation is not # published in the earliest version of the web service. An # @export_*_operation declaration will modify # annotations['type'] in place to signal that it is in # fact being published. annotations['type'] = REMOVED_OPERATION_TYPE method.__dict__[LAZR_WEBSERVICE_EXPORTED] = annotations self.annotate_method(method, annotations) return method def annotate_method(self, method, annotations): """Add annotations for method. This method must be implemented by subclasses. :param f: the method being annotated. :param annotations: the dict containing the method annotations. The annotations will copied to the lazr.webservice.exported tag by a class advisor. """ raise NotImplemented def annotate_exported_methods(interface): """Sets the 'lazr.webservice.exported' tag on exported method.""" for name, method in interface.namesAndDescriptions(True): if not IMethod.providedBy(method): continue annotation_stack = method.queryTaggedValue(LAZR_WEBSERVICE_EXPORTED) if annotation_stack is None: continue if annotation_stack.get('type') is None: continue # Make sure that each version of the web service defines # a self-consistent view of this method. for version, annotations in annotation_stack.stack: if annotations['type'] == REMOVED_OPERATION_TYPE: # The method is published in other versions of the web # service, but not in this one. Don't try to validate this # version's annotations. continue # Method is exported under its own name by default. if 'as' not in annotations: annotations['as'] = method.__name__ # It's possible that call_with, operation_parameters, and/or # operation_returns_* weren't used. annotations.setdefault('call_with', {}) annotations.setdefault('params', {}) annotations.setdefault('return_type', None) # Make sure that all parameters exist and that we miss none. info = method.getSignatureInfo() defined_params = set(info['optional']) defined_params.update(info['required']) exported_params = set(annotations['params']) exported_params.update(annotations['call_with']) undefined_params = exported_params.difference(defined_params) if undefined_params and info['kwargs'] is None: raise TypeError( 'method "%s" doesn\'t have the following exported ' 'parameters in version "%s": %s.' % ( method.__name__, _version_name(version), ", ".join(sorted(undefined_params)))) missing_params = set( info['required']).difference(exported_params) if missing_params: raise TypeError( 'method "%s" is missing definitions for parameter(s) ' 'exported in version "%s": %s' % ( method.__name__, _version_name(version), ", ".join(sorted(missing_params)))) _update_default_and_required_params(annotations['params'], info) def _update_default_and_required_params(params, method_info): """Set missing default/required based on the method signature.""" optional = method_info['optional'] required = method_info['required'] for name, param_def in params.items(): # If the method parameter is optional and the param didn't have # a default, set it to the same as the method. if name in optional and param_def.default is None: default = optional[name] # This is to work around the fact that all strings in # zope schema are expected to be unicode, whereas it's # really possible that the method's default is a simple # string. if isinstance(default, str) and IText.providedBy(param_def): default = unicode(default) param_def.default = default param_def.required = False elif name in required and param_def.default is not None: # A default was provided, so the parameter isn't required. param_def.required = False else: # Nothing to do for that case. pass class call_with(_method_annotator): """Decorator specifying fixed parameters for exported methods.""" def __init__(self, **params): """Specify fixed values for parameters.""" _check_called_from_interface_def('%s()' % self.__class__.__name__) self.params = params def annotate_method(self, method, annotations): """See `_method_annotator`.""" annotations['call_with'] = self.params class mutator_for(_method_annotator): """Decorator indicating that an exported method mutates a field. The method can be invoked through POST, or by setting a value for the given field as part of a PUT or PATCH request. """ def __init__(self, field): """Specify the field for which this method is a mutator.""" self.field = field def annotate_method(self, method, annotations): """See `_method_annotator`. Store information about the mutator method with the field. """ if not self.field.readonly: raise TypeError("Only a read-only field can have a mutator " "method.") # The mutator method must take only one argument, not counting # arguments with values fixed by call_with(). free_params = _free_parameters(method, annotations) if len(free_params) != 1: raise TypeError("A mutator method must take one and only one " "non-fixed argument. %s takes %d." % (method.__name__, len(free_params))) # We need to keep mutator annotations in a separate dictionary # from the field's main annotations because we're not # processing the field. We're processing a named operation, # and we have no idea where the named operation's current # version fits into the field's annotations. version, method_annotations = annotations.stack[-1] mutator_annotations = self.field.queryTaggedValue( LAZR_WEBSERVICE_MUTATORS) if version in mutator_annotations: raise TypeError( "A field can only have one mutator method for version %s; " "%s makes two." % (_version_name(version), method.__name__ )) mutator_annotations[version] = (method, dict(method_annotations)) method_annotations['is_mutator'] = True class accessor_for(_method_annotator): """Decorator indicating that an method is an accessor for a field. The method can be invoked by accessing the field's URL. """ def __init__(self, field): """Specify the field for which this method is a mutator.""" self.field = field def annotate_method(self, method, annotations): """See `_method_annotator`. Store information about the accessor method with the field. """ # We need to keep accessor annotations in a separate dictionary # from the field's main annotations because we're not processing # the field. We're processing a named operation, and we have no # idea where the named operation's current version fits into the # field's annotations. version, method_annotations = annotations.stack[-1] accessor_annotations = self.field.queryTaggedValue( LAZR_WEBSERVICE_ACCESSORS) if version in accessor_annotations: raise TypeError( "A field can only have one accessor method for version %s; " "%s makes two." % (_version_name(version), method.__name__ )) accessor_annotations[version] = (method, dict(method_annotations)) method_annotations['is_accessor'] = True def _free_parameters(method, annotations): """Figure out which of a method's parameters are free. Parameters that have values fixed by call_with() are not free. """ signature = fromFunction(method).getSignatureInfo() return (set(signature['required']) - set(annotations.get('call_with', {}).keys())) class operation_for_version(_method_annotator): """Decorator specifying which version of the webservice is defined. Decorators processed after this one will decorate the given web service version and, by default, subsequent versions will inherit their values. Subsequent versions may provide conflicting values, but those values will not affect this version. """ def __init__(self, version): _check_called_from_interface_def('%s()' % self.__class__.__name__) self.version = version def annotate_method(self, method, annotations): """See `_method_annotator`.""" # The annotations dict is a VersionedDict. Push a new dict # onto its stack, labeled with the version number, and copy in # the old version's annotations so that this version can # modify those annotations without destroying them. annotations.push(self.version) class operation_removed_in_version(operation_for_version): """Decoration removing this operation from the web service. This operation will not be present in the given version of the web service, or any subsequent version, unless it's re-published with an export_*_operation method. """ def annotate_method(self, method, annotations): """See `_method_annotator`.""" # The annotations dict is a VersionedDict. Push a new dict # onto its stack, labeled with the version number. Make sure the # new dict is empty rather than copying the old annotations annotations.push(self.version, True) # We need to set a special 'type' so that lazr.restful can # easily distinguish a method that's not present in the latest # version from a method that was incompletely annotated. annotations['type'] = REMOVED_OPERATION_TYPE class export_operation_as(_method_annotator): """Decorator specifying the name to export the method as.""" def __init__(self, name): _check_called_from_interface_def('%s()' % self.__class__.__name__) self.name = name def annotate_method(self, method, annotations): """See `_method_annotator`.""" annotations['as'] = self.name class rename_parameters_as(_method_annotator): """Decorator specifying the name to export the method parameters as.""" def __init__(self, **params): """params is of the form method_parameter_name=webservice_name.""" _check_called_from_interface_def('%s()' % self.__class__.__name__) self.params = params def annotate_method(self, method, annotations): """See `_method_annotator`.""" param_defs = annotations.get('params') if param_defs is None: raise TypeError( '"%s" isn\'t exported on the webservice.' % method.__name__) for name, export_as in self.params.items(): if name not in param_defs: raise TypeError( 'rename_parameters_as(): no "%s" parameter is exported.' % name) param_defs[name].__name__ = export_as class operation_parameters(_method_annotator): """Specify the parameters taken by the exported operation. The decorator takes a list of `IField` describing the parameters. The name of the underlying method parameter is taken from the argument name. """ def __init__(self, **params): """params is of the form method_parameter_name=Field().""" _check_called_from_interface_def('%s()' % self.__class__.__name__) self.params = params def annotate_method(self, method, annotations): """See `_method_annotator`.""" # It's possible that another decorator already created the params # annotation. params = annotations.setdefault('params', {}) for name, param in self.params.items(): if not IField.providedBy(param): raise TypeError( 'export definition of "%s" in method "%s" must ' 'provide IField: %r' % (name, method.__name__, param)) if name in params: raise TypeError( "'%s' parameter is already defined." % name) # By default, parameters are exported under their own name. param.__name__ = name params[name] = param class operation_returns_entry(_method_annotator): """Specify that the exported operation returns an entry. The decorator takes a single argument: an interface that's been exported as an entry. """ def __init__(self, schema): _check_called_from_interface_def('%s()' % self.__class__.__name__) if not IInterface.providedBy(schema): raise TypeError('Entry type %s does not provide IInterface.' % schema) self.return_type = Reference(schema=schema) def annotate_method(self, method, annotations): annotations['return_type'] = self.return_type class operation_returns_collection_of(_method_annotator): """Specify that the exported operation returns a collection. The decorator takes one required argument, "schema", an interface that's been exported as an entry. """ def __init__(self, schema): _check_called_from_interface_def('%s()' % self.__class__.__name__) if not IInterface.providedBy(schema): raise TypeError('Collection value type %s does not provide ' 'IInterface.' % schema) self.return_type = CollectionField( value_type=Reference(schema=schema)) def annotate_method(self, method, annotations): annotations['return_type'] = self.return_type class _export_operation(_method_annotator): """Basic implementation for the webservice operation method decorators.""" # Should be overriden in subclasses with the string to use as 'type'. type = None def __init__(self): _check_called_from_interface_def('%s()' % self.__class__.__name__) def annotate_method(self, method, annotations): """See `_method_annotator`.""" annotations['type'] = self.type class export_factory_operation(_export_operation): """Decorator marking a method as being a factory on the webservice.""" type = 'factory' def __init__(self, interface, field_names): """Creates a factory decorator. :param interface: The interface where fields specified in field_names are looked-up. :param field_names: The names of the fields in the schema that are used as parameters by this factory. """ # pylint: disable-msg=W0231 _check_called_from_interface_def('%s()' % self.__class__.__name__) self.interface = interface self.params = {} for name in field_names: field = interface.get(name) if field is None: raise TypeError("%s doesn't define '%s'." % ( interface.__name__, name)) if not IField.providedBy(field): raise TypeError("%s.%s doesn't provide IField." % ( interface.__name__, name)) self.params[name] = copy_field(field) def annotate_method(self, method, annotations): """See `_method_annotator`.""" super(export_factory_operation, self).annotate_method( method, annotations) annotations['creates'] = self.interface annotations['params'] = self.params annotations['return_type'] = ObjectLink(schema=self.interface) class cache_for(_method_annotator): """Decorator specifying how long a response may be cached by a client.""" def __init__(self, duration): """Specify the duration, in seconds, of the caching resource.""" if not isinstance(duration, (int, long)): raise TypeError( 'Caching duration should be int or long, not %s' % duration.__class__.__name__) if duration <= 0: raise ValueError( 'Caching duration should be a positive number: %s' % duration) self.duration = duration def annotate_method(self, method, annotations): """See `_method_annotator`.""" annotations['cache_for'] = self.duration class export_read_operation(_export_operation): """Decorator marking a method for export as a read operation.""" type = 'read_operation' class export_write_operation(_export_operation): """Decorator marking a method for export as a write operation.""" type = "write_operation" class export_destructor_operation(_export_operation): """Decorator indicating that an exported method destroys an entry. The method will be invoked when the client sends a DELETE request to the entry. """ type = "destructor" def annotate_method(self, method, annotation_stack): """See `_method_annotator`. Store information about the mutator method with the method. Every version must have a self-consistent set of annotations. """ super(export_destructor_operation, self).annotate_method( method, annotation_stack) # The mutator method must take no arguments, not counting # arguments with values fixed by call_with(). for version, annotations in annotation_stack.stack: if annotations['type'] == REMOVED_OPERATION_TYPE: continue free_params = _free_parameters(method, annotations) if len(free_params) != 0: raise TypeError( "A destructor method must take no non-fixed arguments. " 'In version %s, the "%s" method takes %d: "%s".' % ( _version_name(version), method.__name__, len(free_params), '", "'.join(free_params)) ) def _check_tagged_interface(interface, type): """Make sure that the interface is exported under the proper type.""" if not isinstance(interface, InterfaceClass): raise TypeError('not an interface.') tag = interface.queryTaggedValue(LAZR_WEBSERVICE_EXPORTED) if tag is None: raise TypeError( "'%s' isn't tagged for webservice export." % interface.__name__) elif tag['type'] != type: art = 'a' if type == 'entry': art = 'an' raise TypeError( "'%s' isn't exported as %s %s." % (interface.__name__, art, type)) def generate_entry_interfaces(interface, contributors=[], *versions): """Create IEntry subinterfaces based on the tags in `interface`. :param interface: The data model interface to use as the basis for a number of IEntry subinterfaces. :param versions: The list of versions published by this service, earliest versions first. :return: A list of 2-tuples (version, interface), in the same order as `versions`. """ _check_tagged_interface(interface, 'entry') versions = list(versions) # Make sure any given version defines only one destructor method. destructor_for_version = {} for name, method in interface.namesAndDescriptions(True): if not IMethod.providedBy(method): continue method_annotations = method.queryTaggedValue( LAZR_WEBSERVICE_EXPORTED) if method_annotations is None: continue for version, annotations in method_annotations.stack: if annotations.get('type') == export_destructor_operation.type: destructor = destructor_for_version.get(version) if destructor is not None: raise TypeError( 'An entry can only have one destructor method for ' 'version %s; %s and %s make two.' % ( _version_name(version), method.__name__, destructor.__name__)) destructor_for_version[version] = method # Build a data set describing this entry, as it appears in each # version of the web service in which it appears at all. entry_tags = interface.queryTaggedValue(LAZR_WEBSERVICE_EXPORTED) stack = entry_tags.stack earliest_version = versions[0] # Now that we know the name of the earliest version, get rid of # any None at the beginning of the stack. if stack[0].version is None: entry_tags.rename_version(None, earliest_version) # If require_explicit_versions is set, make sure the first version # to set 'exported' also sets '_as_of_was_used'. _enforce_explicit_version( entry_tags, 'Entry "%s": ' % interface.__name__) # Make sure there's one set of entry tags for every version of the # web service, including versions in which this entry is not # published. entry_tags.normalize_for_versions( versions, {'type': ENTRY_TYPE, 'exported': False}, 'Interface "%s": ' % interface.__name__) # Next, we'll normalize each published field. A normalized field # has a set of annotations for every version in which the entry is # published. We'll make a list of the published fields, which # we'll iterate over once for each version. tags_for_published_fields = [] for iface in itertools.chain([interface], contributors): for name, field in getFields(iface).items(): tag_stack = field.queryTaggedValue(LAZR_WEBSERVICE_EXPORTED) if tag_stack is None: # This field is not published at all. continue error_message_prefix = ( 'Field "%s" in interface "%s": ' % (name, iface.__name__)) _normalize_field_annotations(field, versions, error_message_prefix) tags_for_published_fields.append((name, field, tag_stack)) generated_interfaces = [] entry_tags = interface.queryTaggedValue(LAZR_WEBSERVICE_EXPORTED) for version in versions: entry_tags_this_version = entry_tags.dict_for_name(version) if entry_tags_this_version.get('exported') is False: # Don't define an entry interface for this version at all. continue attrs = {} for name, field, tag_stack in tags_for_published_fields: tags = tag_stack.dict_for_name(version) if tags.get('exported') is False: continue mutated_by, mutated_by_annotations = tags.get( 'mutator_annotations', (None, {})) readonly = (field.readonly and mutated_by is None) if 'readonly' in tags: publish_as_readonly = tags.get('readonly') if readonly and not publish_as_readonly: raise TypeError( ("%s.%s is defined as a read-only field, so you " "can't just declare it to be read-write in " "the web service: you must define a mutator.") % ( interface.__name__, field.__name__)) if not readonly and publish_as_readonly: # The field is read-write internally, but the # developer wants it to be read-only through the web # service. readonly = True attrs[tags['as']] = copy_field( field, __name__=tags['as'], readonly=readonly) class_name = _versioned_class_name( "%sEntry" % interface.__name__, version) entry_interface = InterfaceClass( class_name, bases=(IEntry, ), attrs=attrs, __doc__=interface.__doc__, __module__=interface.__module__) versioned_tag = entry_tags.dict_for_name(version) entry_interface.setTaggedValue(LAZR_WEBSERVICE_NAME, dict( singular=versioned_tag['singular_name'], plural=versioned_tag['plural_name'], publish_web_link=versioned_tag['publish_web_link'])) generated_interfaces.append(VersionedObject(version, entry_interface)) return generated_interfaces def generate_entry_adapters( content_interface, contributors, webservice_interfaces): """Create classes adapting from content_interface to webservice_interfaces. Unlike with generate_collection_adapter and generate_operation_adapter, the simplest implementation generates an entry adapter for every version at the same time. :param content_interface: The original data model interface being exported. :param webservice_interfaces: A list of 2-tuples (version string, webservice interface) containing the generated interfaces for each version of the web service. :param return: A list of 2-tuples (version string, adapter class) """ _check_tagged_interface(content_interface, 'entry') # Go through the fields and build up a picture of what this entry looks # like for every version. adapters_by_version = {} fields = getFields(content_interface).items() for version, iface in webservice_interfaces: fields.extend(getFields(iface).items()) for name, field in fields: tag_stack = field.queryTaggedValue(LAZR_WEBSERVICE_EXPORTED) if tag_stack is None: continue for tags_version, tags in tag_stack.stack: # Has this version been mentioned before? If not, add a # dictionary to adapters_by_version. This dictionary will # be turned into an adapter class for this version. adapter_dict = adapters_by_version.setdefault(tags_version, {}) # Set '_orig_interfaces' as early as possible as we want that # attribute (even if empty) on all adapters. orig_interfaces = adapter_dict.setdefault('_orig_interfaces', {}) # Figure out the mutator for this version and add it to # this version's adapter class dictionary. if tags.get('exported') is False: continue mutator, mutator_annotations = tags.get( 'mutator_annotations', (None, {})) accessor, accessor_annotations = tags.get( 'accessor_annotations', (None, {})) # Always use the field's original_name here as we've combined # fields from the content interface with fields of the webservice # interfaces (where they may have different names). orig_name = tags['original_name'] # Some fields may be provided by an adapter of the content class # instead of being provided directly by the content class. In # these cases we'd need to adapt the content class before trying # to access the field, so to simplify things we always do the # adaptation and rely on the fact that it will be a no-op when an # we adapt an object into an interface it already provides. orig_iface = content_interface for contributor in contributors: if orig_name in contributor: orig_iface = contributor assert orig_name in orig_iface, ( "Could not find interface where %s is defined" % orig_name) if accessor is not None and mutator is not None: prop = PropertyWithAccessorAndMutator( orig_name, 'context', accessor, accessor_annotations, mutator, mutator_annotations, orig_iface) elif mutator is not None and accessor is None: prop = PropertyWithMutator( orig_name, 'context', mutator, mutator_annotations, orig_iface) elif accessor is not None and mutator is None: prop = PropertyWithAccessor( orig_name, 'context', accessor, accessor_annotations, orig_iface) else: prop = Passthrough(orig_name, 'context', orig_iface) adapter_dict[tags['as']] = prop # A dict mapping field names to the interfaces where they came # from. orig_interfaces[name] = orig_iface adapters = [] for version, webservice_interface in webservice_interfaces: if not isinstance(webservice_interface, InterfaceClass): raise TypeError('webservice_interface is not an interface.') class_dict = adapters_by_version.get(version, {}) # If this interface doesn't export a single field/operation, # class_dict will be empty, and so we'll add '_orig_interfaces' # manually as it should be always available, even if empty. if class_dict == {}: class_dict['_orig_interfaces'] = {} class_dict['schema'] = webservice_interface class_dict['__doc__'] = webservice_interface.__doc__ # The webservice interface class name already includes the version # string, so there's no reason to add it again to the end # of the class name. classname = "%sAdapter" % webservice_interface.__name__[1:] factory = type(classname, (Entry,), class_dict) classImplements(factory, webservice_interface) protect_schema( factory, webservice_interface, write_permission=CheckerPublic) adapters.append(VersionedObject(version, factory)) return adapters def params_with_dereferenced_user(params): """Make a copy of the given parameters with REQUEST_USER dereferenced.""" params = params.copy() for name, value in params.items(): if value is REQUEST_USER: params[name] = getUtility( IWebServiceConfiguration).get_request_user() return params class _AccessorWrapper: """A wrapper class for properties with accessors. We define this separately from PropertyWithAccessor and PropertyWithAccessorAndMutator to avoid multple inheritance issues. """ def __get__(self, obj, *args): """Call the accessor method to get the value.""" params = params_with_dereferenced_user( self.accessor_annotations.get('call_with', {})) context = getattr(obj, self.contextvar) if self.adaptation is not None: context = self.adaptation(context) # Error checking code in accessor_for() guarantees that there # is one and only one non-fixed parameter for the accessor # method. return getattr(context, self.accessor)(**params) class _MutatorWrapper: """A wrapper class for properties with mutators. We define this separately from PropertyWithMutator and PropertyWithAccessorAndMutator to avoid multple inheritance issues. """ def __set__(self, obj, new_value): """Call the mutator method to set the value.""" params = params_with_dereferenced_user( self.mutator_annotations.get('call_with', {})) context = getattr(obj, self.contextvar) if self.adaptation is not None: context = self.adaptation(context) # Error checking code in mutator_for() guarantees that there # is one and only one non-fixed parameter for the mutator # method. getattr(context, self.mutator)(new_value, **params) class PropertyWithAccessor(_AccessorWrapper, Passthrough): """A property with a accessor method.""" def __init__(self, name, context, accessor, accessor_annotations, adaptation): super(PropertyWithAccessor, self).__init__(name, context, adaptation) self.accessor = accessor.__name__ self.accessor_annotations = accessor_annotations class PropertyWithMutator(_MutatorWrapper, Passthrough): """A property with a mutator method.""" def __init__(self, name, context, mutator, mutator_annotations, adaptation): super(PropertyWithMutator, self).__init__(name, context, adaptation) self.mutator = mutator.__name__ self.mutator_annotations = mutator_annotations class PropertyWithAccessorAndMutator(_AccessorWrapper, _MutatorWrapper, Passthrough): """A Property with both an accessor an a mutator.""" def __init__(self, name, context, accessor, accessor_annotations, mutator, mutator_annotations, adaptation): super(PropertyWithAccessorAndMutator, self).__init__( name, context, adaptation) self.accessor = accessor.__name__ self.accessor_annotations = accessor_annotations self.mutator = mutator.__name__ self.mutator_annotations = mutator_annotations class CollectionEntrySchema: """A descriptor for converting a model schema into an entry schema. The entry schema class for a resource may not have been defined at the time the collection adapter is generated, but the data model class certainly will have been. This descriptor performs the lookup as needed, at runtime. """ def __init__(self, model_schema): """Initialize with a model schema.""" self.model_schema = model_schema def __get__(self, instance, owner): """Look up the entry schema that adapts the model schema.""" if instance is None or instance.request is None: request = get_current_web_service_request() else: request = instance.request request_interface = getUtility( IWebServiceVersion, name=request.version) entry_class = getGlobalSiteManager().adapters.lookup( (self.model_schema, request_interface), IEntry) if entry_class is None: return None return EntryAdapterUtility(entry_class).entry_interface class BaseCollectionAdapter(Collection): """Base for generated ICollection adapter.""" # These attributes will be set in the generated subclass. method_name = None params = None def find(self): """See `ICollection`.""" method = getattr(self.context, self.method_name) params = params_with_dereferenced_user(self.params) return method(**params) def generate_collection_adapter(interface, version=None): """Create a class adapting from interface to ICollection.""" _check_tagged_interface(interface, 'collection') tag = interface.getTaggedValue(LAZR_WEBSERVICE_EXPORTED) default_content_by_version = tag['collection_default_content'] assert (version in default_content_by_version), ( "'%s' isn't tagged for export to web service " "version '%s'." % (interface.__name__, version)) method_name, params = default_content_by_version[version] entry_schema = tag['collection_entry_schema'] class_dict = { 'entry_schema' : CollectionEntrySchema(entry_schema), 'method_name': method_name, 'params': params, '__doc__': interface.__doc__, } classname = _versioned_class_name( "%sCollectionAdapter" % interface.__name__[1:], version) factory = type(classname, (BaseCollectionAdapter,), class_dict) protect_schema(factory, ICollection) return factory class BaseResourceOperationAdapter(ResourceOperation): """Base class for generated operation adapters.""" def _getMethod(self): return getattr(self._orig_iface(self.context), self._method_name) def _getMethodParameters(self, kwargs): """Return the method parameters. This takes the validated parameters list and handle any possible renames, and adds the parameters fixed using @call_with. :returns: a dictionary. """ # Handle renames. renames = dict( (param_def.__name__, orig_name) for orig_name, param_def in self._export_info['params'].items() if param_def.__name__ != orig_name) params = {} for name, value in kwargs.items(): name = renames.get(name, name) params[name] = value # Handle fixed parameters. params.update(params_with_dereferenced_user( self._export_info['call_with'])) return params def call(self, **kwargs): """See `ResourceOperation`.""" params = self._getMethodParameters(kwargs) # For responses to GET requests, tell the client to cache the # response. if (IResourceGETOperation.providedBy(self) and 'cache_for' in self._export_info): self.request.response.setHeader( 'Cache-control', 'max-age=%i' % self._export_info['cache_for']) result = self._getMethod()(**params) return self.encodeResult(result) class BaseFactoryResourceOperationAdapter(BaseResourceOperationAdapter): """Base adapter class for factory operations.""" def call(self, **kwargs): """See `ResourceOperation`. Factory uses the 201 status code on success and sets the Location header to the URL to the created object. """ params = self._getMethodParameters(kwargs) result = self._getMethod()(**params) response = self.request.response response.setStatus(201) response.setHeader('Location', absoluteURL(result, self.request)) return u'' def generate_operation_adapter(method, version=None): """Create an IResourceOperation adapter for the exported method. :param version: The name of the version for which to generate an operation adapter. None means to generate an adapter for the earliest version. If IWebServiceConfiguration.require_explicit_versions is set, passing in None will cause an error. """ if not IMethod.providedBy(method): raise TypeError("%r doesn't provide IMethod." % method) tag = method.queryTaggedValue(LAZR_WEBSERVICE_EXPORTED) if tag is None: raise TypeError( "'%s' isn't tagged for webservice export." % method.__name__) match = tag.dict_for_name(version) if match is None: raise AssertionError("'%s' isn't tagged for export to web service " "version '%s'" % (method.__name__, version)) config = getUtility(IWebServiceConfiguration) if version is None: # This code path is not currently used except in a test, since # generate_and_register_webservice_operations never passes in # None for `version`. version = config.active_versions[0] if config.require_explicit_versions: raise ValueError( '"%s" is implicitly tagged for export to web service ' 'version "%s", but the service configuration requires ' "all version declarations to be explicit. You should add " '@operation_for_version("%s") to the bottom of the ' 'annotation stack.' % (method.__name__, version, version)) bases = (BaseResourceOperationAdapter, ) operation_type = match['type'] if operation_type == 'read_operation': prefix = 'GET' provides = IResourceGETOperation elif operation_type in ('factory', 'write_operation'): provides = IResourcePOSTOperation prefix = 'POST' if operation_type == 'factory': bases = (BaseFactoryResourceOperationAdapter,) elif operation_type == 'destructor': provides = IResourceDELETEOperation prefix = 'DELETE' else: raise AssertionError('Unknown method export type: %s' % operation_type) return_type = match['return_type'] name = _versioned_class_name( '%s_%s_%s' % (prefix, method.interface.__name__, match['as']), version) class_dict = { 'params': tuple(match['params'].values()), 'return_type': return_type, '_orig_iface': method.interface, '_export_info': match, '_method_name': method.__name__, '__doc__': method.__doc__} if operation_type == 'write_operation': class_dict['send_modification_event'] = True factory = type(name, bases, class_dict) classImplements(factory, provides) protect_schema(factory, provides) return factory def _normalize_field_annotations(field, versions, error_prefix=''): """Make sure a field has annotations for every published version. If a field lacks annotations for a given version, it will not show up in that version's adapter interface. This function makes sure version n+1 inherits version n's behavior, by copying it that behavior over. If the earliest version has both an implicit definition (from keyword arguments) and an explicit definition, the two definitions are consolidated. Since we have the list of versions available, this is also a good time to do some error checking: make sure that version annotations are not duplicated or in the wrong order. Finally, this is a good time to integrate the mutator annotations into the field annotations. """ versioned_dict = field.queryTaggedValue(LAZR_WEBSERVICE_EXPORTED) mutator_annotations = field.queryTaggedValue(LAZR_WEBSERVICE_MUTATORS) accessor_annotations = field.queryTaggedValue(LAZR_WEBSERVICE_ACCESSORS) earliest_version = versions[0] stack = versioned_dict.stack if (len(stack) >= 2 and stack[0].version is None and stack[1].version == earliest_version): # The behavior of the earliest version is defined with keyword # arguments, but the first explicitly-defined version also # refers to the earliest version. We need to consolidate the # versions. implicit_earliest_version = stack[0].object explicit_earliest_version = stack[1].object for key, value in explicit_earliest_version.items(): if key not in implicit_earliest_version: # This key was defined for the earliest version using a # configuration dictionary, but not defined at all # using keyword arguments. The configuration # dictionary takes precedence. continue implicit_value = implicit_earliest_version[key] if implicit_value == value: # The two values are in sync. continue if key == 'as' and implicit_value == field.__name__: # The implicit value was set by the system, not by the # user. The later value will simply take precedence. continue raise ValueError( error_prefix + 'Annotation "%s" has conflicting values ' 'for the earliest version: "%s" (from keyword arguments) ' 'and "%s" (defined explicitly).' % ( key, implicit_value, value)) stack[0].object.update(stack[1].object) stack.remove(stack[1]) # Now that we know the name of the earliest version, get rid of # any None at the beginning of the stack. if stack[0].version is None: versioned_dict.rename_version(None, earliest_version) # If require_explicit_versions is set, make sure the first version # to set 'exported' also sets '_as_of_was_used'. _enforce_explicit_version(versioned_dict, error_prefix) # Make sure there is at most one mutator for the earliest version. # If there is one, move it from the mutator-specific dictionary to # the normal tag stack. implicit_earliest_mutator = mutator_annotations.get(None, None) explicit_earliest_mutator = mutator_annotations.get(earliest_version, None) if (implicit_earliest_mutator is not None and explicit_earliest_mutator is not None): raise ValueError( error_prefix + " Both implicit and explicit mutator definitions " "found for earliest version %s." % earliest_version) earliest_mutator = implicit_earliest_mutator or explicit_earliest_mutator if earliest_mutator is not None: stack[0].object['mutator_annotations'] = earliest_mutator # Make sure there is at most one accessor for the earliest version. # If there is one, move it from the accessor-specific dictionary to # the normal tag stack. implicit_earliest_accessor = accessor_annotations.get(None, None) explicit_earliest_accessor = accessor_annotations.get( earliest_version, None) if (implicit_earliest_accessor is not None and explicit_earliest_accessor is not None): raise ValueError( error_prefix + " Both implicit and explicit accessor definitions " "found for earliest version %s." % earliest_version) earliest_accessor = ( implicit_earliest_accessor or explicit_earliest_accessor) if earliest_accessor is not None: stack[0].object['accessor_annotations'] = earliest_accessor # Fill out the stack so that there is one set of tags for each # version. versioned_dict.normalize_for_versions( versions, dict(exported=False), error_prefix) # Make sure that a mutator defined in version N is inherited in # version N+1. most_recent_mutator_tags = earliest_mutator for version in versions[1:]: most_recent_mutator_tags = mutator_annotations.get( version, most_recent_mutator_tags) # Install a (possibly inherited) mutator for this field in # this version. if most_recent_mutator_tags is not None: tags_for_version = versioned_dict.dict_for_name(version) tags_for_version['mutator_annotations'] = copy.deepcopy( most_recent_mutator_tags) # Make sure that a accessor defined in version N is inherited in # version N+1. most_recent_accessor_tags = earliest_accessor for version in versions[1:]: most_recent_accessor_tags = accessor_annotations.get( version, most_recent_accessor_tags) # Install a (possibly inherited) accessor for this field in # this version. if most_recent_accessor_tags is not None: tags_for_version = versioned_dict.dict_for_name(version) tags_for_version['accessor_annotations'] = copy.deepcopy( most_recent_accessor_tags) return field def _enforce_explicit_version(versioned_dict, error_prefix): """Raise ValueError if the explicit version requirement is not met. If the configuration has `require_explicit_versions` set, then the first version in the given VersionedDict to include a True value for 'exported' must also include a True value for '_as_of_was_used'. :param versioned_dict: a VersionedDict. :param error_prefix: a string to be prepended onto any error message. """ if not getUtility(IWebServiceConfiguration).require_explicit_versions: return for version, annotations in versioned_dict.stack: if annotations.get('exported', False): if not annotations.get('_as_of_was_used', False): raise ValueError( error_prefix + "Exported in version %s, but not" " by using as_of. The service configuration" " requires that you use as_of." % version) break def _version_name(version): """Return a human-readable version name. If `version` is None (indicating the as-yet-unknown earliest version), returns "(earliest version)". Otherwise returns the version name. """ if version is None: return "(earliest version)" return version def _versioned_class_name(base_name, version): """Create a class name incorporating the given version string.""" if version is None: # We need to incorporate the version into a Python class name, # but we won't find out the name of the earliest version until # runtime. Use a generic string that won't conflict with a # real version string. version = "__Earliest" name = "%s_%s" % (base_name, version.encode('utf8')) return make_identifier_safe(name) lazr.restful-0.19.3/src/lazr/restful/wadl20061109.xsd0000644000175000017500000002314511631755356022175 0ustar benjibenji00000000000000 lazr.restful-0.19.3/src/lazr/restful/frameworks/0000755000175000017500000000000011636155340022046 5ustar benjibenji00000000000000lazr.restful-0.19.3/src/lazr/restful/frameworks/django.py0000644000175000017500000001060211631755356023671 0ustar benjibenji00000000000000# Copyright 2009 Canonical Ltd. All rights reserved. """Helpers for publishing Django model objects in lazr.restful services.""" __metaclass__ = type __all__ = [ 'DjangoLocation', 'DjangoWebServiceConfiguration', 'IDjangoLocation', 'ManagerSequencer', ] import settings import martian from zope.interface import Interface, implements from zope.interface.common.sequence import IFiniteSequence from zope.location.interfaces import ILocation from zope.schema import getFieldsInOrder from zope.traversing.browser.interfaces import IAbsoluteURL from zope.traversing.browser.absoluteurl import AbsoluteURL import grokcore.component # Without this trickery, this module (called "django") will mask the # real Django modules. We could use "from __future__ import # absolute_import", but that would not work with Python 2.4. ObjectDoesNotExist = __import__( 'django.core.exceptions', {}).core.exceptions.ObjectDoesNotExist Manager = __import__( 'django.db.models.manager', {}).db.models.manager.Manager from lazr.restful import directives from lazr.restful.interfaces import ( IWebServiceLayer, IWebServiceClientRequest, IWebServiceConfiguration) from lazr.restful.error import NotFoundView from lazr.restful.simple import BaseWebServiceConfiguration class DjangoWebServiceConfiguration(BaseWebServiceConfiguration): """Retrieve configuration options from the Django settings module. Create an empty subclass of this class, and you can configure your web service from your settings.py file. """ class DjangoConfigurationGrokker(martian.ClassGrokker): """Pull lazr.restful configuration from the Django 'settings' module. Subclass DjangoWebServiceConfiguration to get a configuration class that can be overridden from your Django 'settings' module. """ martian.component(DjangoWebServiceConfiguration) def execute(self, cls, config, *kw): for name, field in getFieldsInOrder(IWebServiceConfiguration): settings_name = "LAZR_RESTFUL_" + name.upper() value = getattr(settings, settings_name, None) if value is not None: setattr(cls, name, value) return True class IDjangoLocation(Interface): """Like Zope's ILocation, but adapted to work with Django. The problem with ILocation is that you have to define __name__, and the Django metaclass won't let you assign a property to __name__. """ def __url_path__(self): """The URL path for this object.""" def __parent__(self): """This object's parent in the object tree.""" # We want a raised django.core.exceptions.ObjectDoesNotExist # object to result in a 404 status code. But we don't # control that class definition, so we can't annotate it. # Instead, we define an adapter from that exception class to the # NotFoundView grokcore.component.global_adapter( NotFoundView, (ObjectDoesNotExist, IWebServiceClientRequest), Interface, name="index.html") class DjangoAbsoluteURL(AbsoluteURL): """An AbsoluteURL implementation for Django.""" directives.location_interface(IDjangoLocation) class DjangoLocation(object): """Adapts Django model objects to ILocation. See `IDjangoLocation` for why Django model objects can't implement ILocation directly. """ implements(ILocation) def __init__(self, context): self.context = context @property def __parent__(self): return self.context.__parent__ @property def __name__(self): return self.context.__url_path__ grokcore.component.global_adapter(DjangoLocation, IDjangoLocation, ILocation) class ManagerSequencer(object): """Makes a Django manager object usable with lazr.batchnavigator. IFiniteSequence requires that we implement __len__, and either __getitem__ or __iter__. We implement all three. """ implements(IFiniteSequence) def __init__(self, manager): """Initialize with respect to a Django manager object.""" self.manager = manager def __iter__(self): """Return an iterator over the dataset.""" return self.manager.iterator() def __getitem__(self, index_or_slice): """Slice the dataset.""" return self.manager.all()[(index_or_slice)] def __len__(self): """Return the length of the dataset.""" return self.manager.count() grokcore.component.global_adapter(ManagerSequencer, Manager, IFiniteSequence) lazr.restful-0.19.3/src/lazr/restful/frameworks/django.zcml0000644000175000017500000000022411631755356024205 0ustar benjibenji00000000000000 lazr.restful-0.19.3/src/lazr/restful/frameworks/__init__.py0000644000175000017500000000000011631755356024155 0ustar benjibenji00000000000000lazr.restful-0.19.3/src/lazr/restful/ftesting.zcml0000644000175000017500000000063011631755356022407 0ustar benjibenji00000000000000 lazr.restful-0.19.3/src/lazr/restful/tales.py0000644000175000017500000006164711631755356021376 0ustar benjibenji00000000000000# Copyright 2008 Canonical Ltd. All rights reserved. # """Implementation of the ws: namespace in TALES.""" __metaclass__ = type all = ['entry_adapter_for_schema'] import operator import simplejson import textwrap import urllib from epydoc.markup import DocstringLinker from epydoc.markup.restructuredtext import ( _DocumentPseudoWriter, _EpydocReader, ParsedRstDocstring, ) from docutils import io from docutils.core import Publisher from zope.component import ( adapts, getGlobalSiteManager, getUtility, queryMultiAdapter) from zope.interface import implements from zope.interface.interfaces import IInterface from zope.schema import getFields from zope.schema.interfaces import ( IBytes, IChoice, IDate, IDatetime, IObject, ) from zope.security.proxy import removeSecurityProxy from zope.publisher.interfaces.browser import IBrowserRequest from zope.traversing.browser import absoluteURL from zope.traversing.interfaces import IPathAdapter from lazr.enum import IEnumeratedType from lazr.restful import ( EntryResource, ResourceJSONEncoder, CollectionResource, EntryAdapterUtility, IObjectLink, RESTUtilityBase) from lazr.restful._resource import UnknownEntryAdapter from lazr.restful.interfaces import ( ICollection, ICollectionField, IEntry, IJSONRequestCache, IReference, IResourceDELETEOperation, IResourceGETOperation, IResourceOperation, IResourcePOSTOperation, IScopedCollection, ITopLevelEntryLink, IWebServiceClientRequest, IWebServiceConfiguration, IWebServiceVersion, LAZR_WEBSERVICE_NAME) from lazr.restful.utils import (get_current_web_service_request, is_total_size_link_active) class WadlDocstringLinker(DocstringLinker): """DocstringLinker used during WADL geneneration. epydoc uses this object to turn index and identifier references like `DocstringLinker` into an appropriate markup in the output format. We don't want to generate links in the WADL file so we basically return the identifier without any special linking markup. """ def translate_identifier_xref(self, identifier, label=None): """See `DocstringLinker`.""" if label: return label return identifier def translate_indexterm(self, indexterm): """See `DocstringLinker`.""" return indexterm class _PydocParser: """Encapsulate the state/objects needed to parse docstrings.""" def __init__(self): # Set up the instance we'll be using to render docstrings. self.errors = [] self.writer = _DocumentPseudoWriter() self.publisher = Publisher(_EpydocReader(self.errors), writer=self.writer, source_class=io.StringInput) self.publisher.set_components('standalone', 'restructuredtext', 'pseudoxml') settings_overrides={ 'report_level':10000, 'halt_level':10000, 'warning_stream':None, } self.publisher.process_programmatic_settings(None, settings_overrides, None) self.publisher.set_destination() def parse_docstring(self, docstring, errors): """Parse a docstring for eventual transformation into HTML This function is a replacement for parse_docstring from epydoc.markup.restructuredtext.parse_docstring. This function reuses the Publisher instance while the original did not. Using This function yields significantly faster WADL generation for complex systems. """ # Clear any errors from previous calls. del self.errors[:] self.publisher.set_source(docstring, None) self.publisher.publish() # Move any errors into the caller-provided list. errors[:] = self.errors[:] return ParsedRstDocstring(self.writer.document) _PYDOC_PARSER = _PydocParser() WADL_DOC_TEMPLATE = ( '\n%s\n') def generate_wadl_doc(doc): """Create a wadl:doc element wrapping a docstring.""" if doc is None: return None # Our docstring convention prevents dedent from working correctly, we need # to dedent all but the first line. lines = doc.strip().splitlines() if not len(lines): return None doc = "%s\n%s" % (lines[0], textwrap.dedent("\n".join(lines[1:]))) errors = [] parsed = _PYDOC_PARSER.parse_docstring(doc, errors) if len(errors) > 0: messages = [str(error) for error in errors] raise AssertionError( "Invalid docstring %s:\n %s" % (doc, "\n ".join(messages))) return WADL_DOC_TEMPLATE % parsed.to_html(WadlDocstringLinker()) class WebServiceRequestAPI: """Namespace for web service functions related to a website request.""" implements(IPathAdapter) adapts(IBrowserRequest) def __init__(self, request): """Initialize with respect to a request.""" self.request = request def cache(self): """Return the request's IJSONRequestCache.""" return IJSONRequestCache(self.request) class WebLayerAPI: """Namespace for web service functions used in the website. These functions are used to prepopulate a client cache with JSON representations of resources. """ def __init__(self, context): self.context = context @property def is_entry(self): """Whether the object is published as an entry.""" request = get_current_web_service_request() return queryMultiAdapter((self.context, request), IEntry) != None @property def json(self): """Return a JSON description of the object.""" request = get_current_web_service_request if queryMultiAdapter((self.context, request), IEntry): resource = EntryResource(self.context, request) else: # Just dump it as JSON+XHTML resource = self.context return simplejson.dumps( resource, cls=ResourceJSONEncoder, media_type=EntryResource.JSON_TYPE) class WadlResourceAPI(RESTUtilityBase): "Namespace for WADL functions that operate on resources." def __init__(self, resource): "Initialize with a resource." self.resource = resource underlying_resource = removeSecurityProxy(resource) self.context = underlying_resource.context @property def url(self): """Return the full URL to the resource.""" return absoluteURL(self.context, get_current_web_service_request()) class WadlEntryResourceAPI(WadlResourceAPI): "Namespace for WADL functions that operate on entry resources." def __init__(self, entry_resource): "Initialize with an entry resource." super(WadlEntryResourceAPI, self).__init__(entry_resource) self.entry = self.resource.entry self.schema = self.entry.schema @property def type_link(self): return self.resource.type_url @property def fields_with_values(self): """Return all of this entry's Field objects.""" fields = [] for name, field in getFieldsInOrder(self.schema): fields.append({'field' : field, 'value': "foo"}) return fields class WadlCollectionResourceAPI(WadlResourceAPI): "Namespace for WADL functions that operate on collection resources." @property def url(self): """The full URL to the resource. Scoped collections don't know their own URLs, so we have to figure it out for them here. """ if IScopedCollection.providedBy(self.context): # Check whether the field has been exported with a different name # and use that if so. webservice_tag = self.context.relationship.queryTaggedValue( 'lazr.webservice.exported') if webservice_tag is not None: relationship_name = webservice_tag['as'] else: relationship_name = self.context.relationship.__name__ return (absoluteURL(self.context.context, get_current_web_service_request()) + '/' + urllib.quote(relationship_name)) else: return super(WadlCollectionResourceAPI, self).url @property def type_link(self): "The URL to the resource type for the object." return self.resource.type_url class WadlByteStorageResourceAPI(WadlResourceAPI): """Namespace for functions that operate on byte storage resources.""" def type_link(self): "The URL to the resource type for the object." return "%s#HostedFile" % self._service_root_url() class WadlServiceRootResourceAPI(RESTUtilityBase): """Namespace for functions that operate on the service root resource. This class doesn't subclass WadlResourceAPI because that class assumes there's an underlying 'context' object that's being published. The service root resource is unique in not having a 'context'. Methods like url() need to be implemented specially with that in mind. """ def __init__(self, resource): """Initialize the helper class with a resource.""" self.resource = resource @property def url(self): """Return the full URL to the resource.""" return self._service_root_url() @property def description(self): return getUtility(IWebServiceConfiguration).service_description @property def service_version(self): return self.resource.request.version @property def version_description(self): config = getUtility(IWebServiceConfiguration) return config.version_descriptions.get(self.service_version, None) @property def is_total_size_link_active(self): config = getUtility(IWebServiceConfiguration) return is_total_size_link_active(self.resource.request.version, config) @property def top_level_resources(self): """Return a list of dicts describing the top-level resources.""" resource_dicts = [] top_level = self.resource.getTopLevelPublications() for link_name, publication in top_level.items(): if ITopLevelEntryLink.providedBy(publication): # It's a link to an entry resource. resource = publication else: # It's a collection resource. resource = CollectionResource( publication, self.resource.request) resource_dicts.append({'name' : link_name, 'path' : "$['%s']" % link_name, 'resource' : resource}) return sorted(resource_dicts, key=operator.itemgetter('name')) class WadlResourceAdapterAPI(RESTUtilityBase): """Namespace for functions that operate on resource adapter classes.""" def __init__(self, adapter, adapter_interface): "Initialize with an adapter class." self.adapter = adapter self.adapter_interface = adapter_interface @property def doc(self): """Human-readable XHTML documentation for this object type.""" return generate_wadl_doc(self.adapter.__doc__) @property def _model_class(self): """Return the underlying data model class for this resource.""" registrations = [ reg for reg in getGlobalSiteManager().registeredAdapters() if (IInterface.providedBy(reg.provided) and reg.provided.isOrExtends(self.adapter_interface) and reg.factory == self.adapter)] # If there's more than one model class (because the 'adapter' was # registered to adapt more than one model class to ICollection or # IEntry), we don't know which model class to search for named # operations. Treat this as an error. if len(registrations) != 1: raise AssertionError( "There must be one (and only one) adapter from %s to %s." % ( self.adapter.__name__, self.adapter_interface.__name__)) return registrations[0].required[0] @property def named_operations(self): """Return all named operations registered on the resource. :return: a dict containing 'name' and 'op' keys. 'name' is the name of the operation and 'op' is the ResourceOperation object. """ # Our 'adapter' is the resource adapter class, generated with # reference to some underlying model class. Named operations # are registered in ZCML under the model class. To find them, # we need to locate the model class that our 'adapter' is # adapting. model_class = self._model_class operations = [] request_interface = getUtility( IWebServiceVersion, get_current_web_service_request().version) for interface in (IResourceGETOperation, IResourcePOSTOperation): operations.extend(getGlobalSiteManager().adapters.lookupAll( (model_class, request_interface), interface)) # An operation that was present in an earlier version but was # removed in the current version will show up in this list as # a stub function that returns None. Since we don't want that # operation to show up in this version, we'll filter it out. return [{'name' : name, 'op' : op} for name, op in operations if IResourceOperation.implementedBy(op)] class WadlEntryInterfaceAdapterAPI(WadlResourceAdapterAPI): """Namespace for WADL functions that operate on entry interfaces. That is, IEntry subclasses. """ def __init__(self, entry_interface): super(WadlEntryInterfaceAdapterAPI, self).__init__( entry_interface, IEntry) self.utility = EntryAdapterUtility.forEntryInterface( entry_interface, get_current_web_service_request()) @property def entry_page_representation_link(self): "The URL to the description of a collection of this kind of object." return self.utility.entry_page_representation_link class WadlEntryAdapterAPI(WadlResourceAdapterAPI): """Namespace for WADL functions that operate on entry adapter classes. The entry adapter class is used to describe entries of a certain type, and scoped collections full of entries of that type. """ def __init__(self, adapter): super(WadlEntryAdapterAPI, self).__init__(adapter, IEntry) self.utility = EntryAdapterUtility(adapter) @property def singular_type(self): """Return the singular name for this object type.""" return self.utility.singular_type @property def type_link(self): """The URL to the type definition for this kind of resource.""" return self.utility.type_link @property def full_representation_link(self): """The URL to the description of the object's full representation.""" return self.utility.full_representation_link @property def patch_representation_link(self): """The URL to the description of the object's patch representation.""" return "%s#%s-diff" % ( self._service_root_url(), self.singular_type) @property def entry_page_type(self): """The definition of a collection of this kind of object.""" return self.utility.entry_page_type @property def entry_page_type_link(self): "The URL to the definition of a collection of this kind of object." return self.utility.entry_page_type_link @property def entry_page_representation_id(self): "The name of the description of a colleciton of this kind of object." return self.utility.entry_page_representation_id @property def publish_web_link(self): return self.utility.publish_web_link @property def all_fields(self): "Return all schema fields for the object." return [field for name, field in sorted(getFields(self.adapter.schema).items())] @property def all_writable_fields(self): """Return all writable schema fields for the object. Read-only fields and collections are excluded. """ return [field for field in self.all_fields if not (ICollectionField.providedBy(field) or field.readonly)] @property def supports_delete(self): """Return true if this entry responds to DELETE.""" request_interface = getUtility( IWebServiceVersion, get_current_web_service_request().version) operations = getGlobalSiteManager().adapters.lookupAll( (self._model_class, request_interface), IResourceDELETEOperation) return len(operations) > 0 class WadlCollectionAdapterAPI(WadlResourceAdapterAPI): "Namespace for WADL functions that operate on collection adapters." def __init__(self, adapter): super(WadlCollectionAdapterAPI, self).__init__(adapter, ICollection) @property def collection_type(self): """The name of this kind of resource.""" tag = self.entry_schema.queryTaggedValue(LAZR_WEBSERVICE_NAME) return tag['plural'] @property def type_link(self): "The URL to the resource type for the object." return "%s#%s" % (self._service_root_url(), self.collection_type) @property def entry_schema(self): """The schema interface for the kind of entry in this collection.""" return self.adapter.entry_schema class WadlFieldAPI(RESTUtilityBase): "Namespace for WADL functions that operate on schema fields." def __init__(self, field): """Initialize with a field.""" self.field = field @property def required(self): """An xsd:bool value for whether or not this field is required.""" if self.field.required: return 'true' else: return 'false' @property def name(self): """The name of this field.""" # It would be nice to farm this out to IFieldMarshaller, but # IFieldMarshaller can't be instantiated except on a field # that's been bound to an object. Here there's no object since # we're doing introspection on the class. A possible solution is # to split IFieldMarshaller.representation_name() into a # separate interface. name = self.field.__name__ if ICollectionField.providedBy(self.field): return name + '_collection_link' elif (IReference.providedBy(self.field) or IBytes.providedBy(self.field)): return name + '_link' else: return name @property def doc(self): """The docstring for this field.""" return generate_wadl_doc(self.field.__doc__) @property def path(self): """The JSONPath path to this field within a JSON document.""" return "$['%s']" % self.name @property def type(self): """The XSD type of this field.""" if IDatetime.providedBy(self.field): return 'xsd:dateTime' elif IDate.providedBy(self.field): return 'xsd:date' elif IBytes.providedBy(self.field): return 'binary' else: return None @property def is_link(self): """Does this field have real data or is it just a link?""" return IObjectLink.providedBy(self.field) @property def is_represented_as_link(self): """Is this field represented as a link to another resource?""" return (IReference.providedBy(self.field) or ICollectionField.providedBy(self.field) or IBytes.providedBy(self.field) or self.is_link) @property def type_link(self): """The URL of the description of the type this field is a link to.""" # Handle externally-hosted binary documents. if IBytes.providedBy(self.field): return "%s#HostedFile" % self._service_root_url() # Handle entries and collections of entries. utility = self._entry_adapter_utility if ICollectionField.providedBy(self.field): return utility.entry_page_type_link else: return utility.type_link @property def representation_link(self): """The URL of the description of the representation of this field.""" utility = self._entry_adapter_utility if ICollectionField.providedBy(self.field): return utility.entry_page_representation_link else: return utility.full_representation_link @property def _entry_adapter_utility(self): """Find an entry adapter for this field.""" if ICollectionField.providedBy(self.field): schema = self.field.value_type.schema elif (IReference.providedBy(self.field) or IObjectLink.providedBy(self.field)): schema = self.field.schema else: raise TypeError("Field is not of a supported type.") assert schema is not IObject, ( "Null schema provided for %s" % self.field.__name__) try: return EntryAdapterUtility.forSchemaInterface( schema, get_current_web_service_request()) except UnknownEntryAdapter, e: e.whence = ( 'Encountered as a result of the entry interface %r, field %r.' % (self.field.interface, self.field.getName())) raise e @property def options(self): """An enumeration of acceptable values for this field. :return: An iterable of Items if the field implements IChoice and its vocabulary implements IEnumeratedType. Otherwise, None. """ if (IChoice.providedBy(self.field) and IEnumeratedType.providedBy(self.field.vocabulary)): return self.field.vocabulary.items return None class WadlTopLevelEntryLinkAPI(RESTUtilityBase): """Namespace for WADL functions that operate on top-level entry links.""" def __init__(self, entry_link): self.entry_link = entry_link def type_link(self): return EntryAdapterUtility.forSchemaInterface( self.entry_link.entry_type, get_current_web_service_request()).type_link class WadlOperationAPI(RESTUtilityBase): "Namespace for WADL functions that operate on named operations." def __init__(self, operation): """Initialize with an operation.""" self.operation = operation @property def http_method(self): """The HTTP method used to invoke this operation.""" if IResourceGETOperation.implementedBy(self.operation): return "GET" elif IResourcePOSTOperation.implementedBy(self.operation): return "POST" else: raise AssertionError("Named operations must use GET or POST.") @property def media_type(self): """The preferred media type to send to this operation. An operation that includes binary fields has a media type of multipart/form-data. All other operations have a media type of application/x-www-form-urlencoded. """ for param in self.operation.params: if WadlFieldAPI(param).type == 'binary': return 'multipart/form-data' return 'application/x-www-form-urlencoded' @property def is_get(self): """Whether or not the operation is a GET operation.""" return self.http_method == "GET" @property def doc(self): """Human-readable documentation for this operation.""" return generate_wadl_doc(self.operation.__doc__) @property def has_return_type(self): """Does this operation declare a return type?""" return_field = getattr(self.operation, 'return_type', None) return return_field is not None @property def returns_link(self): """Does this operation return a link to an object?""" return_field = getattr(self.operation, 'return_type', None) if return_field is not None: field_adapter = WadlFieldAPI(return_field) return field_adapter.is_link return False @property def return_type_resource_type_link(self): """Link to the description of this operation's return value.""" return_field = getattr(self.operation, 'return_type', None) if return_field is not None: field_adapter = WadlFieldAPI(return_field) try: return field_adapter.type_link except TypeError: # The operation does not return any object exposed # through the web service. pass return None @property def return_type_representation_link(self): """Link to the representation of this operation's return value.""" return_field = getattr(self.operation, 'return_type', None) if return_field is not None: field_adapter = WadlFieldAPI(return_field) try: return field_adapter.representation_link except TypeError: # The operation does not return any object exposed # through the web service. pass return None lazr.restful-0.19.3/src/lazr/restful/templates/0000755000175000017500000000000011636155340021664 5ustar benjibenji00000000000000lazr.restful-0.19.3/src/lazr/restful/templates/html-resource.pt0000644000175000017500000000056011631755356025033 0ustar benjibenji00000000000000
The name of the field.
The value of the field.
lazr.restful-0.19.3/src/lazr/restful/templates/wadl-root.pt0000644000175000017500000003561411631755356024162 0ustar benjibenji00000000000000 ]> Version-independent description of the web service. Description of this version of the web service. The root of the web service. The link to the WADL description of this resource. Human-readable description of the resource type, in XHTML format. Human-readable documentation for the method. The name of the operation being invoked. Field description Human-readable description of the resource type, in XHTML format. The method docstring. Field title: field description The canonical link to this resource. The canonical human-addressable web link to this resource. The link to the WADL description of this resource. The value of the HTTP ETag for this resource. Field title: field description Field title: field description If we are not using total_size_link, continue to signal that total_size is required as it has been in the past. lazr.restful-0.19.3/src/lazr/restful/templates/wadl-resource.pt0000644000175000017500000000102111631755356025007 0ustar benjibenji00000000000000 lazr.restful-0.19.3/src/lazr/restful/_operation.py0000644000175000017500000002124711631755356022415 0ustar benjibenji00000000000000# Copyright 2008 Canonical Ltd. All rights reserved. """Base classes for one-off HTTP operations.""" import simplejson from zope.component import getMultiAdapter, getUtility, queryMultiAdapter from zope.event import notify from zope.interface import Attribute, implements, providedBy from zope.interface.interfaces import IInterface from zope.schema import Field from zope.schema.interfaces import ( IField, RequiredMissing, ValidationError, WrongType) from zope.security.proxy import isinstance as zope_isinstance from lazr.lifecycle.event import ObjectModifiedEvent from lazr.lifecycle.snapshot import Snapshot from lazr.restful.fields import CollectionField from lazr.restful.interfaces import ( ICollection, IFieldMarshaller, IResourceDELETEOperation, IResourceGETOperation, IResourcePOSTOperation, IWebServiceConfiguration) from lazr.restful.interfaces import ICollectionField, IReference from lazr.restful.utils import is_total_size_link_active from lazr.restful._resource import ( BatchingResourceMixin, CollectionResource, ResourceJSONEncoder) __metaclass__ = type __all__ = [ 'IObjectLink', 'ObjectLink', 'ResourceOperation', 'ResourceGETOperation', 'ResourceDELETEOperation', 'ResourcePOSTOperation' ] class ResourceOperation(BatchingResourceMixin): """A one-off operation associated with a resource.""" JSON_TYPE = 'application/json' send_modification_event = False def __init__(self, context, request): self.context = context self.request = request self.total_size_only = False def total_size_link(self, navigator): """Return a link to the total size of a collection.""" # If the version we're being asked for is equal to or later # than the version in which we started exposing # total_size_link, then include it; otherwise include # total_size. config = getUtility(IWebServiceConfiguration) if not is_total_size_link_active(self.request.version, config): # This is a named operation that includes the total size # inline rather than with a link. return None if not IResourceGETOperation.providedBy(self): # Only GET operations can have their total size split out into # a link, because only GET operations are safe. return None base = str(self.request.URL) query = navigator.getCleanQueryString() if query != '': query += '&' return base + '?' + query + "ws.show=total_size" def __call__(self): values, errors = self.validate() if len(errors) > 0: self.request.response.setStatus(400) self.request.response.setHeader('Content-type', 'text/plain') return "\n".join(errors) if self.send_modification_event: snapshot = Snapshot( self.context, providing=providedBy(self.context)) response = self.call(**values) if self.send_modification_event: event = ObjectModifiedEvent( object=self.context, object_before_modification=snapshot, edited_fields=None) notify(event) return self.encodeResult(response) def encodeResult(self, result): """Encode the result of a custom operation into a string. This method is responsible for turning the return value of a custom operation into a string that can be served . It's also responsible for setting the Content-Type header and the status code. """ if (self.request.response.getHeader('Content-Type') is not None or self.request.response.getStatus() != 599): # The operation took care of everything and just needs # this object served to the client. return result # The similar patterns in the two branches below suggest some deeper # symmetry that should be extracted. if queryMultiAdapter((result, self.request), ICollection): # If the result is a web service collection, serve only one # batch of the collection. collection = getMultiAdapter((result, self.request), ICollection) resource = CollectionResource(collection, self.request) if self.total_size_only: result = resource.get_total_size(collection) else: result = resource.batch() + '}' elif self.should_batch(result): if self.total_size_only: result = self.get_total_size(result) else: result = self.batch(result, self.request) + '}' else: # Serialize the result to JSON. Any embedded entries will be # automatically serialized. try: result = simplejson.dumps(result, cls=ResourceJSONEncoder) except TypeError, e: raise TypeError("Could not serialize object %s to JSON." % result) self.request.response.setStatus(200) self.request.response.setHeader('Content-Type', self.JSON_TYPE) return result def should_batch(self, result): """Whether the given response data should be batched.""" if not IResourceGETOperation.providedBy(self): # Only GET operations have meaningful return values. return False if ICollectionField.providedBy(self.return_type): # An operation defined as returning a collection always # has its response batched. return True if zope_isinstance(result, (basestring, dict, set, list, tuple)): # Ordinary Python data structures generally are not # batched. return False if IReference.providedBy(self.return_type): # Single references can't be iterable. return False try: iterator = iter(result) # Objects that have iterators but aren't ordinary data structures # tend to be result-set objects. Batch them. return True except TypeError: pass # Any other objects (eg. Entries) are not batched. return False def validate(self): """Validate incoming arguments against the operation schema. :return: A tuple (values, errors). 'values' is a dictionary of validated, preprocessed values to be used as parameters when invoking the operation. 'errors' is a list of validation errors. """ validated_values = {} errors = [] # Take incoming string key-value pairs from the HTTP request. # Transform them into objects that will pass field validation, # and that will be useful when the operation is invoked. missing = object() for field in self.params: name = field.__name__ field = field.bind(self.context) if (self.request.get(name, missing) is missing and not field.required): value = field.default else: marshaller = getMultiAdapter( (field, self.request), IFieldMarshaller) try: value = marshaller.marshall_from_request( self.request.form.get(name)) except ValueError, e: errors.append(u"%s: %s" % (name, e)) continue try: field.validate(value) except RequiredMissing: errors.append(u"%s: Required input is missing." % name) except ValidationError, e: errors.append(u"%s: %s" % (name, e)) else: validated_values[name] = value return (validated_values, errors) def call(self, **kwargs): """Actually invoke the operation.""" raise NotImplementedError class ResourceGETOperation(ResourceOperation): """See `IResourceGETOperation`.""" implements(IResourceGETOperation) class ResourceDELETEOperation(ResourceOperation): """See `IResourceDELETEOperation`.""" implements(IResourceDELETEOperation) class ResourcePOSTOperation(ResourceOperation): """See `IResourcePOSTOperation`.""" implements(IResourcePOSTOperation) class IObjectLink(IField): """Field containing a link to an object.""" schema = Attribute("schema", u"The Interface of the Object on the other end of the link.") class ObjectLink(Field): """A reference to an object.""" implements(IObjectLink) def __init__(self, schema, **kw): if not IInterface.providedBy(schema): raise WrongType self.schema = schema super(ObjectLink, self).__init__(**kw) lazr.restful-0.19.3/src/lazr/restful/wsgi.py0000644000175000017500000000514111631755356021222 0ustar benjibenji00000000000000"""A WSGI application for a lazr.restful web service.""" __metaclass__ = type __all__ = [ 'BaseWSGIWebServiceConfiguration', 'WSGIApplication', ] from pkg_resources import resource_string from wsgiref.simple_server import make_server as wsgi_make_server from zope.component import getUtility from zope.configuration import xmlconfig from zope.publisher.publish import publish from lazr.restful.interfaces import ( IWebServiceConfiguration, IServiceRootResource) from lazr.restful.simple import ( BaseWebServiceConfiguration, Publication, Request) class WSGIApplication: request_class = Request publication_class = Publication def __init__(self, environ, start_response): self.environ = environ self.start_response = start_response def __iter__(self): environ = self.environ # Create the request based on the HTTP method used. method = environ.get('REQUEST_METHOD', 'GET').upper() service_root = getUtility(IServiceRootResource) request = self.request_class(environ['wsgi.input'], environ) request.setPublication(self.publication_class(service_root)) # Support post-mortem debugging. handle_errors = environ.get('wsgi.handleErrors', True) # The request returned by the publisher may in fact be different than # the one passed in. request = publish(request, handle_errors=handle_errors) # Start the WSGI server response. response = request.response self.start_response(response.getStatusString(), response.getHeaders()) # Return the result body iterable. return iter(response.consumeBodyIter()) @classmethod def configure_server(cls, host, port, config_package, config_file="site.zcml"): """Configure lazr.restful for a particular web service.""" zcml = resource_string(config_package, config_file) xmlconfig.string(zcml) config = getUtility(IWebServiceConfiguration) config.hostname = host config.port = port @classmethod def make_server(cls, host, port, config_package, config_file="site.zcml"): """Create a WSGI server object for a particular web service.""" cls.configure_server(host, port, config_package, config_file) return wsgi_make_server(host, int(port), cls) class BaseWSGIWebServiceConfiguration(BaseWebServiceConfiguration): """A basic web service configuration optimized for WSGI apps. There is no difference between this and the default WebServiceConfiguration. It's only maintained for backwards compatibility. """ lazr.restful-0.19.3/src/lazr/restful/docs/0000755000175000017500000000000011636155340020616 5ustar benjibenji00000000000000lazr.restful-0.19.3/src/lazr/restful/docs/webservice-error.txt0000644000175000017500000001325311631755356024660 0ustar benjibenji00000000000000Exceptions on the web service ***************************** Exceptions on the LAZR web service are handled like other exceptions occurring during zope publication: a view is looked-up and used to return the response to the client. ===== Setup ===== >>> from zope.component import getSiteManager, getUtility >>> from zope.interface import implements >>> from lazr.restful.interfaces import IWebServiceConfiguration >>> sm = getSiteManager() >>> class SimpleWebServiceConfiguration: ... implements(IWebServiceConfiguration) ... show_tracebacks = False ... active_versions = ['trunk'] ... last_version_with_mutator_named_operations = None >>> webservice_configuration = SimpleWebServiceConfiguration() >>> sm.registerUtility(webservice_configuration) >>> from lazr.restful.interfaces import IWebServiceVersion >>> class ITestServiceRequestTrunk(IWebServiceVersion): ... pass >>> sm.registerUtility( ... ITestServiceRequestTrunk, IWebServiceVersion, name='trunk') ======================= WebServiceExceptionView ======================= WebServiceExcpetionView is a generic view class that can handle exceptions during web service requests. >>> from lazr.restful.error import WebServiceExceptionView >>> def render_error_view(error, request): ... """Create a WebServiceExceptionView to render the exception. ... ... The exception is raised, because exception view can be expected ... to be called from within an exception handler. ... """ ... try: ... raise error ... except Exception, error: ... return WebServiceExceptionView(error, request)() That view returns the exception message as content, and sets the result code to the one specified using the webservice_error() directive. >>> from lazr.restful.declarations import webservice_error >>> class InvalidInput(Exception): ... """Client provided invalid input.""" ... webservice_error(400) Depending on the show_tracebacks setting, it may also print a traceback of the exception. Tracebacks are only shown for errors that have a 5xx http error code. >>> from textwrap import dedent >>> from lazr.restful.testing.webservice import FakeRequest >>> webservice_configuration.show_tracebacks False When tracebacks are not shown, the view simply returns the exception message and sets the status code to the one related to the exception. >>> request = FakeRequest() >>> render_error_view( ... InvalidInput("foo@bar isn't a valid email address"), request) "foo@bar isn't a valid email address" >>> request.response.headers['Content-Type'] 'text/plain' >>> request.response.status 400 When the request contains an OOPSID, it will be set in the X-Lazr-OopsId header: >>> print request.response.headers.get('X-Lazr-OopsId') None >>> request = FakeRequest() >>> request.oopsid = 'OOPS-001' >>> ignored = render_error_view(InvalidInput('bad email'), request) >>> print request.response.headers['X-Lazr-OopsId'] OOPS-001 Even if show_tracebacks is set to true, non-5xx error codes will not produce a traceback. >>> webservice_configuration.show_tracebacks = True >>> print render_error_view(InvalidInput('bad email'), request) bad email Internal server errors ====================== Exceptions that are server-side errors are handled a little differently. >>> class ServerError(Exception): ... """Something went wrong on the server side.""" ... webservice_error(500) If show_tracebacks is True, the user is going to see a full traceback anyway, so there's no point in hiding the exception message. When tracebacks are shown, the view puts a traceback dump in the response. >>> print render_error_view(ServerError('DB crash'), request) DB crash Traceback (most recent call last): ... ServerError: DB crash If show_tracebacks is False, on an internal server error they client will see the exception class name instead of a message. >>> webservice_configuration.show_tracebacks = False >>> print render_error_view(ServerError('DB crash'), request) ServerError ================== Default exceptions ================== Standard exceptions have a view registered for them by default. >>> from zope.configuration import xmlconfig >>> zcmlcontext = xmlconfig.string(""" ... ... ... ... """) >>> from zope.component import getMultiAdapter >>> def render_using_default_view(error): ... """Render an exception using its default 'index.html' view. ... :return: response, result tuple. (The response object and ... the content). ... """ ... try: ... raise error ... except Exception, error: ... request = FakeRequest() ... view = getMultiAdapter((error, request), name="index.html") ... result = view() ... return request.response, result NotFound exceptions have a 404 status code. >>> from zope.publisher.interfaces import NotFound >>> response, result = render_using_default_view( ... NotFound(object(), 'name')) >>> response.status 404 Unauthorized exceptions have a 401 status code. >>> from zope.security.interfaces import Unauthorized >>> response, result = render_using_default_view(Unauthorized()) >>> response.status 401 Other exceptions have the 500 status code. >>> response, result = render_using_default_view(Exception()) >>> response.status 500 lazr.restful-0.19.3/src/lazr/restful/docs/webservice-marshallers.txt0000644000175000017500000006344411631755356026053 0ustar benjibenji00000000000000LAZR's field marshallers ************************ LAZR defines an interface for converting between the values that come in on an HTTP request, and the object values appropriate for schema fields. This is similar to Zope's widget interface, but much smaller. To test the various marshallers we create a dummy request and application root. >>> from lazr.restful.testing.webservice import WebServiceTestPublication >>> from lazr.restful.simple import Request >>> from lazr.restful.example.base.root import ( ... CookbookServiceRootResource) >>> request = Request("", {'HTTP_HOST': 'cookbooks.dev'}) >>> request.annotations[request.VERSION_ANNOTATION] = '1.0' >>> application = CookbookServiceRootResource() >>> request.setPublication(WebServiceTestPublication(application)) >>> request.processInputs() IFieldMarshaller and SimpleFieldMarshaller ========================================== There is a SimpleFieldMarshaller class that provides a good base to implement that interface. >>> from zope.interface.verify import verifyObject >>> from lazr.restful.interfaces import IFieldMarshaller >>> from lazr.restful.marshallers import SimpleFieldMarshaller >>> from zope.schema import Text >>> field = Text(__name__='field_name') >>> marshaller = SimpleFieldMarshaller(field, request) >>> verifyObject(IFieldMarshaller, marshaller) True representation_name =================== The representation_name attribute is used to retrieve the name under which the field should be stored in the JSON representation. In the simple case, it's the same name as the field. >>> marshaller.representation_name 'field_name' marshall_from_json_data() ========================= The marshall_from_json_data() method is used during PUT and PATCH requests to transform the value provided in the JSON representation to a value in the underlying schema field. In SimpleFieldMarshaller implementation, the value is returned unchanged. >>> marshaller.marshall_from_json_data("foo") 'foo' >>> marshaller.marshall_from_json_data(4) 4 >>> marshaller.marshall_from_json_data(u"unicode\u2122") u'unicode\u2122' >>> marshaller.marshall_from_json_data("") '' >>> print marshaller.marshall_from_json_data(None) None marshall_from_request() ======================= The marshall_from_request() method is used during operation invocation to transform a value submitted via the query string or form-encoded POST data into a value the will be accepted by the underlying schema field. SimpleFieldMarshaller tries first to parse the value as a JSON-encoded string, the resulting value is passed on to marshall_from_json_data(). >>> print marshaller.marshall_from_request("null") None >>> marshaller.marshall_from_request("true") True >>> marshaller.marshall_from_request("false") False >>> marshaller.marshall_from_request('["True", "False"]') [u'True', u'False'] >>> marshaller.marshall_from_request("1") 1 >>> marshaller.marshall_from_request("-10.5") -10.5 >>> marshaller.marshall_from_request('"a string"') u'a string' >>> marshaller.marshall_from_request('"false"') u'false' >>> marshaller.marshall_from_request('"null"') u'null' Invalid JSON-encoded strings are interpreted as string literals and passed on directly to marshall_from_json_data(). That's for the convenience of web clients, they don't need to encode string values in quotes, or can pass lists using multiple key-value pairs. >>> marshaller.marshall_from_request(u"a string") u'a string' >>> marshaller.marshall_from_request('False') 'False' >>> marshaller.marshall_from_request("") '' >>> marshaller.marshall_from_request(['value1', 'value2']) ['value1', 'value2'] unmarshall() and variants ========================= The unmarshall() method is used to convert the field's value to a value that can be serialized to JSON as part of an entry representation. The first parameter is the entry that the value is part of. That is used by fields that transform the value into a URL, see the CollectionField marshaller for an example. The second one is the value to convert. In the SimpleFieldMarshaller implementation, the value is returned unchanged. >>> print marshaller.unmarshall(None, 'foo') foo >>> print marshaller.unmarshall(None, None) None When a more detailed representation is needed, unmarshall_to_closeup() can be called. By default, this returns the same data as unmarshall(), but specific marshallers may send more detailed information. >>> marshaller.unmarshall_to_closeup(None, 'foo') 'foo' Marshallers for basic data types ================================ Bool ---- The marshaller for a Bool field checks that the JSON value is either True or False. A ValueError is raised when its not the case. >>> from zope.configuration import xmlconfig >>> zcmlcontext = xmlconfig.string(""" ... ... ... ... """) >>> from zope.component import getMultiAdapter >>> from zope.schema import Bool >>> field = Bool() >>> marshaller = getMultiAdapter((field, request), IFieldMarshaller) >>> verifyObject(IFieldMarshaller, marshaller) True >>> marshaller.marshall_from_json_data(True) True >>> marshaller.marshall_from_json_data(False) False >>> marshaller.marshall_from_json_data("true") Traceback (most recent call last): ... ValueError: got 'str', expected bool: 'true' >>> marshaller.marshall_from_json_data(1) Traceback (most recent call last): ... ValueError: got 'int', expected bool: 1 None is passed through though. >>> print marshaller.marshall_from_json_data(None) None Booleans are encoded using the standard JSON representation of 'true' or 'false'. >>> marshaller.marshall_from_request(u"true") True >>> marshaller.marshall_from_request(u"false") False >>> marshaller.marshall_from_request('True') Traceback (most recent call last): ... ValueError: got 'str', expected bool: 'True' Int --- The marshaller for an Int field checks that the JSON value is an integer. A ValueError is raised when its not the case. >>> from zope.schema import Int >>> field = Int() >>> marshaller = getMultiAdapter((field, request), IFieldMarshaller) >>> verifyObject(IFieldMarshaller, marshaller) True >>> marshaller.marshall_from_json_data(-10) -10 >>> marshaller.marshall_from_json_data("-10") Traceback (most recent call last): ... ValueError: got 'str', expected int: '-10' None is passed through though. >>> print marshaller.marshall_from_json_data(None) None Integers are encoded using strings when in a request. >>> marshaller.marshall_from_request("4") 4 >>> marshaller.marshall_from_request(u"-4") -4 It raises a ValueError if the value cannot be converted to an integer. >>> marshaller.marshall_from_request("foo") Traceback (most recent call last): ... ValueError: got 'str', expected int: 'foo' >>> marshaller.marshall_from_request("4.62") Traceback (most recent call last): ... ValueError: got 'float', expected int: 4.62... Note that python octal and hexadecimal syntax isn't supported. (This would 13 in octal notation.) >>> marshaller.marshall_from_request(u"015") Traceback (most recent call last): ... ValueError: got 'unicode', expected int: u'015' >>> marshaller.marshall_from_request(u"0x04") Traceback (most recent call last): ... ValueError: got 'unicode', expected int: u'0x04' Float ----- The marshaller for a Float field checks that the JSON value is indeed a float. A ValueError is raised when it's not the case. >>> from zope.schema import Float >>> field = Float() >>> marshaller = getMultiAdapter((field, request), IFieldMarshaller) >>> verifyObject(IFieldMarshaller, marshaller) True >>> marshaller.marshall_from_json_data(1.0) 1.0 >>> marshaller.marshall_from_json_data(-1.0) -1.0 >>> marshaller.marshall_from_json_data("true") Traceback (most recent call last): ... ValueError: got 'str', expected float, int: 'true' None is passed through though. >>> print marshaller.marshall_from_json_data(None) None And integers are automatically converted to a float. >>> marshaller.marshall_from_json_data(1) 1.0 Floats are encoded using the standard JSON representation. >>> marshaller.marshall_from_request(u"1.2") 1.2 >>> marshaller.marshall_from_request(u"-1.2") -1.2 >>> marshaller.marshall_from_request(u"-1") -1.0 >>> marshaller.marshall_from_request('True') Traceback (most recent call last): ... ValueError: got 'str', expected float, int: 'True' Datetime -------- The marshaller for a Datetime field checks that the JSON value is indeed a parsable datetime stamp. >>> from zope.schema import Datetime >>> field = Datetime() >>> marshaller = getMultiAdapter((field, request), IFieldMarshaller) >>> verifyObject(IFieldMarshaller, marshaller) True >>> marshaller.marshall_from_json_data('2009-07-07T13:15:00+0000') datetime.datetime(2009, 7, 7, 13, 15, tzinfo=) >>> marshaller.marshall_from_json_data('2009-07-07T13:30:00-0000') datetime.datetime(2009, 7, 7, 13, 30, tzinfo=) >>> marshaller.marshall_from_json_data('2009-07-07T13:45:00Z') datetime.datetime(2009, 7, 7, 13, 45, tzinfo=) >>> marshaller.marshall_from_json_data('2009-07-08T14:30:00') datetime.datetime(2009, 7, 8, 14, 30, tzinfo=) >>> marshaller.marshall_from_json_data('2009-07-09') datetime.datetime(2009, 7, 9, 0, 0, tzinfo=) The time zone must be UTC. An error is raised if is it clearly not UTC. >>> marshaller.marshall_from_json_data('2009-07-25T13:15:00+0500') Traceback (most recent call last): ... ValueError: Time not in UTC. >>> marshaller.marshall_from_json_data('2009-07-25T13:30:00-0200') Traceback (most recent call last): ... ValueError: Time not in UTC. A ValueError is raised when the value is not parsable. >>> marshaller.marshall_from_json_data("now") Traceback (most recent call last): ... ValueError: Value doesn't look like a date. >>> marshaller.marshall_from_json_data('20090708') Traceback (most recent call last): ... ValueError: Value doesn't look like a date. >>> marshaller.marshall_from_json_data(20090708) Traceback (most recent call last): ... ValueError: Value doesn't look like a date. Date ---- The marshaller for a Date field checks that the JSON value is indeed a parsable date. >>> from zope.schema import Date >>> field = Date() >>> marshaller = getMultiAdapter((field, request), IFieldMarshaller) >>> verifyObject(IFieldMarshaller, marshaller) True >>> marshaller.marshall_from_json_data('2009-07-09') datetime.date(2009, 7, 9) The marshaller extends the Datetime marshaller. It will parse a datetime stamp and return a date. >>> marshaller.marshall_from_json_data('2009-07-07T13:15:00+0000') datetime.date(2009, 7, 7) Text ---- The marshaller for IText field checks that the value is a unicode string. A ValueError is raised when that's not the case. >>> from zope.schema import Text >>> field = Text() >>> marshaller = getMultiAdapter((field, request), IFieldMarshaller) >>> verifyObject(IFieldMarshaller, marshaller) True >>> marshaller.marshall_from_json_data(u"Test") u'Test' >>> marshaller.marshall_from_json_data(1.0) Traceback (most recent call last): ... ValueError: got 'float', expected unicode: 1.0 >>> marshaller.marshall_from_json_data('Test') Traceback (most recent call last): ... ValueError: got 'str', expected unicode: 'Test' None is passed through though. >>> print marshaller.marshall_from_json_data(None) None When coming from the request, everything is interpreted as a unicode string: >>> marshaller.marshall_from_request('a string') u'a string' >>> marshaller.marshall_from_request(['a', 'b']) u"['a', 'b']" >>> marshaller.marshall_from_request('true') u'True' >>> marshaller.marshall_from_request('') u'' Except that 'null' still returns None. >>> print marshaller.marshall_from_request('null') None Bytes ----- Since there is no way to represent a bytes string in JSON, all strings are converted to a byte string using UTF-8 encoding. If the value isn't a string, a ValueError is raised. >>> from zope.schema import Bytes >>> field = Bytes(__name__='data') >>> marshaller = getMultiAdapter((field, request), IFieldMarshaller) >>> verifyObject(IFieldMarshaller, marshaller) True >>> marshaller.marshall_from_json_data(u"Test") 'Test' >>> marshaller.marshall_from_json_data(u'int\xe9ressant') 'int\xc3\xa9ressant' >>> marshaller.marshall_from_json_data(1.0) Traceback (most recent call last): ... ValueError: got 'float', expected str: 1.0 Again, except for None which is passed through. >>> print marshaller.marshall_from_json_data(None) None When coming over the request, the value is also converted into a UTF-8 encoded string. >>> marshaller.marshall_from_request(u"Test") 'Test' >>> marshaller.marshall_from_request(u'int\xe9ressant') 'int\xc3\xa9ressant' >>> marshaller.marshall_from_request('1.0') '1.0' But again, None is returned as is. >>> print marshaller.marshall_from_request('null') None Since multipart/form-data can be used to upload data, file-like objects are read. >>> from cStringIO import StringIO >>> marshaller.marshall_from_request(StringIO('A line of data')) 'A line of data' Bytes field used in an entry are stored in the librarian, so their representation name states that it's a link. >>> marshaller.representation_name 'data_link' And the unmarshall() method returns a link that will serve the file. >>> from lazr.restful import EntryResource >>> from lazr.restful.example.base.interfaces import ICookbookSet >>> from zope.component import getUtility >>> entry_resource = EntryResource( ... getUtility(ICookbookSet).get('Everyday Greens'), request) (The value would be the BytesStorage instance used to store the content, but it's not needed.) >>> marshaller.unmarshall(entry_resource, None) 'http://.../cookbooks/Everyday%20Greens/data' ASCIILine --------- ASCIILine is a subclass of Bytes but is marshalled like text. >>> from zope.schema import ASCIILine >>> field = ASCIILine(__name__='field') >>> marshaller = getMultiAdapter((field, request), IFieldMarshaller) >>> verifyObject(IFieldMarshaller, marshaller) True Unicode objects remain Unicode objects. >>> marshaller.marshall_from_json_data(u"Test") u'Test' Note that the marshaller accepts character values where bit 7 is set. >>> marshaller.marshall_from_json_data(u'int\xe9ressant') u'int\xe9ressant' Non-string values like floats are rejected. >>> marshaller.marshall_from_json_data(1.0) Traceback (most recent call last): ... ValueError: got 'float', expected unicode: 1.0 None is passed through. >>> print marshaller.marshall_from_json_data(None) None When coming from the request, everything is interpreted as a unicode string: >>> marshaller.marshall_from_request('a string') u'a string' >>> marshaller.marshall_from_request(['a', 'b']) u"['a', 'b']" >>> marshaller.marshall_from_request('true') u'True' >>> marshaller.marshall_from_request('') u'' >>> marshaller.marshall_from_request(u'int\xe9ressant') u'int\xe9ressant' >>> marshaller.marshall_from_request('1.0') u'1.0' But again, 'null' is returned as None. >>> print marshaller.marshall_from_request('null') None Unlike a Bytes field, an ASCIILine field used in an entry is stored as an ordinary attribute, hence its representation name is the attribute name itself. >>> marshaller.representation_name 'field' Choice marshallers ================== The marshaller for a Choice is chosen based on the Choice's vocabulary. >>> from zope.schema import Choice Choice for IVocabularyTokenized ------------------------------- The default marshaller will use the vocabulary getTermByToken to retrieve the value to use. It raises an error if the value isn't in the vocabulary. >>> field = Choice(__name__='simple', values=[10, 'a value', True]) >>> marshaller = getMultiAdapter((field, request), IFieldMarshaller) >>> verifyObject(IFieldMarshaller, marshaller) True >>> marshaller.marshall_from_json_data(10) 10 >>> marshaller.marshall_from_json_data("a value") 'a value' >>> marshaller.marshall_from_json_data(True) True >>> marshaller.marshall_from_request('true') True >>> marshaller.marshall_from_request('a value') 'a value' >>> marshaller.marshall_from_request('10') 10 >>> marshaller.marshall_from_json_data('100') Traceback (most recent call last): ... ValueError: '100' isn't a valid token None is always returned unchanged. >>> print marshaller.marshall_from_json_data(None) None Since this marshaller's Choice fields deal with small, fixed vocabularies, their unmarshall_to_closeup() implementations to describe the vocabulary as a whole. >>> for token in marshaller.unmarshall_to_closeup(None, '10'): ... print sorted(token.items()) [('title', None), ('token', '10')] [('title', None), ('token', 'a value')] [('title', None), ('token', 'True')] Unicode Exceptions Sidebar -------------------------- Because tracebacks with high-bit characters in them end up being displayed like "ValueError: " we'll use a helper to display them the way we want. >>> def show_ValueError(callable, *args): ... try: ... callable(*args) ... except ValueError, e: ... print 'ValueError:', e.message Choice of EnumeratedTypes ------------------------- The JSON representation of the enumerated value is its title. A string that corresponds to one of the values is marshalled to the appropriate value. A string that doesn't correspond to any enumerated value results in a helpful ValueError. >>> from lazr.restful.example.base.interfaces import Cuisine >>> field = Choice(vocabulary=Cuisine) >>> marshaller = getMultiAdapter((field, request), IFieldMarshaller) >>> verifyObject(IFieldMarshaller, marshaller) True >>> marshaller.marshall_from_json_data("Dessert") >>> show_ValueError(marshaller.marshall_from_json_data, "NoSuchCuisine") ValueError: Invalid value "NoSuchCuisine". Acceptable values are: ... >>> show_ValueError(marshaller.marshall_from_json_data, "dessert") ValueError: Invalid value "dessert". Acceptable values are: ... None is returned unchanged: >>> print marshaller.marshall_from_json_data(None) None This marshaller is for a Choice field describing a small, fixed vocabularies. Because the vocabulary is small, its unmarshall_to_closeup() implementation can describe the whole vocabulary. >>> for cuisine in sorted( ... marshaller.unmarshall_to_closeup(None, "Triaged")): ... print sorted(cuisine.items()) [('title', 'American'), ('token', 'AMERICAN')] ... [('title', 'Vegetarian'), ('token', 'VEGETARIAN')] Objects ------- An object is marshalled to its URL. >>> from lazr.restful.fields import Reference >>> from lazr.restful.example.base.interfaces import ICookbook >>> reference_field = Reference(schema=ICookbook) >>> reference_marshaller = getMultiAdapter( ... (reference_field, request), IFieldMarshaller) >>> verifyObject(IFieldMarshaller, reference_marshaller) True >>> from lazr.restful.example.base.root import COOKBOOKS >>> cookbook = COOKBOOKS[0] >>> cookbook_url = reference_marshaller.unmarshall(None, cookbook) >>> print cookbook_url http://.../cookbooks/Mastering%20the%20Art%20of%20French%20Cooking A URL is unmarshalled to the underlying object. >>> cookbook = reference_marshaller.marshall_from_json_data(cookbook_url) >>> cookbook.name u'Mastering the Art of French Cooking' >>> reference_marshaller.marshall_from_json_data("not a url") Traceback (most recent call last): ... ValueError: "not a url" is not a valid URI. >>> reference_marshaller.marshall_from_json_data(4) Traceback (most recent call last): ... ValueError: got 'int', expected string: 4 >>> print reference_marshaller.marshall_from_json_data(None) None Relative URLs ~~~~~~~~~~~~~ Relative URLs are interpreted as would be expected: >>> cookbook = reference_marshaller.marshall_from_json_data( ... '/cookbooks/Everyday%20Greens') >>> print cookbook.name Everyday Greens Collections ----------- The most complicated kind of marshaller is one that manages a collection of objects associated with some other object. The generic collection marshaller will take care of marshalling to the proper collection type, and of marshalling the individual items using the marshaller for its value_type. >>> from zope.schema import List, Tuple, Set >>> list_of_strings_field = List(value_type=Text()) >>> from lazr.restful.example.base.interfaces import Cuisine >>> tuple_of_ints_field = Tuple(value_type=Int()) >>> list_of_choices_field = List( ... value_type=Choice(vocabulary=Cuisine)) >>> set_of_choices_field = Set( ... value_type=Choice(vocabulary=Cuisine)).bind(None) >>> list_marshaller = getMultiAdapter( ... (list_of_strings_field, request), IFieldMarshaller) >>> verifyObject(IFieldMarshaller, list_marshaller) True >>> tuple_marshaller = getMultiAdapter( ... (tuple_of_ints_field, request), IFieldMarshaller) >>> verifyObject(IFieldMarshaller, tuple_marshaller) True >>> choice_list_marshaller = getMultiAdapter( ... (list_of_choices_field, request), IFieldMarshaller) >>> verifyObject(IFieldMarshaller, choice_list_marshaller) True >>> set_marshaller = getMultiAdapter( ... (set_of_choices_field, request), IFieldMarshaller) >>> verifyObject(IFieldMarshaller, set_marshaller) True The only JSON representation for the collection itself is a list, since that's the only sequence type available in JSON. Anything else will raise a ValueError. >>> list_marshaller.marshall_from_json_data([u"Test"]) [u'Test'] >>> list_marshaller.marshall_from_json_data(u"Test") Traceback (most recent call last): ... ValueError: got 'unicode', expected list: u'Test' None is passed through though. >>> print list_marshaller.marshall_from_json_data(None) None ValueError is also raised if one of the value in the list doesn't validate against the more specific marshaller. >>> list_marshaller.marshall_from_json_data([u'Text', 1, 2]) Traceback (most recent call last): ... ValueError: got 'int', expected unicode: 1 >>> show_ValueError(choice_list_marshaller.marshall_from_request, ... [u'Vegetarian', u'NoSuchChoice']) ValueError: Invalid value "NoSuchChoice"... The return type is correctly typed to the concrete collection. >>> tuple_marshaller.marshall_from_json_data([1, 2, 3]) (1, 2, 3) >>> marshalled_set = set_marshaller.marshall_from_json_data( ... ['Vegetarian', 'Dessert']) >>> marshalled_set set([, ]) >>> result = choice_list_marshaller.marshall_from_request( ... [u'Vegetarian', u'General']) >>> type(result) >>> [item.title for item in result] ['Vegetarian', 'General'] When coming from the request, either a list or a JSON-encoded representation is accepted. The normal request rules for the underlying type are then followed. >>> list_marshaller.marshall_from_request([u'1', u'2']) [u'1', u'2'] >>> list_marshaller.marshall_from_request('["1", "2"]') [u'1', u'2'] >>> tuple_marshaller.marshall_from_request([u'1', u'2']) (1, 2) Except that 'null' still returns None. >>> print list_marshaller.marshall_from_request('null') None Also, as a convenience for web client, so that they don't have to JSON encode single-element list, non-list value are promoted into a single-element list. >>> tuple_marshaller.marshall_from_request('1') (1,) >>> list_marshaller.marshall_from_request('test') [u'test'] The unmarshall() method will return a list containing the unmarshalled representation of each its members. >>> sorted(set_marshaller.unmarshall(None, marshalled_set)) ['Dessert', 'Vegetarian'] CollectionField --------------- Since CollectionField are really a list of references to other objects, and they are exposed using a dedicated CollectionResource, the marshaller for this kind of field is simpler. Let's do an example with a collection of IRecipe objects associated with some ICookbook. (This might be the list of recipes in the cookbook, or something like that.) >>> from lazr.restful.fields import CollectionField >>> from lazr.restful.example.base.interfaces import IRecipe >>> field = CollectionField( ... __name__='recipes', value_type=Reference(schema=IRecipe)) >>> marshaller = getMultiAdapter((field, request), IFieldMarshaller) >>> verifyObject(IFieldMarshaller, marshaller) True Instead of serving the actual collection, collection marshallers serve a URL to that collection. >>> marshaller.unmarshall(entry_resource, ["recipe 1", "recipe 2"]) 'http://.../cookbooks/Everyday%20Greens/recipes' They also annotate the representation name of the field, so that clients know this is a link to a collection-type resource. >>> marshaller.representation_name 'recipes_collection_link' lazr.restful-0.19.3/src/lazr/restful/docs/webservice-request.txt0000644000175000017500000000744011631755356025220 0ustar benjibenji00000000000000Adapting a browser request into a web service request ***************************************************** Most of the time, web service requests come through the web service layer. But there are times when a request that came through the website neeeds to be treated as a web service request. It's easy to adapt an IBrowserRequest into an IIWebServiceClientRequest request. This adapters uses the request factory from the IWebServiceConfiguration utility. >>> from lazr.restful.interfaces import ( ... IWebServiceConfiguration, IWebServiceClientRequest) >>> from lazr.restful.publisher import ( ... browser_request_to_web_service_request) >>> from lazr.restful.testing.webservice import WebServiceTestPublication >>> from lazr.restful.simple import Request >>> from zope.interface import implements >>> from zope.component import getSiteManager >>> from zope.publisher.browser import TestRequest >>> class SimpleWebServiceConfiguration: ... implements(IWebServiceConfiguration) ... path_override = 'api' ... active_versions = ['beta'] ... ... def createRequest(self, body_stream, environ): ... request = Request(body_stream, environ) ... request.setPublication(WebServiceTestPublication(None)) ... return request >>> from lazr.restful.interfaces import IWebServiceVersion >>> class IBetaVersion(IWebServiceVersion): ... """Marker interface for web service version.""" >>> sm = getSiteManager() >>> sm.registerUtility(SimpleWebServiceConfiguration()) >>> sm.registerUtility(IBetaVersion, IWebServiceVersion, name="beta") >>> sm.registerAdapter(browser_request_to_web_service_request) >>> website_request = TestRequest(SERVER_URL="http://cookbooks.dev/") >>> request = IWebServiceClientRequest(website_request) >>> request <...Request...> >>> request.getApplicationURL() 'http://cookbooks.dev/api/beta' ============== The JSON Cache ============== Objects can be stored in a cache so that they can be included in Javascript code inside the template. This is provided by the IJSONRequestCache. There is a default adapter available that works with any IApplicationRequest. An object can be stored in the cache's 'objects' dict or its 'links' dict. >>> from lazr.restful.interfaces import IJSONRequestCache >>> from lazr.restful.jsoncache import JSONRequestCache >>> sm.registerAdapter(JSONRequestCache) >>> cache = IJSONRequestCache(website_request) The 'objects' dict is for objects that should have their full JSON representations put into the template. >>> cache.objects['object1'] = 'foo' >>> cache.objects['object2'] = 'bar' >>> for key in cache.objects: ... print "%s: %s" % (key, cache.objects[key]) object1: foo object2: bar The 'links' dict is for objects that should have their self_links put into the template, not their whole representations. >>> cache.links['objectA'] = 'foo' >>> cache.links['objectB'] = 'bar' >>> for key in cache.links: ... print "%s: %s" % (key, cache.links[key]) objectA: foo objectB: bar There is a TALES formatter available that can be used to obtain a reference to this cache from TALES: >>> from lazr.restful.tales import WebServiceRequestAPI >>> from lazr.restful.testing.tales import test_tales >>> from zope.interface import Interface >>> from zope.traversing.adapters import DefaultTraversable >>> sm.registerAdapter(DefaultTraversable, [Interface]) >>> sm.registerAdapter(WebServiceRequestAPI, name="webservicerequest") >>> cache = test_tales( ... "request/webservicerequest:cache", request=website_request) >>> for key in cache.links: ... print "%s: %s" % (key, cache.links[key]) objectA: foo objectB: bar lazr.restful-0.19.3/src/lazr/restful/docs/absoluteurl.txt0000644000175000017500000001772311631755356023742 0ustar benjibenji00000000000000URL generation ************** lazr.restful includes some classes that make it easy to generate URLs for your objects. RootResourceAbsoluteURL ======================= It's easy to have an object implement ILocation and define its URL recursively, in terms of its parent's URL. But the recursion has to bottom out somewhere, so you need at least one real implementation of IAbsoluteURL. lazr.simple.RootResourceAbsoluteURL is designed to build a URL for your service root resource out of information you put into your IWebServiceConfiguration implementation. First, RootResourceAbsoluteURL needs to be registered. >>> from zope.component import getSiteManager >>> from lazr.restful.simple import RootResourceAbsoluteURL >>> sm = getSiteManager() >>> sm.registerAdapter(RootResourceAbsoluteURL) Next, you need a configuration object. >>> from zope.interface import implements >>> from lazr.restful.interfaces import IWebServiceConfiguration >>> class SimpleWebServiceConfiguration: ... implements(IWebServiceConfiguration) ... hostname = "hostname" ... service_root_uri_prefix = "root_uri_prefix/" ... active_versions = ['active_version', 'latest_version'] ... last_version_with_mutator_named_operations = None ... port = 1000 ... use_https = True As always, the configuration object needs to be registered as the utility for IWebServiceConfiguration. >>> configuration = SimpleWebServiceConfiguration() >>> sm.registerUtility(configuration) Now we can get a RootResourceAbsoluteURL object, given a root resource and a request. >>> from zope.component import getMultiAdapter >>> from lazr.restful.simple import RootResource, Request >>> from zope.traversing.browser.interfaces import IAbsoluteURL >>> resource = RootResource() >>> request = Request("", {}) >>> request.annotations[request.VERSION_ANNOTATION] = ( ... 'active_version') >>> adapter = getMultiAdapter((resource, request), IAbsoluteURL) Calling the RootResourceAbsoluteURL will give the service root's absolute URL. >>> print adapter() https://hostname:1000/root_uri_prefix/active_version/ Converting the adapter to a string will give the same result, but without the trailing slash. >>> print str(adapter) https://hostname:1000/root_uri_prefix/active_version (This is useful for the recursive case. When finding the URL to a subordinate resource, Zope's AbsoluteURL implementation calls str() on this object and joins that string to the subordinate object's name with a slash. Putting a slash here would result in subordinate objects having two consecutive slashes in their URLs.) Changing the web service configuration changes the generated URLs. >>> configuration.use_https = False >>> print getMultiAdapter((resource, request), IAbsoluteURL)() http://hostname:1000/root_uri_prefix/active_version/ >>> configuration.port = None >>> print getMultiAdapter((resource, request), IAbsoluteURL)() http://hostname/root_uri_prefix/active_version/ The URL generated includes a version identifier taken from the value of the 'lazr.restful.version' annotation. >>> request.annotations[request.VERSION_ANNOTATION] = ( ... 'latest_version') >>> adapter = getMultiAdapter((resource, request), IAbsoluteURL) >>> print adapter() http://hostname/root_uri_prefix/latest_version/ For purposes of URL generation, the annotation doesn't have to be a real version defined by the web service. Any string will do. >>> request.annotations[request.VERSION_ANNOTATION] = ( ... 'no_such_version') >>> adapter = getMultiAdapter((resource, request), IAbsoluteURL) >>> print adapter() http://hostname/root_uri_prefix/no_such_version/ (However, the lazr.restful traversal code will reject an invalid version, and so in a real application lazr.restful.version will not be set. See examples/base/tests/service.txt for that test.) Cleanup. >>> request.annotations[request.VERSION_ANNOTATION] = ( ... 'active_version') MultiplePathPartAbsoluteURL =========================== Zope has an AbsoluteURL class that makes it easy to turn an object tree into a set of treelike URL paths: http://top-level/ http://top-level/child http://top-level/child/grandchild And so on. It assumes that each object provides one part of the URL's path. But what if a single object in the tree provides multiple URL's path parts? http://top-level/child-part1/child-part2/grandchild AbsoluteURL won't work, but lazr.restful's MultiplePathPartAbsoluteURL will. To test this, we'll start with a DummyRootResource and its absolute URL generator, DummyRootResourceURL. >>> from lazr.restful.testing.webservice import ( ... DummyRootResource, DummyRootResourceURL) >>> from zope.component import getSiteManager >>> sm = getSiteManager() >>> sm.registerAdapter(DummyRootResourceURL) We'll load the basic lazr.restful site configuration. >>> from zope.configuration import xmlconfig >>> def load_config(): ... xmlconfig.string(""" ... ... ... ... ... """) >>> load_config() Here's a child of DummyRootResource that implements IMultiplePathPartLocation. >>> from zope.interface import implements >>> from lazr.restful.simple import IMultiplePathPartLocation >>> class ChildResource: ... implements(IMultiplePathPartLocation) ... ... __parent__ = DummyRootResource() ... __path_parts__ = ["child-part1", "child-part2"] The ChildResource's URL includes one URL part from the root resource, followed by two from the ChildResource itself. >>> resource = ChildResource() >>> print str(getMultiAdapter((resource, request), IAbsoluteURL)) http://dummyurl/child-part1/child-part2 Now let's put an object underneath the child resource that implements ILocation, as most resources will. >>> from zope.location.interfaces import ILocation >>> class GrandchildResource: ... implements(ILocation) ... __parent__ = ChildResource() ... __name__ = "grandchild" The GrandchildResource's URL contains the URL part from the root resource, the two from the ChildResource, and one from the GrandchildResource itself. >>> print str(getMultiAdapter( ... (GrandchildResource(), request), IAbsoluteURL)) http://dummyurl/child-part1/child-part2/grandchild Edge cases and error handling ============================= MultiplePathPartAbsoluteURL escapes the same characters as AbsoluteURL. It even escapes slashes, if a slash shows up inside a path part. >>> resource.__path_parts__ = ["!foo!", "bar/baz"] >>> print str(getMultiAdapter((resource, request), IAbsoluteURL)) http://dummyurl/%21foo%21/bar%2Fbaz If the __path_parts__ is not iterable, an attempt to get the URL raises an exception: >>> resource.__path_parts__ = "foobar" >>> str(getMultiAdapter((resource, request), IAbsoluteURL)) Traceback (most recent call last): ... TypeError: Expected an iterable for __path_parts__. If the __parent__ or __path_parts__ is missing or None, an attempt to get the URL raises the same exception as AbsoluteURL does. >>> resource.__path_parts__ = None >>> str(getMultiAdapter((resource, request), IAbsoluteURL)) Traceback (most recent call last): ... TypeError: There isn't enough context to get URL information... >>> resource.__path_parts__ = ["foo", "bar"] >>> resource.__parent__ = None >>> str(getMultiAdapter((resource, request), IAbsoluteURL)) Traceback (most recent call last): ... TypeError: There isn't enough context to get URL information... lazr.restful-0.19.3/src/lazr/restful/docs/django.txt0000644000175000017500000001715311631755356022640 0ustar benjibenji00000000000000Django integration ****************** lazr.restful includes some code to make it easier to publish Django model objects as web service resources. Setup ===== lazr.restful doesn't depend on Django, but these tests need access to Django-like modules and classes. We're going to create some fake modules and classes for purposes of the tests. If Django is actually installed on this system, these modules will mask the real modules, but it'll only last until the end of the test. >>> import sys >>> import types >>> def create_fake_module(name): ... previous = None ... previous_names = [] ... for module_name in name.split('.'): ... previous_names.append(module_name) ... full_name = '.'.join(previous_names) ... module = sys.modules.get(full_name) ... if module is None: ... module = types.ModuleType(full_name) ... if previous is not None: ... setattr(previous, module_name, module) ... previous = module ... sys.modules[full_name] = module Before loading django.zcml, we need to create fake versions of all the modules and classes we'll be using, because if the real Django happens to be installed, loading the real Django classes will trigger all sorts of Django code that we can't handle. >>> create_fake_module('settings') >>> import settings >>> class ObjectDoesNotExist(Exception): ... pass >>> create_fake_module('django.core.exceptions') >>> import django.core.exceptions >>> django.core.exceptions.ObjectDoesNotExist = ObjectDoesNotExist >>> class Manager(object): ... """A fake version of Django's Manager class.""" ... def __init__(self, l): ... self.list = l ... def iterator(self): ... return self.list.__iter__() ... def all(self): ... return self.list ... def count(self): ... return len(self.list) >>> create_fake_module('django.db.models.manager') >>> import django.db.models.manager >>> django.db.models.manager.Manager = Manager Now we can load a ZCML configuration typical of a lazr.restful application that publishes Django model objects through a web service. >>> from zope.configuration import xmlconfig >>> ignore = xmlconfig.string(""" ... ... ... ... ... """) Configuration ============= Like any lazr.restful app, a Django app can subclass BaseWebServiceConfiguration and customize the subclass to set configuration options. However, you might find it easier to subclass DjangoWebServiceConfiguration instead and put the configuration in your settings.py file. An attribute 'foo_bar' defined in IWebServiceConfiguration can be set in your settings.py file as LAZR_RESTFUL_FOO_BAR. Here, we'll override the default value of 'show_tracebacks'. >>> settings.LAZR_RESTFUL_SHOW_TRACEBACKS = False >>> from lazr.restful.frameworks.django import ( ... DjangoWebServiceConfiguration) >>> class MyConfiguration(DjangoWebServiceConfiguration): ... pass >>> MyConfiguration().show_tracebacks True Once grok is called on the configuration class... >>> from grokcore.component.testing import grok_component >>> ignored = grok_component('MyConfiguration', MyConfiguration) ...MyConfiguration is the registered utility for IWebServiceConfiguration, and its value for use_https and show_tracebacks have been taken from the 'settings' module. >>> from zope.component import getUtility >>> from lazr.restful.interfaces import IWebServiceConfiguration >>> utility = getUtility(IWebServiceConfiguration) >>> utility.show_tracebacks False Attributes that don't have values in settings.py are given their default value. >>> utility.active_versions [] >>> utility.use_https True IDjangoLocation =============== The simplest way to generate URLs for your resources is to have your service root resource implement IAbsoluteURL (to generate the root of all web service URLs), and have all your model classes implement ILocation (which defines the URL in terms of a parent object's URL). ILocation requires that you define a property called __name__. This is part of the URL unique to an object, as opposed to the first part of the URL, which comes from its parent. Unfortunately, the Django object metaclass won't allow you to assign a property to __name__. IDjangoLocation is a class provided by lazr.restful. It acts just like ILocation, but the last part of the URL comes from __url_path__ instead of __name__. Here's some setup; a root resource that has its own AbsoluteURL implementation. >>> from lazr.restful.testing.webservice import ( ... DummyRootResource, DummyRootResourceURL) >>> from zope.component import getSiteManager >>> sm = getSiteManager() >>> sm.registerAdapter(DummyRootResourceURL) Now here's a subordinate resource that just implements IDjangoLocatino. >>> from zope.interface import implements >>> from lazr.restful.frameworks.django import IDjangoLocation >>> class SubordinateResource: ... implements(IDjangoLocation) ... ... @property ... def __parent__(self): ... return DummyRootResource() ... ... @property ... def __url_path__(self): ... return "myname" The django.zcml file in lazr/restful/frameworks contains an adapter between IDjangoLocation and ILocation. Thanks to that adapter, it's possible to adapt a Django model object to ILocation and check on its __name__. >>> from zope.location.interfaces import ILocation >>> resource = SubordinateResource() >>> as_location = ILocation(resource) >>> print as_location.__name__ myname It's also possible to adapt a Django model object to IAbsoluteURL and get its full URL. >>> from zope.component import getMultiAdapter >>> from zope.traversing.browser.interfaces import IAbsoluteURL >>> from lazr.restful.simple import Request >>> request = Request("", {}) >>> print str(getMultiAdapter((resource, request), IAbsoluteURL)) http://dummyurl/myname ObjectDoesNotExist ================== The django.zcml helper file contains an adapter registration so that Django's ObjectDoesNotExist exceptions are treated as 404 errors. To test this, we simply instantiate an ObjectDoesNotExist and then get a view for it. >>> exception = ObjectDoesNotExist() >>> view = getMultiAdapter((exception, request), name="index.html") >>> view() '' The view for the ObjectDoesNotExist exception sets the HTTP response code to 404. >>> request.response.getStatus() 404 Managers ======== The django.zcml file includes an adapter between Django's Manager class (which controls access to the database) and Zope's IFiniteCollection interface (used by lazr.batchnavigator to batch data sets). >>> from zope.interface.common.sequence import IFiniteSequence >>> data = ["foo", "bar", "baz"] >>> from django.db.models.manager import Manager >>> manager = Manager(data) >>> sequence = IFiniteSequence(manager) The adapter class is ManagerSequencer. >>> sequence A ManagerSequencer object makes a Manager object act like a Python list, which is what IFiniteSequence needs. >>> len(sequence) 3 >>> print sequence[1] bar >>> sequence[1:3] ['bar', 'baz'] >>> [x for x in sequence] ['foo', 'bar', 'baz'] lazr.restful-0.19.3/src/lazr/restful/docs/multiversion.txt0000644000175000017500000010743011631755356024134 0ustar benjibenji00000000000000Publishing multiple versions of a web service ********************************************* A single data model can yield many different lazr.restful web services. Typically these different services represent successive versions of a single web service, improved over time. This test defines three different versions of a web service ('beta', '1.0', and 'dev'), all based on the same underlying data model. Setup ===== First, let's set up the web service infrastructure. Doing this first will let us create HTTP requests for different versions of the web service. The first step is to install the common ZCML used by all lazr.restful web services. >>> from zope.configuration import xmlconfig >>> zcmlcontext = xmlconfig.string(""" ... ... ... ... ... """) Web service configuration object -------------------------------- Here's the web service configuration, which defines the three versions: 'beta', '1.0', and 'dev'. >>> from lazr.restful import directives >>> from lazr.restful.interfaces import IWebServiceConfiguration >>> from lazr.restful.simple import BaseWebServiceConfiguration >>> from lazr.restful.testing.webservice import WebServiceTestPublication >>> class WebServiceConfiguration(BaseWebServiceConfiguration): ... hostname = 'api.multiversion.dev' ... use_https = False ... active_versions = ['beta', '1.0', 'dev'] ... first_version_with_total_size_link = '1.0' ... code_revision = 'test' ... max_batch_size = 100 ... view_permission = None ... directives.publication_class(WebServiceTestPublication) >>> from grokcore.component.testing import grok_component >>> ignore = grok_component( ... 'WebServiceConfiguration', WebServiceConfiguration) >>> from zope.component import getUtility >>> config = getUtility(IWebServiceConfiguration) Collections previously exposed their total size via a `total_size` attribute. However, newer versions of lazr.restful expose a `total_size_link` intead. To facilitate transitioning from one approach to the other the configuration option `first_version_with_total_size_link` has been added to IWebServiceConfiguration. In this case, `first_version_with_total_size_link` is '1.0'. This means that named operations in versions prior to '1.0' will always return a `total_size`, but named operations in '1.0' and later versions will return a `total_size_link` when appropriate. URL generation -------------- The URL to an entry or collection is different in different versions of web service. Not only does every URL includes the version number as a path element ("http://api.multiversion.dev/1.0/..."), the name or location of an object might change from one version to another. We implement this in this example web service by defining ILocation implementations that retrieve the current browser request and branch based on the value of request.version. You'll see this in the ContactSet class. Here, we tell Zope to use Zope's default AbsoluteURL class for generating the URLs of objects that implement ILocation. There's no multiversion-specific code here. >>> from zope.component import getSiteManager >>> from zope.traversing.browser import AbsoluteURL >>> from zope.traversing.browser.interfaces import IAbsoluteURL >>> from zope.location.interfaces import ILocation >>> from lazr.restful.interfaces import IWebServiceLayer >>> sm = getSiteManager() >>> sm.registerAdapter( ... AbsoluteURL, (ILocation, IWebServiceLayer), ... provided=IAbsoluteURL) Defining the request marker interfaces -------------------------------------- Every version must have a corresponding subclass of IWebServiceClientRequest. Each interface class is registered as a named utility implementing IWebServiceVersion. For instance, in the example below, the I10Version class will be registered as the IWebServiceVersion utility with the name "1.0". When a request comes in, lazr.restful figures out which version the client is asking for, and tags the request with the appropriate marker interface. The utility registrations make it easy to get the marker interface for a version, given the version string. In a real application, these interfaces will be generated and registered automatically. >>> from lazr.restful.interfaces import IWebServiceClientRequest >>> class IBetaVersion(IWebServiceClientRequest): ... pass >>> class I10Version(IWebServiceClientRequest): ... pass >>> class IDevVersion(IWebServiceClientRequest): ... pass >>> versions = ((IBetaVersion, 'beta'), ... (I10Version, '1.0'), ... (IDevVersion, 'dev')) >>> from lazr.restful import register_versioned_request_utility >>> for cls, version in versions: ... register_versioned_request_utility(cls, version) Example model objects ===================== Now let's define the data model. The model in webservice.txt is pretty complicated; this model will be just complicated enough to illustrate how to publish multiple versions of a web service. # All classes defined in this test are new-style classes. >>> __metaclass__ = type >>> from zope.interface import Interface, Attribute >>> from zope.schema import Bool, Bytes, Int, Text, TextLine, Object >>> class IContact(Interface): ... name = TextLine(title=u"Name", required=True) ... phone = TextLine(title=u"Phone number", required=True) ... fax = TextLine(title=u"Fax number", required=False) Here's an interface for the 'set' object that manages the contacts. >>> from lazr.restful.interfaces import ITraverseWithGet >>> class IContactSet(ITraverseWithGet): ... def getAllContacts(): ... "Get all contacts." ... ... def getContactsWithPhone(): ... "Get all contacts that have a phone number." ... ... def findContacts(self, string, search_fax): ... """Find contacts by name, phone number, or fax number.""" Here's a simple implementation of IContact. >>> from urllib import quote >>> from zope.interface import implements >>> from lazr.restful.security import protect_schema >>> class Contact: ... implements(IContact, ILocation) ... def __init__(self, name, phone, fax): ... self.name = name ... self.phone = phone ... self.fax = fax ... ... @property ... def __parent__(self): ... return ContactSet() ... ... @property ... def __name__(self): ... return self.name >>> protect_schema(Contact, IContact) Here's a simple ContactSet with a predefined list of contacts. >>> from operator import attrgetter >>> from zope.publisher.interfaces.browser import IBrowserRequest >>> from lazr.restful.interfaces import IServiceRootResource >>> from lazr.restful.simple import TraverseWithGet >>> from lazr.restful.utils import get_current_web_service_request >>> class ContactSet(TraverseWithGet): ... implements(IContactSet, ILocation) ... ... def __init__(self): ... self.contacts = CONTACTS ... ... def get(self, request, name): ... contacts = [contact for contact in self.contacts ... if contact.name == name] ... if len(contacts) == 1: ... return contacts[0] ... return None ... ... def getAllContacts(self): ... return self.contacts ... ... def getContactsWithPhone(self): ... return [contact for contact in self.contacts ... if contact.phone is not None] ... ... def findContacts(self, string, search_fax=True): ... return [contact for contact in self.contacts ... if (string in contact.name ... or (contact.phone is not None ... and string in contact.phone) ... or (search_fax and string in contact.fax))] ... ... @property ... def __parent__(self): ... request = get_current_web_service_request() ... return getUtility( ... IServiceRootResource, name=request.version) ... ... @property ... def __name__(self): ... request = get_current_web_service_request() ... if request.version == 'beta': ... return 'contact_list' ... return 'contacts' >>> from lazr.restful.security import protect_schema >>> protect_schema(ContactSet, IContactSet) Here are the "model objects" themselves: >>> C1 = Contact("Cleo Python", "555-1212", "111-2121") >>> C2 = Contact("Oliver Bluth", "10-1000000", "22-2222222") >>> C3 = Contact("Fax-your-order Pizza", None, "100-200-300") >>> CONTACTS = [C1, C2, C3] Defining the web service data model =================================== We've defined an underlying data model (IContact), and now we're going to define the evolution of a web service through three versions, by defining three derivative data models. In a real application, these IEntry subclasses would be generated from lazr.restful decorators present in IContact but for testing purposes we're going to just define the three IEntry subclasses manually. The "beta" version of the web service publishes the IContact interface exactly as it is defined. >>> from lazr.restful.interfaces import IEntry >>> class IContactEntry(IEntry): ... """Marker for a contact published through the web service.""" >>> from zope.interface import taggedValue >>> from lazr.restful.interfaces import LAZR_WEBSERVICE_NAME >>> class IContactEntryBeta(IContactEntry, IContact): ... """The part of an author we expose through the web service.""" ... taggedValue(LAZR_WEBSERVICE_NAME, ... dict(singular="contact", plural="contacts")) The "1.0" version publishes the IContact interface as is, but renames two of the fields. >>> class IContactEntry10(IContactEntry): ... name = TextLine(title=u"Name", required=True) ... phone_number = TextLine(title=u"Phone number", required=True) ... fax_number = TextLine(title=u"Fax number", required=False) ... taggedValue(LAZR_WEBSERVICE_NAME, ... dict(singular="contact", plural="contacts")) IContactEntry10's "phone_number" and "fax_number" fields correspond to IContact's 'phone' and 'fax' fields. Since we changed the name, we must set the tags on the fields object giving the corresponding field name in IContact. Ordinarily, the lazr.restful declarations take care of this for us, but here we need to do it ourselves because we're defining the IEntry classes by hand. >>> from lazr.restful.declarations import LAZR_WEBSERVICE_EXPORTED >>> IContactEntry10['phone_number'].setTaggedValue( ... LAZR_WEBSERVICE_EXPORTED, dict(original_name="phone")) >>> IContactEntry10['fax_number'].setTaggedValue( ... LAZR_WEBSERVICE_EXPORTED, dict(original_name="fax")) The "dev" version drops the "fax_number" field because fax machines are obsolete. >>> class IContactEntryDev(IContactEntry): ... name = TextLine(title=u"Name", required=True) ... phone_number = TextLine(title=u"Phone number", required=True) ... taggedValue(LAZR_WEBSERVICE_NAME, ... dict(singular="contact", plural="contacts")) >>> IContactEntryDev['phone_number'].setTaggedValue( ... LAZR_WEBSERVICE_EXPORTED, dict(original_name="phone")) Implementing the entry resources ================================ The Contact class defined above implements the IContact interface, but IContact just describes the data model, not any particular web service. The IContactEntry subclasses above -- IContactEntryBeta, IContactEntry10, IContactEntryDev -- describe the three versions of the web service. Each of these interfaces must be implemented by a subclass of Entry. In a real application, the Entry subclasses would be generated from lazr.restful decorators present in IContact (just like the IEntry subclasses), but for testing purposes we're going to define the three Entry subclasses manually. >>> from zope.component import adapts >>> from zope.interface import implements >>> from lazr.delegates import delegates >>> from lazr.restful import Entry >>> class ContactEntryBeta(Entry): ... """A contact, as exposed through the 'beta' web service.""" ... adapts(IContact) ... implements(IContactEntryBeta) ... delegates(IContactEntryBeta) ... schema = IContactEntryBeta ... # This dict is normally generated by lazr.restful, but since we ... # create the adapters manually here, we need to do the same for ... # this dict. ... _orig_interfaces = { ... 'name': IContact, 'phone': IContact, 'fax': IContact} ... def __init__(self, context, request): ... self.context = context >>> sm.registerAdapter( ... ContactEntryBeta, [IContact, IBetaVersion], ... provided=IContactEntry) By wrapping one of our predefined Contacts in a ContactEntryBeta object, we can verify that it implements IContactEntryBeta and IContactEntry. >>> entry = ContactEntryBeta(C1, None) >>> IContactEntry.validateInvariants(entry) >>> IContactEntryBeta.validateInvariants(entry) Here's the implemenation of IContactEntry10, which defines Python properties to implement the different field names. >>> class ContactEntry10(Entry): ... adapts(IContact) ... implements(IContactEntry10) ... delegates(IContactEntry10) ... schema = IContactEntry10 ... # This dict is normally generated by lazr.restful, but since we ... # create the adapters manually here, we need to do the same for ... # this dict. ... _orig_interfaces = { ... 'name': IContact, 'phone_number': IContact, ... 'fax_number': IContact} ... ... def __init__(self, context, request): ... self.context = context ... ... @property ... def phone_number(self): ... return self.context.phone ... ... @property ... def fax_number(self): ... return self.context.fax >>> sm.registerAdapter( ... ContactEntry10, [IContact, I10Version], ... provided=IContactEntry) >>> entry = ContactEntry10(C1, None) >>> IContactEntry.validateInvariants(entry) >>> IContactEntry10.validateInvariants(entry) Finally, here's the implementation of IContactEntry for the "dev" version of the web service. >>> class ContactEntryDev(Entry): ... adapts(IContact) ... implements(IContactEntryDev) ... delegates(IContactEntryDev) ... schema = IContactEntryDev ... # This dict is normally generated by lazr.restful, but since we ... # create the adapters manually here, we need to do the same for ... # this dict. ... _orig_interfaces = {'name': IContact, 'phone_number': IContact} ... ... def __init__(self, context, request): ... self.context = context ... ... @property ... def phone_number(self): ... return self.context.phone >>> sm.registerAdapter( ... ContactEntryDev, [IContact, IDevVersion], ... provided=IContactEntry) >>> entry = ContactEntryDev(C1, None) >>> IContactEntry.validateInvariants(entry) >>> IContactEntryDev.validateInvariants(entry) Looking up the appropriate implementation ========================================= Because there is no single IEntry implementation for Contact objects, you can't just adapt Contact to IEntry. >>> from zope.component import getAdapter >>> getAdapter(C1, IEntry) Traceback (most recent call last): ... ComponentLookupError: ... When adapting Contact to IEntry you must provide a versioned request object. The IEntry object you get back will implement the appropriate version of the web service. To test this we'll need to manually create some versioned request objects. The traversal process would take care of this for us (see "Request lifecycle" below), but it won't work yet because we have yet to define a service root resource. >>> from lazr.restful.testing.webservice import ( ... create_web_service_request) >>> from zope.interface import alsoProvides >>> from zope.component import getMultiAdapter >>> request_beta = create_web_service_request('/beta/') >>> alsoProvides(request_beta, IBetaVersion) >>> beta_entry = getMultiAdapter((C1, request_beta), IEntry) >>> print beta_entry.fax 111-2121 >>> request_10 = create_web_service_request('/1.0/') >>> alsoProvides(request_10, I10Version) >>> one_oh_entry = getMultiAdapter((C1, request_10), IEntry) >>> print one_oh_entry.fax_number 111-2121 >>> request_dev = create_web_service_request('/dev/') >>> alsoProvides(request_dev, IDevVersion) >>> dev_entry = getMultiAdapter((C1, request_dev), IEntry) >>> print dev_entry.fax Traceback (most recent call last): ... AttributeError: 'ContactEntryDev' object has no attribute 'fax' Implementing the collection resource ==================================== The set of contacts publishes a slightly different named operation in every version of the web service, so in a little bit we'll be implementing three different versions of the same named operation. The contact set itself also changes between versions. In the 'beta' and '1.0' versions, the contact set serves all contcts. In the 'dev' version, the contact set omits contacts that only have a fax number. We'll implement this behavior by implementing ICollection twice and registering each implementation for the appropriate versions of the web service. >>> from lazr.restful import Collection >>> from lazr.restful.interfaces import ICollection First we'll implement the version used in 'beta' and '1.0'. >>> class ContactCollectionBeta(Collection): ... """A collection of contacts, exposed through the web service.""" ... adapts(IContactSet) ... ... entry_schema = IContactEntry ... ... def find(self): ... """Find all the contacts.""" ... return self.context.getAllContacts() Let's make sure it implements ICollection. >>> from zope.interface.verify import verifyObject >>> contact_set = ContactSet() >>> verifyObject(ICollection, ContactCollectionBeta(contact_set, None)) True Register it as the ICollection adapter for IContactSet in IBetaVersion and I10Version. >>> for version in [IBetaVersion, I10Version]: ... sm.registerAdapter( ... ContactCollectionBeta, [IContactSet, version], ... provided=ICollection) Make sure the functionality works properly. >>> collection = getMultiAdapter( ... (contact_set, request_beta), ICollection) >>> len(collection.find()) 3 >>> collection = getMultiAdapter( ... (contact_set, request_10), ICollection) >>> len(collection.find()) 3 Now let's implement the different version used in 'dev'. >>> class ContactCollectionDev(ContactCollectionBeta): ... def find(self): ... """Find all the contacts, sorted by name.""" ... return self.context.getContactsWithPhone() This class also implements ICollection. >>> verifyObject(ICollection, ContactCollectionDev(contact_set, None)) True Register it as the ICollection adapter for IContactSet in IDevVersion. >>> sm.registerAdapter( ... ContactCollectionDev, [IContactSet, IDevVersion], ... provided=ICollection) Make sure the functionality works properly. Note that the contact that only has a fax number no longer shows up. >>> collection = getMultiAdapter( ... (contact_set, request_dev), ICollection) >>> [contact.name for contact in collection.find()] ['Cleo Python', 'Oliver Bluth'] Implementing the named operations --------------------------------- All three versions of the web service publish a named operation for searching for contacts, but they publish it in slightly different ways. In 'beta' it publishes a named operation called 'findContacts', which does a search based on name, phone number, and fax number. In '1.0' it publishes the same operation, but the name is 'find'. In 'dev' the contact set publishes 'find', but the functionality is changed to search only the name and phone number. Here's the named operation as implemented in versions 'beta' and '1.0'. >>> from lazr.restful import ResourceGETOperation >>> from lazr.restful.fields import CollectionField, Reference >>> from lazr.restful.interfaces import IResourceGETOperation >>> class FindContactsOperationBase(ResourceGETOperation): ... """An operation that searches for contacts.""" ... implements(IResourceGETOperation) ... ... params = [ TextLine(__name__='string') ] ... return_type = CollectionField(value_type=Reference(schema=IContact)) ... ... def call(self, string): ... try: ... return self.context.findContacts(string) ... except ValueError, e: ... self.request.response.setStatus(400) ... return str(e) This operation is registered as the "findContacts" operation in the 'beta' service, and the 'find' operation in the '1.0' service. >>> sm.registerAdapter( ... FindContactsOperationBase, [IContactSet, IBetaVersion], ... provided=IResourceGETOperation, name="findContacts") >>> sm.registerAdapter( ... FindContactsOperationBase, [IContactSet, I10Version], ... provided=IResourceGETOperation, name="find") Here's the slightly different named operation as implemented in version 'dev'. >>> class FindContactsOperationNoFax(FindContactsOperationBase): ... """An operation that searches for contacts.""" ... ... def call(self, string): ... try: ... return self.context.findContacts(string, False) ... except ValueError, e: ... self.request.response.setStatus(400) ... return str(e) >>> sm.registerAdapter( ... FindContactsOperationNoFax, [IContactSet, IDevVersion], ... provided=IResourceGETOperation, name="find") The service root resource ========================= To make things more interesting we'll define two distinct service roots. The 'beta' web service will publish the contact set as 'contact_list', and subsequent versions will publish it as 'contacts'. >>> from lazr.restful.simple import RootResource >>> from zope.traversing.browser.interfaces import IAbsoluteURL >>> class BetaServiceRootResource(RootResource): ... implements(IAbsoluteURL) ... ... top_level_collections = { ... 'contact_list': (IContact, ContactSet()) } >>> class PostBetaServiceRootResource(RootResource): ... implements(IAbsoluteURL) ... ... top_level_collections = { ... 'contacts': (IContact, ContactSet()) } >>> for version, cls in (('beta', BetaServiceRootResource), ... ('1.0', PostBetaServiceRootResource), ... ('dev', PostBetaServiceRootResource)): ... app = cls() ... sm.registerUtility(app, IServiceRootResource, name=version) >>> beta_app = getUtility(IServiceRootResource, 'beta') >>> dev_app = getUtility(IServiceRootResource, 'dev') >>> beta_app.top_level_names ['contact_list'] >>> dev_app.top_level_names ['contacts'] Both classes will use the default lazr.restful code to generate their URLs. >>> from zope.traversing.browser import absoluteURL >>> from lazr.restful.simple import RootResourceAbsoluteURL >>> for cls in (BetaServiceRootResource, PostBetaServiceRootResource): ... sm.registerAdapter( ... RootResourceAbsoluteURL, [cls, IBrowserRequest]) >>> beta_request = create_web_service_request('/beta/') >>> print beta_request.traverse(None) >>> print absoluteURL(beta_app, beta_request) http://api.multiversion.dev/beta/ >>> dev_request = create_web_service_request('/dev/') >>> print dev_request.traverse(None) >>> print absoluteURL(dev_app, dev_request) http://api.multiversion.dev/dev/ Request lifecycle ================= When a request first comes in, there's no way to tell which version it's associated with. >>> from lazr.restful.testing.webservice import ( ... create_web_service_request) >>> request_beta = create_web_service_request('/beta/') >>> IBetaVersion.providedBy(request_beta) False The traversal process associates the request with a particular version. >>> request_beta.traverse(None) >>> IBetaVersion.providedBy(request_beta) True >>> print request_beta.version beta Using the web service ===================== Now that we can create versioned web service requests, let's try out the different versions of the web service. Beta ---- Here's the service root resource. >>> import simplejson >>> request = create_web_service_request('/beta/') >>> resource = request.traverse(None) >>> body = simplejson.loads(resource()) >>> print sorted(body.keys()) ['contacts_collection_link', 'resource_type_link'] >>> print body['contacts_collection_link'] http://api.multiversion.dev/beta/contact_list Here's the contact list. >>> request = create_web_service_request('/beta/contact_list') >>> resource = request.traverse(None) We can't access the underlying data model object through the request, but since we happen to know which object it is, we can pass it into absoluteURL along with the request object, and get the correct URL. >>> print absoluteURL(contact_set, request) http://api.multiversion.dev/beta/contact_list >>> body = simplejson.loads(resource()) >>> body['total_size'] 3 >>> for link in sorted( ... [contact['self_link'] for contact in body['entries']]): ... print link http://api.multiversion.dev/beta/contact_list/Cleo%20Python http://api.multiversion.dev/beta/contact_list/Fax-your-order%20Pizza http://api.multiversion.dev/beta/contact_list/Oliver%20Bluth We can traverse through the collection to an entry. >>> request_beta = create_web_service_request( ... '/beta/contact_list/Cleo Python') >>> resource = request_beta.traverse(None) Again, we can't access the underlying data model object through the request, but since we know which object represents Cleo Python, we can pass it into absoluteURL along with this request object, and get the object's URL. >>> print C1.name Cleo Python >>> print absoluteURL(C1, request_beta) http://api.multiversion.dev/beta/contact_list/Cleo%20Python >>> body = simplejson.loads(resource()) >>> sorted(body.keys()) ['fax', 'http_etag', 'name', 'phone', 'resource_type_link', 'self_link'] >>> print body['name'] Cleo Python We can traverse through an entry to one of its fields. >>> request_beta = create_web_service_request( ... '/beta/contact_list/Cleo Python/fax') >>> field = request_beta.traverse(None) >>> print simplejson.loads(field()) 111-2121 We can invoke a named operation, and it returns a total_size (because 'beta' is an earlier version than the first_version_with_total_size_link). >>> import simplejson >>> request_beta = create_web_service_request( ... '/beta/contact_list', ... environ={'QUERY_STRING' : 'ws.op=findContacts&string=Cleo'}) >>> operation = request_beta.traverse(None) >>> result = simplejson.loads(operation()) >>> [contact['name'] for contact in result['entries']] ['Cleo Python'] >>> result['total_size'] 1 >>> request_beta = create_web_service_request( ... '/beta/contact_list', ... environ={'QUERY_STRING' : 'ws.op=findContacts&string=111'}) >>> operation = request_beta.traverse(None) >>> result = simplejson.loads(operation()) >>> [contact['fax'] for contact in result['entries']] ['111-2121'] 1.0 --- Here's the service root resource. >>> import simplejson >>> request = create_web_service_request('/1.0/') >>> resource = request.traverse(None) >>> body = simplejson.loads(resource()) >>> print sorted(body.keys()) ['contacts_collection_link', 'resource_type_link'] Note that 'contacts_collection_link' points to a different URL in '1.0' than in 'dev'. >>> print body['contacts_collection_link'] http://api.multiversion.dev/1.0/contacts An attempt to use the 'beta' name of the contact list in the '1.0' web service will fail. >>> request = create_web_service_request('/1.0/contact_list') >>> resource = request.traverse(None) Traceback (most recent call last): ... NotFound: Object: , name: u'contact_list' Here's the contact list under its correct URL. >>> request = create_web_service_request('/1.0/contacts') >>> resource = request.traverse(None) >>> print absoluteURL(contact_set, request) http://api.multiversion.dev/1.0/contacts >>> body = simplejson.loads(resource()) >>> body['total_size'] 3 >>> for link in sorted( ... [contact['self_link'] for contact in body['entries']]): ... print link http://api.multiversion.dev/1.0/contacts/Cleo%20Python http://api.multiversion.dev/1.0/contacts/Fax-your-order%20Pizza http://api.multiversion.dev/1.0/contacts/Oliver%20Bluth We can traverse through the collection to an entry. >>> request_10 = create_web_service_request( ... '/1.0/contacts/Cleo Python') >>> resource = request_10.traverse(None) >>> print absoluteURL(C1, request_10) http://api.multiversion.dev/1.0/contacts/Cleo%20Python Note that the 'fax' and 'phone' fields are now called 'fax_number' and 'phone_number'. >>> body = simplejson.loads(resource()) >>> sorted(body.keys()) ['fax_number', 'http_etag', 'name', 'phone_number', 'resource_type_link', 'self_link'] >>> print body['name'] Cleo Python We can traverse through an entry to one of its fields. >>> request_10 = create_web_service_request( ... '/1.0/contacts/Cleo Python/fax_number') >>> field = request_10.traverse(None) >>> print simplejson.loads(field()) 111-2121 The fax field in '1.0' is called 'fax_number', and attempting to traverse to its 'beta' name ('fax') will fail. >>> request_10 = create_web_service_request( ... '/1.0/contacts/Cleo Python/fax') >>> field = request_10.traverse(None) Traceback (most recent call last): ... NotFound: Object: , name: u'fax' We can invoke a named operation. Note that the name of the operation is now 'find' (it was 'findContacts' in 'beta'). And note that total_size has been replaced by total_size_link, since '1.0' is the first_version_with_total_size_link. >>> request_10 = create_web_service_request( ... '/1.0/contacts', ... environ={'QUERY_STRING' : 'ws.op=find&string=e&ws.size=2'}) >>> operation = request_10.traverse(None) >>> result = simplejson.loads(operation()) >>> [contact['name'] for contact in result['entries']] ['Cleo Python', 'Oliver Bluth'] >>> result['total_size'] Traceback (most recent call last): ... KeyError: 'total_size' >>> print result['total_size_link'] http://.../1.0/contacts?string=e&ws.op=find&ws.show=total_size >>> size_request = create_web_service_request( ... '/1.0/contacts', ... environ={'QUERY_STRING' : ... 'string=e&ws.op=find&ws.show=total_size'}) >>> operation = size_request.traverse(None) >>> result = simplejson.loads(operation()) >>> print result 3 If the resultset fits on a single page, total_size will be provided instead of total_size_link, as a convenience. >>> request_10 = create_web_service_request( ... '/1.0/contacts', ... environ={'QUERY_STRING' : 'ws.op=find&string=111'}) >>> operation = request_10.traverse(None) >>> result = simplejson.loads(operation()) >>> [contact['fax_number'] for contact in result['entries']] ['111-2121'] >>> result['total_size'] 1 Attempting to invoke the operation using its 'beta' name won't work. >>> request_10 = create_web_service_request( ... '/1.0/contacts', ... environ={'QUERY_STRING' : 'ws.op=findContacts&string=Cleo'}) >>> operation = request_10.traverse(None) >>> print operation() No such operation: findContacts Dev --- Here's the service root resource. >>> request = create_web_service_request('/dev/') >>> resource = request.traverse(None) >>> body = simplejson.loads(resource()) >>> print sorted(body.keys()) ['contacts_collection_link', 'resource_type_link'] >>> print body['contacts_collection_link'] http://api.multiversion.dev/dev/contacts Here's the contact list. >>> request_dev = create_web_service_request('/dev/contacts') >>> resource = request_dev.traverse(None) >>> print absoluteURL(contact_set, request_dev) http://api.multiversion.dev/dev/contacts >>> body = simplejson.loads(resource()) >>> body['total_size'] 2 >>> for link in sorted( ... [contact['self_link'] for contact in body['entries']]): ... print link http://api.multiversion.dev/dev/contacts/Cleo%20Python http://api.multiversion.dev/dev/contacts/Oliver%20Bluth We can traverse through the collection to an entry. >>> request_dev = create_web_service_request( ... '/dev/contacts/Cleo Python') >>> resource = request_dev.traverse(None) >>> print absoluteURL(C1, request_dev) http://api.multiversion.dev/dev/contacts/Cleo%20Python Note that the published field names have changed between 'dev' and '1.0'. The phone field is still 'phone_number', but the 'fax_number' field is gone. >>> body = simplejson.loads(resource()) >>> sorted(body.keys()) ['http_etag', 'name', 'phone_number', 'resource_type_link', 'self_link'] >>> print body['name'] Cleo Python We can traverse through an entry to one of its fields. >>> request_dev = create_web_service_request( ... '/dev/contacts/Cleo Python/name') >>> field = request_dev.traverse(None) >>> print simplejson.loads(field()) Cleo Python We cannot use 'dev' to traverse to a field not published in the 'dev' version. >>> request_beta = create_web_service_request( ... '/dev/contacts/Cleo Python/fax') >>> field = request_beta.traverse(None) Traceback (most recent call last): ... NotFound: Object: , name: u'fax' >>> request_beta = create_web_service_request( ... '/dev/contacts/Cleo Python/fax_number') >>> field = request_beta.traverse(None) Traceback (most recent call last): ... NotFound: Object: , name: u'fax_number' We can invoke a named operation. >>> request_dev = create_web_service_request( ... '/dev/contacts', ... environ={'QUERY_STRING' : 'ws.op=find&string=Cleo'}) >>> operation = request_dev.traverse(None) >>> result = simplejson.loads(operation()) >>> [contact['name'] for contact in result['entries']] ['Cleo Python'] Note that a search for Cleo's fax number no longer finds anything, because the named operation published as 'find' in the 'dev' web service doesn't search the fax field. >>> request_dev = create_web_service_request( ... '/dev/contacts', ... environ={'QUERY_STRING' : 'ws.op=find&string=111'}) >>> operation = request_dev.traverse(None) >>> result = simplejson.loads(operation()) >>> [entry for entry in result['entries']] [] lazr.restful-0.19.3/src/lazr/restful/docs/interface.txt0000644000175000017500000001323311631755356023331 0ustar benjibenji00000000000000LAZR Interface Helpers ********************** ============== use_template() ============== Sometime it is convenient to create an interface by copying another one, without creating a typed relationship between the two. A good use-case for that is for a UI form. The model and form schema are related but not identical. For examples, the labels/descriptions should probably be different since the model doc is intended to the developers, while the UI ones are for the user. The model may have additional fields that are not relevant for the UI model and/or vice versa. And there is no reason to say that an object providing the UI schema *is a* model. (Like the extension relationship would allow us to claim.) In the end, the relationship between the two schemas is one of convenience: we want to share the data validation constraint specified by the model. For these cases, there is lazr.restful.interface.use_template. Let's define a "model" schema: >>> from zope.interface import Attribute, Interface >>> from zope.schema import Int, Choice, Text >>> class MyModel(Interface): ... """A very simple model.""" ... ... age = Int(title=u"Number of years since birth") ... name = Text(title=u"Identifier") >>> class Initiated(MyModel): ... illuminated = Choice( ... title=u"Has seen the fnords?", values=('yes', 'no', 'maybe')) ... fancy_value = Attribute(u"Some other fancy value.") The use_template() helper takes as first parameter the interface to use as a template and either a 'include' or 'exclude' parameter taking the list of the fields to include/exclude. So to create a form for UI with more appropriate labels you would use: >>> from lazr.restful.interface import use_template >>> class MyForm1(Interface): ... "Form schema for a MyModel." ... use_template(MyModel, include=['age', 'name']) ... age.title = u'Your age:' ... name.title = u'Your name:' The MyForm1 interface now has an age and name fields that have a distinct titles than their original: >>> sorted(MyForm1.names()) ['age', 'name'] >>> print MyForm1.get('age').title Your age: >>> print MyForm1.get('name').title Your name: The interface attribute points to the correct interface: >>> MyForm1.get('age').interface is MyForm1 True And the original field wasn't updated: >>> print MyModel.get('age').title Number of years since birth >>> MyModel.get('name').interface is MyModel True Using the exclude form of the directive, you could get an equivalent to MyForm1 using the following: >>> class MyForm2(Interface): ... use_template(Initiated, exclude=['illuminated', 'fancy_value']) >>> sorted(MyForm2.names()) ['age', 'name'] It is an error to use both arguments: >>> class MyForm3(Interface): ... use_template(MyModel, include=['age'], exclude=['name']) Traceback (most recent call last): ... ValueError: you cannot use 'include' and 'exclude' at the same time. If neither include, nor exclude are used. The new interface is an exact copy of the template: >>> class MyForm4(Interface): ... use_template(Initiated) >>> sorted(MyForm4.names()) ['age', 'fancy_value', 'illuminated', 'name'] The order of the field in the new interface is based on the order of the declaration in the use_template() directive. >>> class MyForm5(Interface): ... use_template(Initiated, include=['name', 'illuminated', 'age']) >>> from zope.schema import getFieldNamesInOrder >>> getFieldNamesInOrder(MyForm5) ['name', 'illuminated', 'age'] The directive can only be used from within a class scope: >>> use_template(MyModel) Traceback (most recent call last): ... TypeError: use_template() can only be used from within a class definition. ================ copy_attribute() ================ use_template() uses the copy_attribute() function to copy attributes from the original interface. It can also be used on its own to make a copy of an interface attribute or schema field. >>> from lazr.restful.interface import copy_attribute >>> illuminated = copy_attribute(Initiated['illuminated']) >>> illuminated <...Choice...> >>> illuminated.__name__ 'illuminated' >>> illuminated.title u'Has seen the fnords?' The interface attribute is cleared: >>> print illuminated.interface None It also supports the Field ordering (the copied field will have an higher order than its original.) >>> Initiated['illuminated'].order < illuminated.order True The parameter to the function must provide IAttribute: >>> copy_attribute(MyModel) Traceback (most recent call last): ... TypeError: <...MyModel...> doesn't provide IAttribute. ============ copy_field() ============ There is also a copy_field() field function that can be used to copy a schema field and override some of the copy attributes at the same time. >>> from lazr.restful.interface import copy_field >>> age = copy_field( ... MyModel['age'], title=u'The age.', required=False, ... arbitrary=1) >>> age.__class__.__name__ 'Int' >>> age.title u'The age.' >>> age.arbitrary 1 >>> age.required False >>> MyModel['age'].required True If the value for an overridden field is invalid, an exception will be raised: >>> copy_field(MyModel['age'], title='This should be unicode') Traceback (most recent call last): ... WrongType: ... That function can only be called on an IField: >>> copy_field(Initiated['fancy_value']) Traceback (most recent call last): ... TypeError: <...Attribute...> doesn't provide IField. lazr.restful-0.19.3/src/lazr/restful/docs/webservice-declarations.txt0000644000175000017500000031744411631755356026210 0ustar benjibenji00000000000000Web Service API Declarations **************************** You can easily create a web service by tagging your content interfaces with some decorators. From this tagging the web service API will be created automatically. Exporting the data model ======================== The LAZR Web Service data model consists of entries and collection (see webservice.txt for all the details). Entries support the IEntry interface and are basically a single resource exported. Think something like a bug, a person, an article, etc. Collections are a set of resources of the same types, think something like the set of bugs, persons, teams, articles, etc. Exporting entries ================= Only entries are exported as data. You can mark that one of your content interface is exported on the web service as an entry, by using the export_as_webservice_entry() declaration. You can mark the fields that should be part of the entry data model by using the exported() wrapper. It takes an optional 'exported_as' parameter that can be used to change the name under which the field will be exported. For example, here we declare that the IBook interface is exported as an entry on the web service. It exports the title, author, and base_price field, but not the inventory_number field. >>> from zope.interface import Interface >>> from zope.schema import Text, TextLine, Float, List >>> from lazr.restful.declarations import ( ... export_as_webservice_entry, exported) >>> class IBook(Interface): ... """A simple book data model.""" ... export_as_webservice_entry() ... ... title = exported(TextLine(title=u'The book title')) ... ... author = exported(TextLine(title=u"The book's author.")) ... ... base_price = exported(Float( ... title=u"The regular price of the book."), ... exported_as='price') ... ... inventory_number = TextLine(title=u'The inventory part number.') These declarations add tagged values to the original interface elements. The tags are in the lazr.restful namespace and are dictionaries of elements. >>> from pprint import pformat >>> def print_export_tag(element): ... """Print the content of the 'lazr.restful.exported' tag.""" ... def format_value(value): ... if isinstance(value, dict): ... return pformat(value, width=40) ... else: ... return repr(value) ... tag = element.queryTaggedValue('lazr.restful.exported') ... if tag is None: ... print "tag 'lazr.restful.exported' is not present" ... else: ... print "\n".join( ... "%s: %s" %(key, format_value(value)) ... for key, value in sorted(tag.items())) >>> print_export_tag(IBook) _as_of_was_used: False contributes_to: None exported: True plural_name: 'books' publish_web_link: True singular_name: 'book' type: 'entry' >>> print_export_tag(IBook['title']) as: 'title' original_name: 'title' type: 'field' >>> print_export_tag(IBook['author']) as: 'author' original_name: 'author' type: 'field' >>> print_export_tag(IBook['base_price']) as: 'price' original_name: 'base_price' type: 'field' >>> print_export_tag(IBook['inventory_number']) tag 'lazr.restful.exported' is not present Only IField can be exported as entry fields. >>> from zope.interface import Attribute >>> class Foo(Interface): ... export_as_webservice_entry() ... not_a_field = exported(Attribute('A standard attribute')) Traceback (most recent call last): ... TypeError: exported() can only be used on IFields. Object fields cannot be exported because they cause validation problems. >>> from zope.schema import Object >>> class UsesIObject(Interface): ... export_as_webservice_entry() ... object = exported(Object(schema=Interface)) Traceback (most recent call last): TypeError: Object exported; use Reference instead. Instead you should use Reference, a subclass of Object designed to avoid the validation problems. >>> from lazr.restful.fields import Reference >>> class UsesIReference(Interface): ... export_as_webservice_entry() ... object = exported(Reference(schema=Interface)) In the same vein, export_as_webservice_entry() can only be used on Interface. >>> class NotAnInterface(object): ... export_as_webservice_entry() Traceback (most recent call last): ... TypeError: export_as_webservice_entry() can only be used on an interface. And from within a class declaration. >>> export_as_webservice_entry() Traceback (most recent call last): ... TypeError: export_as_webservice_entry() can only be used from within an interface definition. publish_web_link ------------- If each webservice entry corresponds to some page on a website, lazr.restful will publish a web_link for each entry, pointing to the corresponding website page. For a given entry type, you can suppress this by passing in False for the `publish_web_link` argument to `export_as_webservice_entry`. >>> from zope.interface import Attribute >>> class INotOnTheWebsite(Interface): ... export_as_webservice_entry(publish_web_link=False) ... field = exported(TextLine(title=u"A field.")) >>> print_export_tag(INotOnTheWebsite) _as_of_was_used: False contributes_to: None ... publish_web_link: False ... Exporting a collection ====================== Collections scoped to an entry are exported simply by using exported() on the CollectionField containing the scoped collection items: >>> class ISimpleComment(Interface): ... """A simple comment.""" ... comment = TextLine(title=u'Comment') >>> from zope.schema import Object >>> from lazr.restful.fields import CollectionField >>> class IBookWithComments(IBook): ... """A book with some comments.""" ... export_as_webservice_entry() ... ... comments = exported(CollectionField( ... value_type=Object(schema=ISimpleComment))) Top-level collections are different though, they are exported by using the export_as_webservice_collection() in the ``Set`` class. The method that returns all of the collection items must be tagged with @collection_default_content decorator. >>> from lazr.restful.declarations import ( ... export_as_webservice_collection, collection_default_content, ... REQUEST_USER) >>> class IBookSet(Interface): ... """Set of all the books in the system.""" ... export_as_webservice_collection(IBook) ... ... @collection_default_content() ... def getAllBooks(): ... """Return an iterator over all the books.""" In case the method to call requires parameters, the value to use can be specified using parameters to the decorator constructor. There is a special REQUEST_USER marker that can be used to specify that this parameter should contain the logged in user. >>> class ICheckedOutBookSet(Interface): ... """Give access to the checked out books.""" ... export_as_webservice_collection(IBook) ... ... @collection_default_content(user=REQUEST_USER, title='') ... def getByTitle(title, user): ... """Return checked out books. ... :param title: String to match against the book title. ... The empty string matches everything. ... :param user: The user who should have checked the book out. ... """ Like for entries, this adds keys in the 'lazr.restful.exported' tagged value. >>> print_export_tag(IBookSet) collection_default_content: {None: ('getAllBooks', {})} collection_entry_schema: type: 'collection' >>> print_export_tag(ICheckedOutBookSet) collection_default_content: {None: ('getByTitle', {'title': '', 'user': })} collection_entry_schema: type: 'collection' The entry schema for a collection must be provided and must be an interface: >>> class MissingEntrySchema(Interface): ... export_as_webservice_collection() Traceback (most recent call last): ... TypeError: export_as_webservice_collection() takes exactly 1 argument (0 given) >>> class InvalidEntrySchema(Interface): ... export_as_webservice_collection("not an interface") Traceback (most recent call last): ... TypeError: entry_schema must be an interface. It's an error to try to export a collection without marking a method as exporting the default content. >>> class IDummyInterface(Interface): ... pass >>> class MissingDefaultContent(Interface): ... export_as_webservice_collection(IDummyInterface) Traceback (most recent call last): ... TypeError: export_as_webservice_collection() is missing a method tagged with @collection_default_content. As it is an error, to mark more than one method: >>> class TwoDefaultContent(Interface): ... export_as_webservice_collection(IDummyInterface) ... @collection_default_content() ... def getAll1(): ... """A first getAll().""" ... @collection_default_content() ... def getAll2(): ... """Another getAll().""" Traceback (most recent call last): ... TypeError: Only one method can be marked with @collection_default_content for version '(earliest version)'. export_as_webservice_collection() can only be used on Interface. >>> class NotAnInterface(object): ... export_as_webservice_collection(IDummyInterface) Traceback (most recent call last): ... TypeError: export_as_webservice_collection() can only be used on an interface. And from within a class declaration. >>> export_as_webservice_collection(IDummyInterface) Traceback (most recent call last): ... TypeError: export_as_webservice_collection() can only be used from within an interface definition. collection_default_content() can only be used from within an Interface declaration: >>> @collection_default_content() ... def a_function(): pass Traceback (most recent call last): ... TypeError: @collection_default_content can only be used from within an interface definition. And the interface must have been exported as a collection: >>> class NotExported(Interface): ... export_as_webservice_entry() ... @collection_default_content() ... def a_function(): pass Traceback (most recent call last): ... TypeError: @collection_default_content can only be used from within an interface exported as a collection. Exporting methods ================= Entries and collections can publish named operations on the webservice. Every named operation corresponds to some method defined on the content interface. To publish a method as a named operation, you tag it with special decorators. Four different decorators are used based on the kind of method exported. 1. @export_read_operation This will mark the method as available as a GET operation on the exported resource. 2. @export_write_operation This will mark the method as available as a POST operation on the exported resource. 3. @export_factory_operation(schema, fields) Like the @export_write_operation decorator, this will mark the method as available as a POST operation on the exported resource, with the addition that the result of the method is a new object and the HTTP status code will be set appropriately. This decorator takes as parameters the schema of the object it is creating and the name of the fields in the schema that are passed as parameters. 4. @export_destructor_operation This will mark the method as available as a DELETE operation on the exported resource. The specification of the web service's acceptable method parameters should be described using the @operation_parameters decorator, which takes normal IField instances. When an operation returns an object that's exposed as a resource, you should describe its return value with the @operation_returns_collection_of and @operation_returns_entry decorators. Both decorators take an interface that has been exposed as an entry. @operation_returns_entry is used when the operation returns a single entry; @operation_returns_collection_of is used when the operation returns a collection of entries. >>> from lazr.restful.declarations import ( ... export_operation_as, export_factory_operation, ... export_read_operation, operation_parameters, ... operation_returns_entry, operation_returns_collection_of, ... rename_parameters_as) >>> from lazr.restful.interface import copy_field >>> class IBookSetOnSteroids(IBookSet): ... """IBookSet supporting some methods.""" ... export_as_webservice_collection(IBook) ... ... @collection_default_content() ... @operation_parameters( ... text=copy_field(IBook['title'], title=u'Text to search for.')) ... @operation_returns_collection_of(IBook) ... @export_read_operation() ... def searchBookTitles(text): ... """Return list of books whose titles contain 'text'.""" ... ... @operation_parameters( ... text=copy_field(IBook['title'], title=u'Text to search for.')) ... @operation_returns_entry(IBook) ... @export_read_operation() ... def bestMatch(text): ... """Return the best match for books containing 'text'.""" ... ... @export_operation_as('create_book') ... @rename_parameters_as(base_price='price') ... @export_factory_operation( ... IBook, ['author', 'base_price', 'title']) ... def new(author, base_price, title): ... """Create a new book.""" In the above example, the exported new() method demonstrates two features to support having different names on the web service than in the internal API. It is possible to export a method under a different name by using the @export_operation_as decorator which takes the name under which the method should be exported. The @rename_parameters_as decorator can be used to rename the method parameters on the web service. In the example, the 'base_price' parameter will be called 'price' when exported on the web service. When some required parameters of the method should not be provided by the webservice client, it is possible to use the @call_with decorator to specify the value to use. The special REQUEST_USER marker can be used to specify that this parameter should contain the logged in user. >>> from lazr.restful.declarations import ( ... call_with, export_destructor_operation, export_write_operation, ... REQUEST_USER) >>> class IBookOnSteroids(IBook): ... """IBook with some methods.""" ... export_as_webservice_entry() ... ... @call_with(who=REQUEST_USER, kind='normal') ... @export_write_operation() ... def checkout(who, kind): ... """Check this book out.""" ... ... @export_destructor_operation() ... def destroy(): ... """Destroy the book.""" Like other declarations, these will add tagged values to the interface method. We didn't have to specify the return type for the factory operation, because a factory operation always returns the newly-created object. >>> print_export_tag(IBookSetOnSteroids['new']) as: 'create_book' call_with: {} creates: <...IBook...> params: {'author': <...TextLine...>, 'base_price': <...Float...>, 'title': <...TextLine...>} return_type: type: 'factory' We did specify the return type for the 'searchBookTitles' method: it returns a collection. >>> print_export_tag(IBookSetOnSteroids['searchBookTitles']) as: 'searchBookTitles' call_with: {} params: {'text': <...TextLine...>} return_type: type: 'read_operation' The 'bestMatch' method returns an entry. >>> print_export_tag(IBookSetOnSteroids['bestMatch']) as: 'bestMatch' call_with: {} params: {'text': <...TextLine...>} return_type: type: 'read_operation' The 'checkout' method doesn't return anything. >>> print_export_tag(IBookOnSteroids['checkout']) as: 'checkout' call_with: {'kind': 'normal', 'who': } params: {} return_type: None type: 'write_operation' Parameters that are not renamed are exported under the same name: >>> for name, param in sorted(IBookSetOnSteroids['new'].getTaggedValue( ... 'lazr.restful.exported')['params'].items()): ... print "%s: %s" % (name, param.__name__) author: author base_price: price title: title It is possible to use @operation_parameters with @export_factory_operation to specify parameters that are not part of the schema. >>> class ComplexBookFactory(Interface): ... export_as_webservice_entry() ... ... @operation_parameters(collection=TextLine()) ... @export_factory_operation(IBook, ['author', 'title']) ... def create_book(author, title, collection): ... """Create a book in a collection.""" >>> print_export_tag(ComplexBookFactory['create_book']) as: 'create_book' call_with: {} creates: <...IBook...> params: {'author': <...TextLine...>, 'collection': <...TextLine...>, 'title': <...TextLine...>} return_type: type: 'factory' Default values and required parameters -------------------------------------- Parameters default and required attributes are set automatically based on the method signature. >>> class ComplexParameterDefinition(Interface): ... export_as_webservice_entry() ... ... @operation_parameters( ... required1=TextLine(), ... required2=TextLine(default=u'Not required'), ... optional1=TextLine(required=True), ... optional2=TextLine(), ... ) ... @export_read_operation() ... def a_method(required1, required2, optional1='Default', ... optional2='Default2'): ... """Method demonstrating how required/default are set.""" In this example, the required1 definition will be automatically considered required. >>> param_defs = ComplexParameterDefinition['a_method'].getTaggedValue( ... 'lazr.restful.exported')['params'] >>> param_defs['required1'].required True But required2 will not be considered required because a default value was provided. >>> param_defs['required2'].required False NOTE: It's not possible to make an optional parameter required on the webservice. In the above case, required=True was specified on "optional1", but that will be overridden. The reason for that is that by default required is always True, so it's not possible to distinguish between the case where required was set to True, and required is True because it's the default value. >>> param_defs['optional1'].required False >>> param_defs['optional1'].default u'Default' And optional2 was exported with the same default than the method: >>> param_defs['optional2'].required False >>> param_defs['optional2'].default u'Default2' Error handling -------------- All these decorators can only be used from within an interface definition: >>> @export_operation_as('test') ... def a_method1(self): pass Traceback (most recent call last): ... TypeError: export_operation_as() can only be used from within an interface definition. >>> @export_read_operation() ... def another_method(self): pass Traceback (most recent call last): ... TypeError: export_read_operation() can only be used from within an interface definition. An error is also reported if not enough parameters are defined as exported: >>> class MissingParameter(Interface): ... export_as_webservice_entry() ... @call_with(param1=1) ... @operation_parameters( ... param2=TextLine()) ... @export_read_operation() ... def a_method(param1, param2, param3, param4): pass Traceback (most recent call last): ... TypeError: method "a_method" is missing definitions for parameter(s) exported in version "(earliest version)": param3, param4 Defining a parameter not available on the method also results in an error: >>> class BadParameter(Interface): ... export_as_webservice_entry() ... @operation_parameters( ... no_such_param=TextLine()) ... @export_read_operation() ... def a_method(): pass Traceback (most recent call last): ... TypeError: method "a_method" doesn't have the following exported parameters in version "(earliest version)": no_such_param. But that's not a problem if the exported method actually takes arbitrary keyword parameters: >>> class AnyParameter(Interface): ... export_as_webservice_entry() ... @operation_parameters( ... param1=TextLine()) ... @export_read_operation() ... def a_method(**kwargs): pass When using @export_factory_operation, TypeError will also be raised if one of the field doesn't exists in the schema: >>> class MissingParameter(Interface): ... export_as_webservice_entry() ... @export_factory_operation(IBook, ['no_such_field']) ... def a_method(): pass Traceback (most recent call last): ... TypeError: IBook doesn't define 'no_such_field'. Or if the field name doesn't represent a field: >>> class NotAField(Interface): ... export_as_webservice_entry() ... @export_factory_operation(IBookOnSteroids, ['checkout']) ... def a_method(): pass Traceback (most recent call last): ... TypeError: IBookOnSteroids.checkout doesn't provide IField. Or if @operation_parameters redefine a field specified in the factory: >>> class Redefinition(Interface): ... export_as_webservice_entry() ... @operation_parameters(title=TextLine()) ... @export_factory_operation(IBookOnSteroids, ['title']) ... def create_book(title): pass Traceback (most recent call last): ... TypeError: 'title' parameter is already defined. All parameters definitions must be schema fields: >>> class BadParameterDefinition(Interface): ... export_as_webservice_entry() ... @operation_parameters(a_param=object()) ... @export_read_operation() ... def a_method(): pass Traceback (most recent call last): ... TypeError: export definition of "a_param" in method "a_method" must provide IField: Renaming a parameter that wasn't defined results in an error: >>> class NonExistentParameter(Interface): ... @rename_parameters_as(param1='name', param2='name2') ... @operation_parameters(param1=TextLine()) ... @export_read_operation() ... def a_method(param1): pass Traceback (most recent call last): ... TypeError: rename_parameters_as(): no "param2" parameter is exported. Trying to use @rename_parameters_as without exporting the method also results in an error. >>> class MissingMethodExport(Interface): ... @rename_parameters_as(a_param='name') ... def a_method(): pass Traceback (most recent call last): ... TypeError: "a_method" isn't exported on the webservice. The decorators @operation_returns_entry and @operation_returns_collection_of will only accept an IInterface as argument. >>> class ReturnOtherThanInterface(Interface): ... export_as_webservice_entry() ... @operation_returns_entry("not-an-interface") ... @export_read_operation() ... def a_method(**kwargs): pass Traceback (most recent call last): ... TypeError: Entry type not-an-interface does not provide IInterface. >>> class ReturnOtherThanInterface(Interface): ... export_as_webservice_entry() ... @operation_returns_collection_of("not-an-interface") ... @export_read_operation() ... def a_method(**kwargs): pass Traceback (most recent call last): ... TypeError: Collection value type not-an-interface does not provide IInterface. Exporting exceptions ==================== When a method raises an exception, the default is to report the error as '500 Internal Server Error'. In many cases, that's not the case and one of the 4XX error would be better. For Python 2.6 or higher, or for annotating an already existing exception, you can use error_status. In Python 2.6, you would spell this as follows:: from lazr.restful.declarations import error_status @error_status(400) class InvalidDemo(Exception): """An example exception""" In earlier Pythons it is still usable. >>> from lazr.restful.declarations import error_status >>> class InvalidDemo(Exception): ... """An example exception""" ... >>> ignore = error_status(400)(InvalidDemo) The function sets the __lazr_webservice_error__ attribute on the exception, which will be used by the view handling the exception. >>> InvalidDemo.__lazr_webservice_error__ 400 The function raises an exception if it is used for something that already has a conflicting __lazr_webservice_error__ attribute. >>> ignore = error_status(400)(InvalidDemo) # OK >>> InvalidDemo.__lazr_webservice_error__ 400 >>> error_status(401)(InvalidDemo) # Not OK Traceback (most recent call last): ... ValueError: ('Exception already has an error status', 400) It also raises an exception if it is used on something that is not an Exception. >>> error_status(400)(object) Traceback (most recent call last): ... TypeError: Annotated value must be an exception class. Exceptions can be also be tagged internally to the class definition with the webservice_error() declaration to state the proper HTTP status code to use for that kind of error. >>> from lazr.restful.declarations import webservice_error >>> class InvalidEmail(Exception): ... """Error happening when the email is not valid.""" ... webservice_error(400) As with error_status, the directive sets the __lazr_webservice_error__ attribute on the exception, which will be used by the view handling the exception. >>> InvalidEmail.__lazr_webservice_error__ 400 Using that directive outside of a class declaration is an error: >>> webservice_error(402) Traceback (most recent call last): ... TypeError: webservice_error() can only be used from within an exception definition. Export and inheritance ====================== A child interface inherits the markup of its ancestors, even when the base interface isn't exported itself. >>> class IHasName(Interface): ... name = exported(TextLine()) ... ... @operation_parameters(new_name=TextLine()) ... @export_write_operation() ... def rename(new_name): ... """Rename the object.""" >>> class IUser(IHasName): ... export_as_webservice_entry() ... ... nickname = exported(TextLine()) ... ... @operation_parameters(to=Object(IHasName), msg=TextLine()) ... @export_write_operation() ... def talk_to(to, msg): ... """Sends a message to another named object.""" >>> for name in sorted(IUser.names(True)): ... print '== %s ==' % name ... print_export_tag(IUser[name]) == name == as: 'name' original_name: 'name' type: 'field' == nickname == as: 'nickname' original_name: 'nickname' type: 'field' == rename == as: 'rename' call_with: {} params: {'new_name': <...TextLine...>} return_type: None type: 'write_operation' == talk_to == as: 'talk_to' call_with: {} params: {'msg': <...TextLine...>, 'to': <...Object...>} return_type: None type: 'write_operation' Contributing interfaces ======================= It is possible to mix multiple interfaces into a single exported entry. This is specially useful when you want to export fields/methods that belong to adapters for your entry's class instead of to the class itself. For example, we can have an IDeveloper interface contributing to IUser. >>> class IDeveloper(Interface): ... export_as_webservice_entry(contributes_to=[IUser]) ... ... programming_languages = exported(List( ... title=u'Programming Languages spoken by this developer')) This will cause all the fields/methods of IDeveloper to be exported as part of the IBook entry instead of exporting a new entry for IDeveloper. For this to work you just need to ensure an object of the exported entry type can be adapted into the contributing interface (e.g. an IUser object can be adapted into IDeveloper). >>> print_export_tag(IDeveloper) _as_of_was_used: False contributes_to: [] exported: True plural_name: 'developers' publish_web_link: True singular_name: 'developer' type: 'entry' To learn how this works, see ContributingInterfacesTestCase in tests/test_declarations.py. Generating the webservice ========================= Setup ----- Before we can continue, we must define a web service configuration object. Each web service needs to have one of these registered utilities providing basic information about the web service. This one is just a dummy. >>> from lazr.restful.testing.helpers import TestWebServiceConfiguration >>> from zope.component import provideUtility >>> from lazr.restful.interfaces import IWebServiceConfiguration >>> class MyWebServiceConfiguration(TestWebServiceConfiguration): ... active_versions = ["beta", "1.0", "2.0", "3.0"] ... last_version_with_mutator_named_operations = "1.0" ... first_version_with_total_size_link = "2.0" ... code_revision = "1.0b" ... default_batch_size = 50 >>> provideUtility(MyWebServiceConfiguration(), IWebServiceConfiguration) We must also set up the ability to create versioned requests. This web service has four versions: 'beta', '1.0', '2.0', and '3.0'. We'll need a marker interface for every version, registered as a utility under the name of the version. Each version interface subclasses the previous version's interface. This lets a request use a resource definition for the previous version if it hasn't changed since then. >>> from zope.component import getSiteManager >>> from lazr.restful.interfaces import IWebServiceVersion >>> class ITestServiceRequestBeta(IWebServiceVersion): ... pass >>> class ITestServiceRequest10(ITestServiceRequestBeta): ... pass >>> class ITestServiceRequest20(ITestServiceRequest10): ... pass >>> class ITestServiceRequest30(ITestServiceRequest20): ... pass >>> sm = getSiteManager() >>> for marker, name in [(ITestServiceRequestBeta, 'beta'), ... (ITestServiceRequest10, '1.0'), ... (ITestServiceRequest20, '2.0'), ... (ITestServiceRequest30, '3.0')]: ... sm.registerUtility(marker, IWebServiceVersion, name=name) >>> from lazr.restful.testing.webservice import FakeRequest >>> request = FakeRequest(version='beta') Entry ----- The webservice can be generated from tagged interfaces. For every version in the web service, generate_entry_interfaces() will create a subinterface of IEntry containing a copy of those IField definitions from the original interface that were tagged for export. >>> from lazr.restful.declarations import generate_entry_interfaces >>> [[version, entry_interface]] = generate_entry_interfaces( ... IBook, [], 'beta') The created interface is named with 'Entry' appended to the original name, and is in the same module >>> entry_interface.__module__ '__builtin__' >>> entry_interface.__name__ 'IBookEntry_beta' The original interface docstring is copied over to the new interface: >>> entry_interface.__doc__ 'A simple book data model.' It extends IEntry. >>> from lazr.restful.interfaces import IEntry >>> entry_interface.extends(IEntry) True All fields tagged were copied to the new interface: >>> def dump_entry_interface(entry_interface): ... for name, field in sorted( ... entry_interface.namesAndDescriptions()): ... print "%s: %s" % (name, field.__class__.__name__) >>> dump_entry_interface(entry_interface) author: TextLine price: Float title: TextLine The field __name__ attribute contains the exported name: >>> print entry_interface['price'].__name__ price Associated with the interface through tags are automatically-generated 'singular' and 'plural' names for the interface. >>> from lazr.restful.interfaces import LAZR_WEBSERVICE_NAME >>> tags = entry_interface.queryTaggedValue(LAZR_WEBSERVICE_NAME) >>> print tags['singular'] book >>> print tags['plural'] books It's an error to use generate_entry_interfaces() on an interface that wasn't marked for export: >>> class SimpleNotExported(Interface): ... """Interface not exported.""" >>> generate_entry_interfaces(SimpleNotExported, [], 'beta') Traceback (most recent call last): ... TypeError: 'SimpleNotExported' isn't tagged for webservice export. The interface must also be exported as an entry: >>> generate_entry_interfaces(IBookSet, [], 'beta') Traceback (most recent call last): ... TypeError: 'IBookSet' isn't exported as an entry. The adapter can be generated using the generate_entry_adapters() function, which takes the tagged content interface and the IEntry subinterface as parameters. >>> from lazr.restful.declarations import generate_entry_adapters >>> entry_adapter_factories = generate_entry_adapters( ... IBook, [], [('beta', entry_interface)]) generate_entry_adapters() generates an adapter for every version of the web service (see a test for it below, in "Versioned Services"). This web service only has one version, so there's only one adapter. >>> [factory] = entry_adapter_factories >>> print factory.version beta >>> entry_adapter_factory = factory.object The generated adapter provides the webservice interface: >>> entry_interface.implementedBy(entry_adapter_factory) True The resulting class is named based on the interface: >>> print entry_adapter_factory.__name__ BookEntry_betaAdapter Its docstring is also copied over from the original interface: >>> entry_adapter_factory.__doc__ 'A simple book data model.' The resulting adapter has its schema attribute set to the exported interface, and proxies all attributes to the underlying object. >>> from zope.interface.verify import verifyObject >>> from zope.interface import implements >>> class Book(object): ... """Simple IBook implementation.""" ... implements(IBook) ... def __init__(self, author, title, base_price, ... inventory_number): ... self.author = author ... self.title = title ... self.base_price = base_price ... self.inventory_number = inventory_number Now we can turn a Book object into something that implements IBookEntry. >>> entry_adapter = entry_adapter_factory( ... Book(u'Aldous Huxley', u'Island', 10.0, '12345'), ... request) >>> entry_adapter.schema is entry_interface True >>> verifyObject(entry_interface, entry_adapter) True >>> entry_adapter.author u'Aldous Huxley' >>> entry_adapter.price 10.0 >>> entry_adapter.title u'Island' It's an error to call this function on an interface not exported on the web service: >>> generate_entry_adapters( ... SimpleNotExported, [], ('beta', entry_interface)) Traceback (most recent call last): ... TypeError: 'SimpleNotExported' isn't tagged for webservice export. Or exported as a collection: >>> generate_entry_adapters(IBookSet, [], ('beta', entry_interface)) Traceback (most recent call last): ... TypeError: 'IBookSet' isn't exported as an entry. Collection ---------- An ICollection adapter for content interface tagged as being exported as collections on the webservice can be generated by using the generate_collection_adapter() function. >>> from lazr.restful.interfaces import ICollection >>> from lazr.restful.declarations import ( ... generate_collection_adapter) >>> collection_adapter_factory = generate_collection_adapter(IBookSet) >>> ICollection.implementedBy(collection_adapter_factory) True The find() method will return the result of calling the method tagged with the @collection_default_content decorator. >>> class BookSet(object): ... """Simple IBookSet implementation.""" ... implements(IBookSet) ... ... def __init__(self, books=()): ... self.books = books ... ... def getAllBooks(self): ... return self.books >>> collection_adapter = collection_adapter_factory( ... BookSet(['A book', 'Another book']), request) >>> verifyObject(ICollection, collection_adapter) True >>> collection_adapter.find() ['A book', 'Another book'] The adapter's docstring is taken from the original interface. >>> collection_adapter.__doc__ 'Set of all the books in the system.' If parameters were specified, they'll be passed in to the method by find(). The REQUEST_USER marker value will be replaced by the logged in user. >>> class CheckedOutBookSet(object): ... """Simple ICheckedOutBookSet implementation.""" ... implements(ICheckedOutBookSet) ... ... def getByTitle(self, title, user): ... print '%s searched for checked out book matching "%s".' % ( ... user, title) >>> checked_out_adapter = generate_collection_adapter( ... ICheckedOutBookSet)(CheckedOutBookSet(), request) >>> checked_out_adapter.find() A user searched for checked out book matching "". It's an error to call this function on an interface not exported on the web service: >>> generate_collection_adapter(SimpleNotExported) Traceback (most recent call last): ... TypeError: 'SimpleNotExported' isn't tagged for webservice export. Or exported as an entry. >>> generate_collection_adapter(IBook) Traceback (most recent call last): ... TypeError: 'IBook' isn't exported as a collection. Methods ------- IResourceOperation adapters can be generated for exported methods by using the generate_operation_adapter() function. Using it on a method exported as a read operation will generate an IResourceGETOperation. >>> from lazr.restful.interfaces import IResourceGETOperation >>> from lazr.restful.declarations import ( ... generate_operation_adapter) >>> read_method_adapter_factory = generate_operation_adapter( ... IBookSetOnSteroids['searchBookTitles']) >>> IResourceGETOperation.implementedBy(read_method_adapter_factory) True The defined adapter is named GET___beta and uses the ResourceOperation base class. The "_beta" indicates that the adapter will be used in the earliest version of the web service, and any subsequent versions, until a newer implementation supercedes it. >>> from lazr.restful import ResourceOperation >>> read_method_adapter_factory.__name__ 'GET_IBookSetOnSteroids_searchBookTitles_beta' >>> issubclass(read_method_adapter_factory, ResourceOperation) True The adapter's docstring is taken from the decorated method docstring. >>> read_method_adapter_factory.__doc__ "Return list of books whose titles contain 'text'." The adapter's params attribute contains the specification of the parameters accepted by the operation. >>> from operator import attrgetter >>> def print_params(params): ... """Print the name and type of the defined parameters.""" ... for param in sorted(params, key=attrgetter('__name__')): ... print "%s: %s" % (param.__name__, param.__class__.__name__) >>> print_params(read_method_adapter_factory.params) text: TextLine The call() method calls the underlying method and returns its result. >>> class BookSetOnSteroids(BookSet): ... implements(IBookSetOnSteroids) ... ... result = None ... ... def searchBookTitles(self, text): ... return self.result ... ... def new(self, author, base_price, title): ... return Book(author, title, base_price, "unknown") Now we can create a fake request that invokes the named operation. >>> request = FakeRequest(version='beta') >>> read_method_adapter = read_method_adapter_factory( ... BookSetOnSteroids(), request) >>> verifyObject(IResourceGETOperation, read_method_adapter) True >>> read_method_adapter.send_modification_event False >>> read_method_adapter.context.result = [] Since the method is declared as returning a list of objects, the return value is a dictionary containing a batched list. >>> print read_method_adapter.call(text='') {"total_size": 0, "start": 0, "entries": []} Methods exported as a write operations generates an adapter providing IResourcePOSTOperation. >>> from lazr.restful.interfaces import IResourcePOSTOperation >>> write_method_adapter_factory = generate_operation_adapter( ... IBookOnSteroids['checkout']) >>> IResourcePOSTOperation.implementedBy(write_method_adapter_factory) True The generated adapter class name is POST___beta. >>> print write_method_adapter_factory.__name__ POST_IBookOnSteroids_checkout_beta The adapter's params property also contains the available parameters (for which there are none in this case.) >>> print_params(write_method_adapter_factory.params) >>> class BookOnSteroids(Book): ... implements(IBookOnSteroids) ... def checkout(self, who, kind): ... print "%s did a %s check out of '%s'." % ( ... who, kind, self.title) >>> write_method_adapter = write_method_adapter_factory( ... BookOnSteroids( ... 'Aldous Huxley', 'The Doors of Perception', 8, 'unknown'), ... FakeRequest()) >>> verifyObject(IResourcePOSTOperation, write_method_adapter) True >>> write_method_adapter.send_modification_event True The call() method invokes the exported method on the context object. In this case, the underlying parameters were set using call_with. The REQUEST_USER specification is replaced by the current user. >>> write_method_adapter.call() A user did a normal check out of 'The Doors of Perception'. 'null' Methods exported as a factory also generate an adapter providing IResourcePOSTOperation. >>> factory_method_adapter_factory = generate_operation_adapter( ... IBookSetOnSteroids['new']) >>> IResourcePOSTOperation.implementedBy(factory_method_adapter_factory) True >>> factory_method_adapter = factory_method_adapter_factory( ... BookSetOnSteroids(), FakeRequest()) >>> verifyObject(IResourcePOSTOperation, factory_method_adapter) True >>> factory_method_adapter.send_modification_event False The generated adapter class name is also POST___beta. >>> print write_method_adapter_factory.__name__ POST_IBookOnSteroids_checkout_beta The adapter's params property also contains the available parameters. >>> print_params(factory_method_adapter_factory.params) author: TextLine price: Float title: TextLine Factory operations set the 201 Created status code and return the URL to the newly created object. The body of the response will be empty. (For the URL generation to work, we need to register an IAbsoluteURL adapter and set the request as the current interaction.) >>> from urllib import quote >>> from zope.component import provideAdapter >>> from zope.traversing.browser.interfaces import IAbsoluteURL >>> from zope.publisher.interfaces.http import IHTTPApplicationRequest >>> class BookAbsoluteURL(object): ... """Returns a believable absolute URL for a book.""" ... implements(IAbsoluteURL) ... ... def __init__(self, context, request): ... self.context = context ... self.request = request ... ... def __str__(self): ... return ("http://api.example.org/books/" + ... quote(self.context.title)) ... ... __call__ = __str__ >>> provideAdapter(BookAbsoluteURL, ... [IBook, IHTTPApplicationRequest], IAbsoluteURL) >>> from zope.security.management import endInteraction, newInteraction >>> endInteraction() >>> newInteraction(factory_method_adapter.request) >>> factory_method_adapter.call( ... author='Aldous Huxley', title="Eyeless in Gaza", price=10.5) u'' >>> response = factory_method_adapter.request.response >>> response.status 201 >>> print response.headers['Location'] http://api.example.org/books/Eyeless%20in%20Gaza The generate_operation_adapter() function can only be called on an IMethod marked for export: >>> generate_operation_adapter(IBook) Traceback (most recent call last): ... TypeError: <...IBook...> doesn't provide IMethod. >>> generate_operation_adapter(IBookSet['getAllBooks']) Traceback (most recent call last): ... TypeError: 'getAllBooks' isn't tagged for webservice export. Methods exported as a destructor operations generates an adapter providing IResourceDELETEOperation. >>> from lazr.restful.interfaces import IResourceDELETEOperation >>> destructor_method_adapter_factory = generate_operation_adapter( ... IBookOnSteroids['destroy']) >>> IResourceDELETEOperation.implementedBy( ... destructor_method_adapter_factory) True The generated adapter class name is DELETE___beta. >>> print destructor_method_adapter_factory.__name__ DELETE_IBookOnSteroids_destroy_beta Destructor ---------- A method can be designated as a destructor for the entry. Here, the destroy() method is designated as the destructor for IHasText. >>> class IHasText(Interface): ... export_as_webservice_entry() ... text = exported(TextLine(readonly=True)) ... ... @export_destructor_operation() ... def destroy(): ... pass >>> ignored = generate_entry_interfaces(IHasText, [], 'beta') A destructor method cannot take any free arguments. >>> class IHasText(Interface): ... export_as_webservice_entry() ... text = exported(TextLine(readonly=True)) ... ... @export_destructor_operation() ... @operation_parameters(argument=TextLine()) ... def destroy(argument): ... pass Traceback (most recent call last): ... TypeError: A destructor method must take no non-fixed arguments. In version (earliest version), the "destroy" method takes 1: "argument". >>> class IHasText(Interface): ... export_as_webservice_entry() ... text = exported(TextLine(readonly=True)) ... ... @export_destructor_operation() ... @call_with(argument="fixed value") ... def destroy(argument): ... pass >>> ignored = generate_entry_interfaces(IHasText, [], 'beta') An entry cannot have more than one destructor. >>> from lazr.restful.declarations import export_destructor_operation >>> class IHasText(Interface): ... export_as_webservice_entry() ... text = exported(TextLine(readonly=True)) ... ... @export_destructor_operation() ... def destroy(): ... pass ... ... @export_destructor_operation() ... def destroy2(): ... pass >>> generate_entry_interfaces(IHasText, [], 'beta') Traceback (most recent call last): ... TypeError: An entry can only have one destructor method for version (earliest version); destroy and destroy2 make two. Mutators -------- A method can be designated as a mutator for some field. Here, the set_text() method is designated as the mutator for the 'text' field. >>> from lazr.restful.declarations import mutator_for >>> class IHasText(Interface): ... export_as_webservice_entry() ... text = exported(TextLine(readonly=True)) ... ... @mutator_for(text) ... @operation_parameters(text=TextLine()) ... @export_write_operation() ... def set_text(text): ... pass The implementation of set_text() applies a standardized transform to the incoming text. >>> from zope.interface import implements >>> class HasText(object): ... implements(IHasText); ... ... def __init__(self): ... self.text = '' ... ... def set_text(self, text): ... self.text = "!" + text + "!" Generate the entry interface and adapter... >>> [hastext_entry_interface] = generate_entry_interfaces( ... IHasText, [], 'beta') >>> [hastext_entry_adapter_factory] = generate_entry_adapters( ... IHasText, [], [hastext_entry_interface]) >>> obj = HasText() >>> print hastext_entry_adapter_factory.version beta >>> hastext_entry_adapter = hastext_entry_adapter_factory.object( ... obj, request) ...and you'll have an object that invokes set_text() when you set the 'text' attribute. >>> hastext_entry_adapter.text '' >>> hastext_entry_adapter.text = 'foo' >>> hastext_entry_adapter.text '!foo!' The original interface defines 'text' as read-only, but the generated interface does not. >>> hastext_entry_interface.object.get('text').readonly False It's not necessary to expose the mutator method as a write operation. >>> class IHasText(Interface): ... export_as_webservice_entry() ... text = exported(TextLine(readonly=True)) ... ... @mutator_for(text) ... def set_text(text): ... pass A mutator method must take only one argument: the new value for the field. Taking no arguments is obviously an error. >>> class ZeroArgumentMutator(Interface): ... export_as_webservice_entry() ... value = exported(TextLine(readonly=True)) ... ... @mutator_for(value) ... def set_value(): ... pass Traceback (most recent call last): ... TypeError: A mutator method must take one and only one non-fixed argument. set_value takes 0. Taking more than one argument is also an error... >>> class TwoArgumentMutator(Interface): ... export_as_webservice_entry() ... value = exported(TextLine(readonly=True)) ... ... @mutator_for(value) ... def set_value(arg1, arg2): ... pass Traceback (most recent call last): ... TypeError: A mutator method must take one and only one non-fixed argument. set_value takes 2. ...unless all but one of the arguments are spoken for by a call_with() annotation. This definition does not result in a TypeError. >>> class OneFixedArgumentMutator(Interface): ... export_as_webservice_entry() ... value = exported(TextLine(readonly=True)) ... ... @mutator_for(value) ... @call_with(arg1=REQUEST_USER, arg3='fixed') ... @operation_parameters(arg2=TextLine()) ... @export_write_operation() ... def set_value(arg1, arg2, arg3): ... pass A field can only have a mutator if it's read-only (not settable directly). >>> class WritableMutator(Interface): ... export_as_webservice_entry() ... value = exported(TextLine(readonly=False)) ... ... @mutator_for(value) ... @export_write_operation() ... def set_value(new_value): ... pass Traceback (most recent call last): ... TypeError: Only a read-only field can have a mutator method. A field can only have one mutator. >>> class FieldWithTwoMutators(Interface): ... export_as_webservice_entry() ... value = exported(TextLine(readonly=True)) ... ... @mutator_for(value) ... @export_write_operation() ... @operation_parameters(new_value=TextLine()) ... def set_value(new_value): ... pass ... ... @mutator_for(value) ... @export_write_operation() ... @operation_parameters(new_value=TextLine()) ... def set_value_2(new_value): ... pass Traceback (most recent call last): ... TypeError: A field can only have one mutator method for version (earliest version); set_value_2 makes two. Read-only fields ---------------- A read-write field can be published as read-only in the web service. >>> class ExternallyReadOnlyField(Interface): ... export_as_webservice_entry() ... value = exported(TextLine(readonly=False), readonly=True) >>> interfaces = generate_entry_interfaces( ... ExternallyReadOnlyField, [], 'beta') >>> [(beta, beta_interface)] = interfaces >>> ExternallyReadOnlyField['value'].readonly False >>> beta_interface['value'].readonly True A read-only field cannot be published as read-write in the web service just by declaring it read-write. You have to provide a mutator. >>> class InternallyReadOnlyField(Interface): ... export_as_webservice_entry() ... value = exported(TextLine(readonly=True), readonly=False) >>> generate_entry_interfaces(InternallyReadOnlyField, [], 'beta') Traceback (most recent call last): ... TypeError: InternallyReadOnlyField.value is defined as a read-only field, so you can't just declare it to be read-write in the web service: you must define a mutator. Caching ------- It is possible to cache a server response in the browser cache using the @cache_for decorator: >>> from lazr.restful.declarations import cache_for >>> >>> class ICachedBookSet(IBookSet): ... """IBookSet supporting caching.""" ... export_as_webservice_collection(IBook) ... ... @collection_default_content() ... @export_read_operation() ... @cache_for(60) ... def getAllBooks(): ... """Return all books.""" ... ... >>> class CachedBookSet(BookSet): ... """Simple ICachedBookSet implementation.""" ... implements(ICachedBookSet) ... ... def getAllBooks(self): ... return self.books >>> read_method_adapter_factory = generate_operation_adapter( ... ICachedBookSet['getAllBooks']) >>> read_method_adapter = read_method_adapter_factory( ... CachedBookSet(['Cool book']), request) >>> print read_method_adapter.call() ['Cool book'] >>> print request.response.headers {'Content-Type': 'application/json', 'Cache-control': 'max-age=60'} Only positive int or long objects should be passed to @cache_for: >>> class ICachedBookSet(IBookSet): ... @cache_for('60') ... def getAllBooks(): ... """Return all books.""" ... Traceback (most recent call last): ... TypeError: Caching duration should be int or long, not str >>> >>> class ICachedBookSet(IBookSet): ... @cache_for(-15) ... def getAllBooks(): ... """Return all books.""" ... Traceback (most recent call last): ... ValueError: Caching duration should be a positive number: -15 Versioned services ================== Different versions of the webservice can publish the same data model object in totally different ways. Collections ----------- A collection's contents are determined by calling one of its methods. Which method is called, and with which arguments, can vary across versions. >>> from lazr.restful.declarations import generate_operation_adapter >>> class IMultiVersionCollection(Interface): ... export_as_webservice_collection(Interface) ... ... @collection_default_content('2.0') ... def content_20(): ... """The content method for version 2.0.""" ... ... @collection_default_content('1.0', argument='1.0 value') ... @collection_default_content(argument='pre-1.0 value') ... def content_pre_20(argument): ... """The content method for versions before 2.0""" Here's a simple implementation of IMultiVersionCollection. It'll illustrate how the different versions of the web service invoke different methods to find the collection contents. >>> class MultiVersionCollection(): ... """Simple IMultiVersionCollection implementation.""" ... implements(IMultiVersionCollection) ... ... def content_20(self): ... return ["contents", "for", "version", "2.0"] ... ... def content_pre_20(self, argument): ... return ["you", "passed", "in", argument] By passing a version string into generate_collection_adapter(), we can get different adapter classes for different versions of the web service. We'll be invoking each version against the same data model object. Here it is: >>> data_object = MultiVersionCollection() Passing in None to generate_collection_adapter gets us the collection as it appears in the earliest version of the web service. The content_pre_20() method is invoked with the 'argument' parameter equal to "pre-1.0 value". >>> interface = IMultiVersionCollection >>> adapter_earliest_factory = generate_collection_adapter( ... interface, None) >>> print adapter_earliest_factory.__name__ MultiVersionCollectionCollectionAdapter___Earliest >>> collection_earliest = adapter_earliest_factory(data_object, request) >>> print collection_earliest.find() ['you', 'passed', 'in', 'pre-1.0 value'] Passing in '1.0' gets us the collection as it appears in the 1.0 version of the web service. Note that the argument passed in to content_pre_20() is different, and so the returned contents are slightly different. >>> adapter_10_factory = generate_collection_adapter(interface, '1.0') >>> print adapter_10_factory.__name__ MultiVersionCollectionCollectionAdapter_1_0 >>> collection_10 = adapter_10_factory(data_object, request) >>> print collection_10.find() ['you', 'passed', 'in', '1.0 value'] Passing in '2.0' gets us a collection with totally different contents, because a totally different method is being called. >>> adapter_20_factory = generate_collection_adapter(interface, '2.0') >>> print adapter_20_factory.__name__ MultiVersionCollectionCollectionAdapter_2_0 >>> collection_20 = adapter_20_factory(data_object, request) >>> print collection_20.find() ['contents', 'for', 'version', '2.0'] An error occurs when we try to generate an adapter for a version that's not mentioned in the annotations. >>> generate_collection_adapter(interface, 'NoSuchVersion') Traceback (most recent call last): ... AssertionError: 'IMultiVersionCollection' isn't tagged for export to web service version 'NoSuchVersion'. Entries ------- The singular and plural name of an entry never changes between versions, because the names are a property of the original interface. But the published fields can change or be renamed from version to version. Here's a data model interface defining four fields which are published in some versions and not others, and which may have different names in different versions. 1. A TextLine called 'field', published in all versions. 2. A Text called 'unchanging_name', published in all versions. 3. A TextLine called 'field3' in the earliest version, removed in '1.0', published as '20_name' in '2.0', and renamed to '30_name' in '3.0'. 4. A Float not published in the earliest version, introduced as 'new_in_10' in '1.0', and renamed to 'renamed_in_30' in '3.0'. >>> from zope.schema import Text, Float >>> class IMultiVersionEntry(Interface): ... export_as_webservice_entry() ... ... field = exported(TextLine()) ... ... field2 = exported(Text(), exported_as='unchanging_name') ... ... field3 = exported(TextLine(), ... ('3.0', dict(exported_as='30_name')), ... ('2.0', dict(exported=True, exported_as='20_name')), ... ('1.0', dict(exported=False))) ... ... field4 = exported(Float(), ... ('3.0', dict(exported_as='renamed_in_30')), ... ('1.0', dict(exported=True, exported_as='new_in_10')), ... exported=False) Let's take a look at the entry interfaces generated for each version. >>> versions = ['beta', '1.0', '2.0', '3.0'] >>> versions_and_interfaces = generate_entry_interfaces( ... IMultiVersionEntry, [], *versions) >>> for version, interface in versions_and_interfaces: ... print version beta 1.0 2.0 3.0 >>> interface_beta, interface_10, interface_20, interface_30 = ( ... [interface for version, interface in versions_and_interfaces]) >>> dump_entry_interface(interface_beta) field: TextLine field3: TextLine unchanging_name: Text >>> dump_entry_interface(interface_10) field: TextLine new_in_10: Float unchanging_name: Text >>> dump_entry_interface(interface_20) 20_name: TextLine field: TextLine new_in_10: Float unchanging_name: Text >>> dump_entry_interface(interface_30) 30_name: TextLine field: TextLine renamed_in_30: Float unchanging_name: Text Here's a simple implementation of the entry. >>> class MultiVersionEntry(): ... """Simple IMultiVersionEntry implementation.""" ... implements(IMultiVersionEntry) ... field = "field value" ... field2 = "unchanging value" ... field3 = "field 3 value" ... field4 = 1.0 When we call generate_entry_adapters(), we'll get an adapter classes for each version of the web service. We'll be invoking each version against the same data model object. Here it is: >>> data_object = MultiVersionEntry() generate_entry_adapters() generates adaptor factories that mediate between this data model object and the many-faceted interface classes. >>> entry_adapters = generate_entry_adapters( ... IMultiVersionEntry, [], versions_and_interfaces) >>> for version, adapter in entry_adapters: ... print version beta 1.0 2.0 3.0 >>> adapter_beta, adapter_10, adapter_20, adapter_30 = ( ... [interface for version, interface in entry_adapters]) Here's the 'beta' version of the object: >>> object_beta = adapter_beta(data_object, request) >>> print object_beta.field field value >>> print object_beta.field3 field 3 value >>> print object_beta.unchanging_name unchanging value The 'field4' field is not available in the 'beta' version under any name. >>> print object_beta.field4 Traceback (most recent call last): ... AttributeError: 'MultiVersionEntryEntry_betaAdapter' object has no attribute 'field4' >>> print object_beta.new_in_10 Traceback (most recent call last): ... AttributeError: 'MultiVersionEntryEntry_betaAdapter' object has no attribute 'new_in_10' Here's the '1.0' version. 'field3' is gone and the 'field4' field is now available as 'new_in_10'. >>> object_10 = adapter_10(data_object, request) >>> print object_10.field field value >>> print object_10.unchanging_name unchanging value >>> print object_10.new_in_10 1.0 >>> object_10.field3 Traceback (most recent call last): ... AttributeError: 'MultiVersionEntryEntry_1_0Adapter' object has no attribute 'field3' Here's the '2.0' version. 'field3' is back, but now it's called '20_name'. >>> object_20 = adapter_20(data_object, request) >>> print object_20.field field value >>> print object_20.unchanging_name unchanging value >>> print getattr(object_20, '20_name') field 3 value >>> print object_20.new_in_10 1.0 Here's the '3.0' version. 'field3' has been renamed to '30_name' and 'field4' has been renamed to 'renamed_in_30' >>> object_30 = adapter_30(data_object, request) >>> print object_30.field field value >>> print object_30.unchanging_name unchanging value >>> print getattr(object_30, '30_name') field 3 value >>> print object_30.renamed_in_30 1.0 >>> getattr(object_30, '20_name') Traceback (most recent call last): ... AttributeError: 'MultiVersionEntryEntry_3_0Adapter' object has no attribute '20_name' >>> object_30.new_in_10 Traceback (most recent call last): ... AttributeError: 'MultiVersionEntryEntry_3_0Adapter' object has no attribute 'new_in_10' Why the list of version strings? ******************************** Why does generate_entry_interfaces need a list of version strings? This example should make it clear. >>> class IAmbiguousMultiVersion(Interface): ... export_as_webservice_entry() ... ... field1 = exported(TextLine(), ... ('foo', dict(exported_as='foo_name'))) ... field2 = exported(TextLine(), ... ('bar', dict(exported_as='bar_name'))) This web service clearly has two versions, 'foo', and 'bar', but which is the earlier version and which the later? If 'foo' is the earlier version, then 'bar' inherits behavior from 'foo'. >>> foo, bar = generate_entry_interfaces( ... IAmbiguousMultiVersion, [], 'foo', 'bar') >>> print foo.version foo >>> dump_entry_interface(foo.object) field2: TextLine foo_name: TextLine >>> print bar.version bar >>> dump_entry_interface(bar.object) bar_name: TextLine foo_name: TextLine But if 'bar' is the earlier version, then 'foo' inherits behavior from 'bar'. (We need to redefine the class because our previous call to generate_entry_interfaces() modified the class to reflect the original list of versions.) >>> class IAmbiguousMultiVersion(Interface): ... export_as_webservice_entry() ... ... field1 = exported(TextLine(), ... ('foo', dict(exported_as='foo_name'))) ... field2 = exported(TextLine(), ... ('bar', dict(exported_as='bar_name'))) >>> bar, foo = generate_entry_interfaces( ... IAmbiguousMultiVersion, [], 'bar', 'foo') >>> print bar.version bar >>> dump_entry_interface(bar.object) bar_name: TextLine field1: TextLine >>> print foo.version foo >>> dump_entry_interface(foo.object) bar_name: TextLine foo_name: TextLine If a web service definition is complex enough, it's possible to derive an ordered list of all the versions just from looking at the field annotations. But it's not possible in general, and that's why generate_entry_interfaces takes a list of versions. Error handling ************** You'll get an error if you annotate a field with a version that turns out not to be included in the version list. >>> class INonexistentVersionEntry(Interface): ... export_as_webservice_entry() ... ... field = exported(TextLine(), ... ('2.0', dict(exported_as='foo')), ... ('1.0', dict(exported_as='bar'))) >>> generate_entry_interfaces( ... INonexistentVersionEntry, [], 'beta', '1.0') Traceback (most recent call last): ... ValueError: Field "field" in interface "INonexistentVersionEntry": Unrecognized version "2.0". You'll get an error if you put an earlier version's annotations on top of a later version. >>> class IWrongOrderEntry(Interface): ... export_as_webservice_entry() ... ... field = exported(TextLine(), ... ('1.0', dict(exported_as='bar')), ... ('2.0', dict(exported_as='foo'))) >>> generate_entry_interfaces(IWrongOrderEntry, [], '1.0', '2.0') Traceback (most recent call last): ... ValueError: Field "..." in interface "IWrongOrderEntry": Version "1.0" defined after the later version "2.0". You'll get an error if you define annotations twice for the same version. This can happen because you repeated the version annotations: >>> class IDuplicateEntry(Interface): ... export_as_webservice_entry() ... ... field = exported(TextLine(), ... ('beta', dict(exported_as='another_beta_name')), ... ('beta', dict(exported_as='beta_name'))) >>> generate_entry_interfaces(IDuplicateEntry, [], 'beta', '1.0') Traceback (most recent call last): ... ValueError: Field "field" in interface "IDuplicateEntry": Duplicate definitions for version "beta". Or it can happen because you defined the earliest version implicitly using keyword arguments, and then explicitly defined conflicting values. >>> class IDuplicateEntry(Interface): ... export_as_webservice_entry() ... ... field = exported(TextLine(), ... ('beta', dict(exported_as='beta_name')), ... exported_as='earliest_name') >>> generate_entry_interfaces(IDuplicateEntry, [], 'beta', '1.0') Traceback (most recent call last): ... ValueError: Field "field" in interface "IDuplicateEntry": Annotation "as" has conflicting values for the earliest version: "earliest_name" (from keyword arguments) and "beta_name" (defined explicitly). You'll get an error if you include an unrecognized key in a field's version definition. >>> class InvalidMultiVersionEntry(Interface): ... export_as_webservice_entry() ... ... field = exported(TextLine(), ... ('3.0', dict(not_recognized='this will error'))) Traceback (most recent call last): ... ValueError: Unrecognized annotation for version "3.0": "not_recognized" >>> class InvalidMultiVersionEntry(Interface): ... export_as_webservice_entry() ... ... field = exported(TextLine(), not_recognized='this will error') Traceback (most recent call last): ... TypeError: exported got an unexpected keyword argument 'not_recognized' generate_entry_interfaces() generates an interface class for every version, even when an interface does not change at all between versions. (This could be optimized away.) >>> class IUnchangingEntry(Interface): ... export_as_webservice_entry() ... ... field = exported(TextLine(), ... ('3.0', dict(exported_as='30_name')), ... ('beta', dict(exported_as='unchanging_name'))) >>> [interface.version for interface in ... generate_entry_interfaces(IUnchangingEntry, [], *versions)] ['beta', '1.0', '2.0', '3.0'] Named operations ---------------- It's easy to reflect the most common changes between versions: operations and arguments being renamed, changes in fixed values, etc. This method appears differently in three versions of the web service: 2.0, 1.0, and in an unnamed pre-1.0 version. >>> from lazr.restful.declarations import operation_for_version >>> class IMultiVersionMethod(Interface): ... export_as_webservice_entry() ... ... ... @cache_for(300) ... @operation_for_version('3.0') ... ... @call_with(fixed='2.0 value', user=REQUEST_USER) ... @operation_for_version('2.0') ... ... @call_with(fixed='1.0 value', user=REQUEST_USER) ... @export_operation_as('new_name') ... @rename_parameters_as(required="required_argument") ... @operation_for_version('1.0') ... ... @call_with(fixed='pre-1.0 value', user=REQUEST_USER) ... @cache_for(100) ... @operation_parameters( ... required=TextLine(), ... fixed=TextLine() ... ) ... @export_read_operation() ... def a_method(required, fixed, user): ... """Method demonstrating multiversion publication.""" Here's a simple implementation of IMultiVersionMethod. It'll illustrate how the different versions of the web service invoke `a_method` with different hard-coded values for the `fixed` argument. >>> class MultiVersionMethod(): ... """Simple IMultiVersionMethod implementation.""" ... implements(IMultiVersionMethod) ... ... def a_method(self, required, fixed, user): ... return "Required value: %s. Fixed value: %s. User: %s." % ( ... required, fixed, user) By passing a version string into generate_operation_adapter(), we can get different adapter classes for different versions of the web service. We'll be invoking each version against the same data model object. Here it is: >>> data_object = MultiVersionMethod() Passing in None to generate_operation_adapter gets us the method as it appears in the earliest version of the web service. >>> method = IMultiVersionMethod['a_method'] >>> adapter_earliest_factory = generate_operation_adapter(method, None) >>> print adapter_earliest_factory.__name__ GET_IMultiVersionMethod_a_method_beta >>> method_earliest = adapter_earliest_factory(data_object, request) >>> print method_earliest.call(required="foo") Required value: foo. Fixed value: pre-1.0 value. User: A user. Passing in '1.0' or '2.0' gets us the method as it appears in the appropriate version of the web service. Note that the name of the adapter factory changes to reflect the fact that the method's name in 1.0 is 'new_name', not 'a_method'. >>> adapter_10_factory = generate_operation_adapter(method, '1.0') >>> print adapter_10_factory.__name__ GET_IMultiVersionMethod_new_name_1_0 >>> method_10 = adapter_10_factory(data_object, request) >>> print method_10.call(required="bar") Required value: bar. Fixed value: 1.0 value. User: A user. >>> adapter_20_factory = generate_operation_adapter(method, '2.0') >>> print adapter_20_factory.__name__ GET_IMultiVersionMethod_new_name_2_0 >>> method_20 = adapter_20_factory(data_object, request) >>> print method_20.call(required="baz") Required value: baz. Fixed value: 2.0 value. User: A user. >>> adapter_30_factory = generate_operation_adapter(method, '3.0') >>> print adapter_30_factory.__name__ GET_IMultiVersionMethod_new_name_3_0 >>> method_30 = adapter_30_factory(data_object, request) >>> print method_30.call(required="baz") Required value: baz. Fixed value: 2.0 value. User: A user. An error occurs when we try to generate an adapter for a version that's not mentioned in the annotations. >>> generate_operation_adapter(method, 'NoSuchVersion') Traceback (most recent call last): ... AssertionError: 'a_method' isn't tagged for export to web service version 'NoSuchVersion' Now that we've seen how lazr.restful uses the annotations to create classes, let's take a closer look at how the 'a_method' method object is annotated. >>> dictionary = method.getTaggedValue('lazr.restful.exported') The tagged value containing the annotations looks like a dictionary, but it's actually a stack of dictionaries named after the versions. >>> dictionary.dict_names [None, '1.0', '2.0', '3.0'] The dictionary on top of the stack is for the 3.0 version of the web service. This version inherits its name ('new_name') and its fixed arguments ('2.0 value' and REQUEST_USER) from the 2.0 version, but it also sets a new value for 'cache_for'. >>> print dictionary['as'] new_name >>> print pformat(dictionary['call_with']) {'fixed': '2.0 value', 'user': } >>> dictionary['cache_for'] 300 Let's pop the 3.0 version off the stack. Now we can see how the method looks in 2.0. In 2.0, the method is published as 'new_name' and its 'fixed' argument is fixed to the string '2.0 value'. It inherits its value for 'cache_for' from version 1.0. >>> ignored = dictionary.pop() >>> print dictionary['as'] new_name >>> print pformat(dictionary['call_with']) {'fixed': '2.0 value', 'user': } >>> dictionary['cache_for'] 100 The published name of the 'required' argument is 'required_argument', not 'required'. >>> print dictionary['params']['required'].__name__ required_argument Let's pop the 2.0 version off the stack. Now we can see how the method looks in 1.0. It's still called 'new_name', and its 'required' argument is still called 'required_argument', but its 'fixed' argument is fixed to the string '1.0 value'. >>> ignored = dictionary.pop() >>> print dictionary['as'] new_name >>> print pformat(dictionary['call_with']) {'fixed': '1.0 value', 'user': } >>> print dictionary['params']['required'].__name__ required_argument >>> dictionary['cache_for'] 100 Let's pop one more time to see how the method looks in the pre-1.0 version. It hasn't yet been renamed to 'new_name', its 'required' argument hasn't yet been renamed to 'required_argument', and its 'fixed' argument is fixed to the string 'pre-1.0 value'. >>> ignored = dictionary.pop() >>> print dictionary['as'] a_method >>> print dictionary['params']['required'].__name__ required >>> print pformat(dictionary['call_with']) {'fixed': 'pre-1.0 value', 'user': } >>> dictionary['cache_for'] 100 @operation_removed_in_version ***************************** Sometimes you want version n+1 to remove a named operation that was present in version n. The @operation_removed_in_version declaration does just this. Let's define an operation that's introduced in 1.0 and removed in 2.0. >>> from lazr.restful.declarations import operation_removed_in_version >>> class DisappearingMultiversionMethod(Interface): ... export_as_webservice_entry() ... @operation_removed_in_version(2.0) ... @operation_parameters(arg=Float()) ... @export_read_operation() ... @operation_for_version(1.0) ... def method(arg): ... """A doomed method.""" >>> dictionary = DisappearingMultiversionMethod[ ... 'method'].getTaggedValue('lazr.restful.exported') The method is not present in 2.0: >>> version, attrs = dictionary.pop() >>> print version 2.0 >>> sorted(attrs.items()) [('type', 'removed_operation')] It is present in 1.0: >>> version, attrs = dictionary.pop() >>> print version 1.0 >>> print attrs['type'] read_operation >>> print attrs['params']['arg'] But it's not present in the unnamed pre-1.0 version, since it hadn't been defined yet: >>> pre_10 = dictionary.pop() >>> print pre_10.version None >>> print pre_10.object {'type': 'removed_operation'} The @operation_removed_in_version declaration can also be used to reset a named operation's definition if you need to completely re-do it. For instance, ordinarily you can't change the type of an operation, or totally redefine its parameters--and you shouldn't really need to. It's usually easier to publish two different operations that have the same name in different versions. But you can do it with a single operation, by removing the operation with @operation_removed_in_version and defining it again--either in the same version or in some later version. In this example, the type of the operation, the type and number of the arguments, and the return value change in version 1.0. >>> class ReadOrWriteMethod(Interface): ... export_as_webservice_entry() ... ... @operation_parameters(arg=TextLine(), arg2=TextLine()) ... @export_write_operation() ... @operation_removed_in_version(1.0) ... ... @operation_parameters(arg=Float()) ... @operation_returns_collection_of(Interface) ... @export_read_operation() ... def method(arg, arg2='default'): ... """A read *or* a write operation, depending on version.""" >>> dictionary = ReadOrWriteMethod[ ... 'method'].getTaggedValue('lazr.restful.exported') In version 1.0, the 'method' named operation is a write operation that takes two TextLine arguments and has no special return value. >>> version, attrs = dictionary.pop() >>> print version 1.0 >>> print attrs['type'] write_operation >>> attrs['params']['arg'] >>> attrs['params']['arg2'] >>> print attrs.get('return_type') None In the unnamed pre-1.0 version, the 'method' operation is a read operation that takes a single Float argument and returns a collection. >>> version, attrs = dictionary.pop() >>> print attrs['type'] read_operation >>> attrs['params']['arg'] >>> attrs['params'].keys() ['arg'] >>> attrs['return_type'] Mutators ******** Different versions can define different mutator methods for the same field. >>> class IDifferentMutators(Interface): ... export_as_webservice_entry() ... ... field = exported(TextLine(readonly=True)) ... ... @mutator_for(field) ... @export_write_operation() ... @operation_for_version('beta') ... @operation_parameters(new_value=TextLine()) ... def set_value(new_value): ... pass ... ... @mutator_for(field) ... @export_write_operation() ... @operation_for_version('1.0') ... @operation_parameters(new_value=TextLine()) ... def set_value_2(new_value): ... pass >>> ignored = generate_entry_interfaces( ... IDifferentMutators, [], 'beta', '1.0') But you can't define two mutators for the same field in the same version. >>> class IDuplicateMutator(Interface): ... export_as_webservice_entry() ... ... field = exported(TextLine(readonly=True)) ... ... @mutator_for(field) ... @export_write_operation() ... @operation_for_version('1.0') ... @operation_parameters(new_value=TextLine()) ... def set_value(new_value): ... pass ... ... @mutator_for(field) ... @export_write_operation() ... @operation_for_version('1.0') ... @operation_parameters(new_value=TextLine()) ... def set_value_2(new_value): ... pass Traceback (most recent call last): ... TypeError: A field can only have one mutator method for version 1.0; set_value_2 makes two. Here's a case that's a little trickier. You'll also get an error if you implicitly define a mutator for the earliest version (without giving its name), and then define another one explicitly (giving the name of the earliest version.) >>> class IImplicitAndExplicitMutator(Interface): ... export_as_webservice_entry() ... ... field = exported(TextLine(readonly=True)) ... ... @mutator_for(field) ... @export_write_operation() ... @operation_for_version('beta') ... @operation_parameters(new_value=TextLine()) ... def set_value_2(new_value): ... pass ... ... @mutator_for(field) ... @export_write_operation() ... @operation_parameters(new_value=TextLine()) ... def set_value_2(new_value): ... pass >>> generate_entry_interfaces( ... IImplicitAndExplicitMutator, [], 'beta', '1.0') Traceback (most recent call last): ... ValueError: Field "field" in interface "IImplicitAndExplicitMutator": Both implicit and explicit mutator definitions found for earliest version beta. This error isn't detected until you try to generate the entry interfaces, because until that point lazr.restful doesn't know that 'beta' is the earliest version. If the earliest version was 'alpha', the IImplicitAndExplicitMutator class would be valid. (Again, to test this hypothesis, we need to re-define the class, because the generate_entry_interfaces call modified the original class's annotations in place.) >>> class IImplicitAndExplicitMutator(Interface): ... export_as_webservice_entry() ... ... field = exported(TextLine(readonly=True)) ... ... @mutator_for(field) ... @export_write_operation() ... @operation_for_version('beta') ... @operation_parameters(new_value=TextLine()) ... def set_value_2(new_value): ... pass ... ... @mutator_for(field) ... @export_write_operation() ... @operation_parameters(new_value=TextLine()) ... def set_value_2(new_value): ... pass >>> ignored = generate_entry_interfaces( ... IImplicitAndExplicitMutator, [], 'alpha', 'beta', '1.0') Destructor operations ********************* A destructor can be published in different ways in different versions, but the restrictions on destructor arguments are enforced separately for each version. Here, the destructor fixes a value for the 'fixed2' argument in the earliest version, but not in '1.0'. This is fine: the 1.0 value for 'fixed2' will be inherited from the previous version. >>> class IGoodDestructorEntry(Interface): ... export_as_webservice_entry() ... ... @call_with(fixed1="value3") ... @operation_for_version('1.0') ... @export_destructor_operation() ... @call_with(fixed1="value1", fixed2="value") ... @operation_parameters(fixed1=TextLine(), fixed2=TextLine()) ... def destructor(fixed1, fixed2): ... """Another destructor method.""" >>> ignore = generate_entry_interfaces( ... IGoodDestructorEntry, [], 'beta', '1.0') In this next example, the destructor is removed in 1.0 and added back in 2.0. The 2.0 version does not inherit any values from its prior incarnation, so the fact that it does not fix any value for 'fixed2' is a problem. The fact that 'fixed2' is fixed in 3.0 doesn't help; the method is incompletely specified in 2.0. >>> class IBadDestructorEntry(Interface): ... export_as_webservice_entry() ... ... @call_with(fixed2="value4") ... @operation_for_version('2.0') ... @export_destructor_operation() ... @operation_parameters(fixed1=TextLine(), fixed2=TextLine()) ... @call_with(fixed1="value3") ... @operation_for_version('2.0') ... @operation_removed_in_version('1.0') ... @export_destructor_operation() ... @call_with(fixed1="value1", fixed2="value") ... @operation_parameters(fixed1=TextLine(), fixed2=TextLine()) ... def destructor(fixed1, fixed2): ... """Another destructor method.""" Traceback (most recent call last): ... TypeError: A destructor method must take no non-fixed arguments. In version 2.0, the "destructor" method takes 1: "fixed2". Security ======== The adapters have checkers defined for them that grant access to all attributes in the interface. (There is no reason to protect them since the underlying content security checker will still apply.) :: >>> from lazr.restful.debug import debug_proxy >>> from zope.security.checker import ProxyFactory # ProxyFactory wraps the content using the defined checker. >>> print debug_proxy(ProxyFactory(entry_adapter)) zope.security._proxy._Proxy (using zope.security.checker.Checker) public: author, price, schema, title public (set): author, price, schema, title >>> print debug_proxy(ProxyFactory(collection_adapter)) zope.security._proxy._Proxy (using zope.security.checker.Checker) public: entry_schema, find >>> print debug_proxy(ProxyFactory(read_method_adapter)) zope.security._proxy._Proxy (using zope.security.checker.Checker) public: __call__, return_type, send_modification_event >>> print debug_proxy(ProxyFactory(write_method_adapter)) zope.security._proxy._Proxy (using zope.security.checker.Checker) public: __call__, send_modification_event >>> print debug_proxy(ProxyFactory(factory_method_adapter)) zope.security._proxy._Proxy (using zope.security.checker.Checker) public: __call__, send_modification_event ZCML Registration ================= There is a ZCML directive available that will inspect a given module and generate and register all the interfaces and adapters for all interfaces marked for export. (Put the interface in a module where it will be possible for the ZCML handler to inspect.) >>> from lazr.restful.testing.helpers import register_test_module >>> bookexample = register_test_module( ... 'bookexample', IBook, IBookSet, IBookOnSteroids, ... IBookSetOnSteroids, ISimpleComment, InvalidEmail) After the registration, adapters from IBook to IEntry, and IBookSet to ICollection are available: >>> from zope.component import getMultiAdapter >>> book = Book(u'George Orwell', u'1984', 10.0, u'12345-1984') >>> bookset = BookSet([book]) >>> entry_adapter = getMultiAdapter((book, request), IEntry) >>> verifyObject(IEntry, entry_adapter) True >>> print entry_adapter.schema.__name__ IBookEntry_beta >>> verifyObject(entry_adapter.schema, entry_adapter) True >>> collection_adapter = getMultiAdapter((bookset, request), ICollection) >>> verifyObject(ICollection, collection_adapter) True IResourceOperation adapters named under the exported method names are also available for IBookSetOnSteroids and IBookOnSteroids. >>> from zope.component import getGlobalSiteManager, getUtility >>> adapter_registry = getGlobalSiteManager().adapters >>> from lazr.restful.interfaces import IWebServiceClientRequest >>> request_interface = IWebServiceClientRequest >>> adapter_registry.lookup( ... (IBookSetOnSteroids, request_interface), ... IResourceGETOperation, 'searchBookTitles') >>> adapter_registry.lookup( ... (IBookSetOnSteroids, request_interface), ... IResourcePOSTOperation, 'create_book') >>> adapter_registry.lookup( ... (IBookOnSteroids, request_interface), ... IResourcePOSTOperation, 'checkout') There is also a 'index.html' view on the IWebServiceClientRequest registered for the InvalidEmail exception. >>> from zope.interface import implementedBy >>> adapter_registry.lookup( ... (implementedBy(InvalidEmail), IWebServiceClientRequest), ... Interface, 'index.html') (Clean-up.) >>> import sys >>> del bookexample >>> del sys.modules['lazr.restful.bookexample'] Error handling -------------- Some error handling happens in the ZCML registration phase. At this point, all the annotations have been processed, and the IWebServiceConfiguration utility (with its canonical list of versions) has become available. This lets us run checks on the versioning annotations that couldn't be run before. Here's a class annotated by someone who believes that version 1.0 of the web service is a later version than version 2.0. (Or who believes that named operation annotations proceed from the top down rather than the bottom up.) >>> class WrongOrderVersions(Interface): ... export_as_webservice_entry() ... @export_operation_as('10_name') ... @operation_for_version("1.0") ... @operation_parameters(arg=Float()) ... @export_read_operation() ... @operation_for_version("2.0") ... def method(arg): ... """A method.""" An attempt to register this module with ZCML results in an error explaining the problem. >>> register_test_module('wrongorder', WrongOrderVersions) Traceback (most recent call last): ... ConfigurationExecutionError: : Annotations on "WrongOrderVersions.method" put an earlier version on top of a later version: "beta", "2.0", "1.0". The correct order is: "beta", "1.0", "2.0". ... Here's a class in which a named operation is removed in version 1.0 and then annotated without being reinstated. >>> class AnnotatingARemovedMethod(Interface): ... export_as_webservice_entry() ... @operation_parameters(arg=TextLine()) ... @export_operation_as('already_been_removed') ... @operation_removed_in_version("2.0") ... @operation_parameters(arg=Float()) ... @export_read_operation() ... @operation_for_version("1.0") ... def method(arg): ... """A method.""" >>> register_test_module('annotatingremoved', AnnotatingARemovedMethod) Traceback (most recent call last): ... ConfigurationExecutionError: ... Method "method" contains annotations for version "2.0", even though it's not published in that version. The bad annotations are: "params", "as". ... Mutators as named operations ---------------------------- In earlier versions of lazr.restful, mutator methods were published as named operations. This behavior is now deprecated and will eventually be removed. But to maintain backwards compatibility, mutator methods are still published as named operations up to a certain point. The MyWebServiceConfiguration class (above) defines last_version_with_mutator_named_operations as '1.0', meaning that in 'beta' and '1.0', mutator methods will be published as named operations, and in '2.0' and '3.0' they will not. Let's consider an entry that defines a mutator in the very first version of the web service and never removes it. >>> class IBetaMutatorEntry(Interface): ... export_as_webservice_entry() ... ... field = exported(TextLine(readonly=True)) ... ... @mutator_for(field) ... @export_write_operation() ... @operation_parameters(new_value=TextLine()) ... def set_value(new_value): ... pass >>> class BetaMutator: ... implements(IBetaMutatorEntry) >>> module = register_test_module( ... 'betamutator', IBetaMutatorEntry, BetaMutator) Here's a helper method that will create a request for a given version. >>> from zope.interface import alsoProvides >>> def request_for(version): ... request = FakeRequest(version=version) ... marker = getUtility(IWebServiceVersion, name=version) ... alsoProvides(request, marker) ... return request Here's a helper method that will look up named operation for a given version. >>> from lazr.restful.interfaces import IResourcePOSTOperation >>> def operation_for(context, version, name): ... request = request_for(version) ... return getMultiAdapter( ... (context, request), IResourcePOSTOperation, name) In the 'beta' and '1.0' versions, the lookup succeeds and returns the generated adapter class defined for 'beta'. These two versions publish "set_value" as a named POST operation. >>> context = BetaMutator() >>> operation_for(context, 'beta', 'set_value') >>> operation_for(context, '1.0', 'set_value') In '2.0', the lookup fails, not because of anything in the definition of IBetaMutatorEntry, but because the web service configuration defines 1.0 as the last version in which mutators are published as named operations. >>> operation_for(context, '2.0', 'set_value') Traceback (most recent call last): ... ComponentLookupError: ... Here's an entry that defines a mutator method in version 2.0, after the cutoff point. >>> class I20MutatorEntry(Interface): ... export_as_webservice_entry() ... ... field = exported(TextLine(readonly=True)) ... ... @mutator_for(field) ... @export_write_operation() ... @operation_parameters(new_value=TextLine()) ... @operation_for_version('2.0') ... def set_value(new_value): ... pass >>> class Mutator20: ... implements(I20MutatorEntry) >>> module = register_test_module( ... 'mutator20', I20MutatorEntry, Mutator20) The named operation lookup never succeeds. In '1.0' it fails because the mutator hasn't been published yet. In '2.0' it fails because that version comes after the last one to publish mutators as named operations ('1.0'). >>> context = Mutator20() >>> operation_for(context, '1.0', 'set_value') Traceback (most recent call last): ... ComponentLookupError: ... >>> operation_for(context, '2.0', 'set_value') Traceback (most recent call last): ... ComponentLookupError: ... Edge cases ********** You can promote a named operation to a mutator operation ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Here's a named operation that was defined in '1.0' and promoted to a mutator in '3.0'. >>> class IOperationPromotedToMutator(Interface): ... export_as_webservice_entry() ... ... field = exported(TextLine(readonly=True)) ... ... @mutator_for(field) ... @operation_for_version('3.0') ... @operation_parameters(text=TextLine()) ... @export_write_operation() ... @operation_for_version('1.0') ... def set_value(text): ... pass >>> class OperationPromotedToMutator: ... implements(IOperationPromotedToMutator) ... ... def __init__(self): ... self.field = None ... ... def set_value(self, value): ... self.field = "!" + value + "!" >>> module = register_test_module( ... 'mutatorpromotion', IOperationPromotedToMutator, ... OperationPromotedToMutator) >>> context = OperationPromotedToMutator() The operation is not available in 'beta', because it hasn't been defined yet. >>> print operation_for(context, 'beta', 'set_value').__class__.__name__ Traceback (most recent call last): ... ComponentLookupError: ... The operation is available in both '1.0', and '2.0', even though mutator operations aren't published as named operations after 1.0. This is because the operation doesn't become a mutator operation until 3.0. >>> print operation_for(context, '1.0', 'set_value').__class__.__name__ POST_IOperationPromotedToMutator_set_value_1_0 >>> print operation_for(context, '2.0', 'set_value').__class__.__name__ POST_IOperationPromotedToMutator_set_value_1_0 The operation is not available in 3.0, the version in which it becomes a mutator. >>> operation_for(context, '3.0', 'set_value') Traceback (most recent call last): ... ComponentLookupError: ... But the mutator is active, as you can see by modifying the entry's field: >>> context = OperationPromotedToMutator() >>> request_30 = request_for('3.0') >>> entry = getMultiAdapter((context, request_30), IEntry) >>> entry.field = 'foo' >>> print entry.field !foo! You can't immediately reinstate a mutator operation as a named operation ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Here's one that shows a limitation of the software. This method defines a mutator 'set_value' for version 1.0, which will be removed in version 2.0. It *also* defines a named operation to be published as 'set_value' in version 2.0, and a third operation to be published as 'set_value' in version 3.0. >>> class IMutatorPlusNamedOperationEntry(Interface): ... export_as_webservice_entry() ... ... field = exported(TextLine(readonly=True)) ... ... @mutator_for(field) ... @export_write_operation() ... @operation_parameters(new_value=TextLine()) ... @operation_for_version('1.0') ... def set_value(new_value): ... pass ... ... @export_write_operation() ... @operation_parameters(new_value=TextLine()) ... @export_operation_as('set_value') ... @operation_for_version('2.0') ... def not_a_mutator(new_value): ... pass ... ... @export_write_operation() ... @operation_parameters(new_value=TextLine()) ... @export_operation_as('set_value') ... @operation_for_version('3.0') ... def also_not_a_mutator(new_value): ... pass >>> class MutatorPlusNamedOperation: ... implements(IMutatorPlusNamedOperationEntry) >>> module = register_test_module( ... 'multimutator', IMutatorPlusNamedOperationEntry, ... MutatorPlusNamedOperation) The mutator is accessible for version 1.0, as you'd expect. >>> context = MutatorPlusNamedOperation() >>> print operation_for(context, '1.0', 'set_value').__class__.__name__ POST_IMutatorPlusNamedOperationEntry_set_value_1_0 But the named operation that replaces the mutator in version 1.0 is not accessible. >>> operation_for(context, '2.0', 'set_value') Traceback (most recent call last): ... ComponentLookupError: ... The named operation of the same name defined in version 3.0 _is_ accessible. >>> print operation_for(context, '3.0', 'set_value').__class__.__name__ POST_IMutatorPlusNamedOperationEntry_set_value_3_0 So, in the version that gets rid of named operations for mutator methods, you can't define a named operation with the same name as one of the outgoing mutator methods. Removing mutator named operations altogether ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ You can remove this behavior altogether (such that mutators are never named operations) by setting the value of the configuration variable 'last_version_with_mutator_named_operations' to None. >>> config = getUtility(IWebServiceConfiguration) >>> config.last_version_with_mutator_named_operations = None Here's a class identical to IBetaMutatorEntry: it defines a mutator in the 'beta' version of the web service. (We have to redefine the class to avoid conflicting registrations.) >>> class IBetaMutatorEntry2(IBetaMutatorEntry): ... export_as_webservice_entry() ... ... field = exported(TextLine(readonly=True)) ... ... @mutator_for(field) ... @export_write_operation() ... @operation_parameters(new_value=TextLine()) ... def set_value(new_value): ... pass >>> class BetaMutator2: ... implements(IBetaMutatorEntry2) >>> module = register_test_module( ... 'betamutator2', IBetaMutatorEntry2, BetaMutator2) >>> module = register_test_module( ... 'betamutator', IBetaMutatorEntry, BetaMutator) Back when last_version_with_mutator_named_operations was '1.0', the 'set_value' named operation on IBetaMutatorEntry was accessible in 'beta' but not in '1.0' or later versions. Now, IBetaMutatorEntry2's 'set_value' mutator is not even accessible in 'beta'. >>> context = BetaMutator2() >>> operation_for(context, 'beta', 'set_value') Traceback (most recent call last): ... ComponentLookupError: ... Getting the old behavior back ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ You can bring back the old behavior (in which mutators are always named operations) by setting 'last_version_with_mutator_named_operations' to the last active version. >>> config.last_version_with_mutator_named_operations = ( ... config.active_versions[-1]) Again, we have to publish a new entry class, to avoid conflicting registrations. >>> class IBetaMutatorEntry3(Interface): ... export_as_webservice_entry() ... ... field = exported(TextLine(readonly=True)) ... ... @mutator_for(field) ... @export_write_operation() ... @operation_parameters(new_value=TextLine()) ... def set_value(new_value): ... pass >>> class BetaMutator3: ... implements(IBetaMutatorEntry3) >>> module = register_test_module( ... 'betamutator3', IBetaMutatorEntry3, BetaMutator3) Back when last_version_with_mutator_named_operations was '1.0', the 'set_value' mutator on IBetaMutatorEntry was not accessible in any version past '1.0'. Now, the corresponding IBetaMutatorEntry3 mutator is accessible in every version. >>> context = BetaMutator3() >>> operation_for(context, 'beta', 'set_value') <...POST_IBetaMutatorEntry3_set_value_beta...> >>> operation_for(context, '1.0', 'set_value') <...POST_IBetaMutatorEntry3_set_value_beta...> >>> operation_for(context, '2.0', 'set_value') <...POST_IBetaMutatorEntry3_set_value_beta...> >>> operation_for(context, '3.0', 'set_value') <...POST_IBetaMutatorEntry3_set_value_beta...> lazr.restful-0.19.3/src/lazr/restful/docs/fields.txt0000644000175000017500000000511211631755356022634 0ustar benjibenji00000000000000LAZR Fields *********** =============== CollectionField =============== CollectionField is a field representing an iterable collection. The field provides ICollectionField which is an extension of ISequence. >>> from zope.schema import Int >>> from zope.schema.interfaces import ISequence >>> from lazr.restful.interfaces import ICollectionField >>> from lazr.restful.fields import CollectionField >>> int_collection = CollectionField( ... title=u'A collection', value_type=Int()) >>> from zope.interface.verify import verifyObject >>> verifyObject(ICollectionField, int_collection) True >>> ICollectionField.extends(ISequence) True By default, such fields are readonly. >>> int_collection.readonly True But it can be made read-write. >>> rw_collection = CollectionField( ... title=u'A writable collection.', readonly=False) >>> rw_collection.readonly False The validate method accepts any iterable that satisfy the contained elements. >>> int_collection.validate(range(10)) But if the object isn't iterable, NotAContainer is raised. >>> int_collection.validate(object()) Traceback (most recent call last): ... NotAContainer: If the iterable contains an invalid item, WrongContainedType is raised. >>> int_collection.validate(['a', 1, 2, 'b']) Traceback (most recent call last): ... WrongContainedType: ... ========= Reference ========= A Reference field is just like an Object except that it doesn't validate by value, but only check that the value provides the proper schema. >>> from zope.interface import Interface, directlyProvides >>> from zope.schema import Text >>> class MySchema(Interface): ... a_value = Text() >>> from lazr.restful.fields import Reference >>> from lazr.restful.interfaces import IReference >>> reference = Reference(schema=MySchema) >>> verifyObject(IReference, reference) True >>> class Fake(object): ... pass >>> fake = Fake() >>> reference.validate(fake) Traceback (most recent call last): ... SchemaNotProvided >>> directlyProvides(fake, MySchema) >>> reference.validate(fake) The Reference field supports the standard IField constraint. >>> reference = Reference( ... schema=MySchema, ... constraint=lambda value: 'good' in value.a_value) >>> fake.a_value = 'bad' >>> reference.validate(fake) Traceback (most recent call last): ... ConstraintNotSatisfied... >>> fake.a_value = 'good' >>> reference.validate(fake) lazr.restful-0.19.3/src/lazr/restful/docs/webservice.txt0000644000175000017500000023713211631755356023535 0ustar benjibenji00000000000000******************** RESTful Web Services ******************** lazr.restful builds on Zope conventions to make it easy to expose your model objects as RESTful HTTP resources. I'll demonstrate these features by defining a model for managing recipes, and then publishing the model objects as resources through a web service. Example model objects ===================== Here's the interface for a simple set of model objects. This is the kind of model object you'd find in any Zope application, with no special knowledge of web services. The model is of a group of cookbooks. Each cookbook has a known person as the author. Each cookbook contains multiple recipes. A recipe is a recipe _for_ a dish, and two or more cookbooks may provide different recipes for the same dish. Users may comment on cookbooks and on individual recipes. # All classes defined in this test are new-style classes. >>> __metaclass__ = type >>> from zope.interface import Interface, Attribute >>> from zope.schema import Bool, Bytes, Int, Text, TextLine >>> from lazr.restful.fields import Reference >>> class ITestDataObject(Interface): ... """A marker interface for data objects.""" ... path = Attribute("The path portion of this object's URL. " ... "Defined here for simplicity of testing.") >>> class IAuthor(ITestDataObject): ... name = TextLine(title=u"Name", required=True) ... # favorite_recipe.schema will be set to IRecipe once ... # IRecipe is defined. ... favorite_recipe = Reference(schema=Interface) ... popularity = Int(readonly=True) >>> class ICommentTarget(ITestDataObject): ... comments = Attribute('List of comments about this object.') >>> class IComment(ITestDataObject): ... target = Attribute('The object containing this comment.') ... text = TextLine(title=u"Text", required=True) >>> class ICookbook(ICommentTarget): ... name = TextLine(title=u"Name", required=True) ... author = Reference(schema=IAuthor) ... cuisine = TextLine(title=u"Cuisine", required=False, default=None) ... recipes = Attribute("List of recipes published in this cookbook.") ... cover = Bytes(0, 5000, title=u"An image of the cookbook's cover.") ... def removeRecipe(recipe): ... """Remove a recipe from this cookbook.""" >>> class IDish(ITestDataObject): ... name = TextLine(title=u"Name", required=True) ... recipes = Attribute("List of recipes for this dish.") ... def removeRecipe(recipe): ... """Remove one of the recipes for this dish.""" >>> class IRecipe(ICommentTarget): ... id = Int(title=u"Unique ID", required=True) ... dish = Reference(schema=IDish) ... cookbook = Reference(schema=ICookbook) ... instructions = Text(title=u"How to prepare the recipe.", ... required=True) ... private = Bool(title=u"Whether the public can see this recipe.", ... default=False) ... def delete(): ... """Delete this recipe.""" >>> IAuthor['favorite_recipe'].schema = IRecipe Here's the interface for the 'set' objects that manage the authors, cookbooks, and dishes. The inconsistent naming is intentional. >>> from lazr.restful.interfaces import ITraverseWithGet >>> class ITestDataSet(ITestDataObject, ITraverseWithGet): ... """A marker interface.""" >>> class IAuthorSet(ITestDataSet): ... def getAllAuthors(self): ... "Get all authors." ... ... def get(self, request, name): ... "Retrieve a single author by name." >>> class ICookbookSet(ITestDataSet): ... def getAll(self): ... "Get all cookbooks." ... ... def get(self, request, name): ... "Retrieve a single cookbook by name." ... ... def findRecipes(self, name): ... "Find recipes with a given name, across cookbooks." >>> class IDishSet(ITestDataSet): ... def getAll(self): ... "Get all dishes." ... ... def get(self, request, name): ... "Retrieve a single dish by name." Here are simple implementations of IAuthor, IComment, ICookbook, IDish, and IRecipe. The web service uses the standard Zope protocol for mapping URLs to object. So a URL is mapped to an object using the IPublishTraverse interface, and the URL of an object is found by using the IAbsoluteURL interface. >>> from urllib import quote >>> from zope.component import ( ... adapts, getSiteManager, getMultiAdapter) >>> from zope.interface import implements >>> from zope.publisher.interfaces import IPublishTraverse, NotFound >>> from zope.publisher.interfaces.browser import IBrowserRequest >>> from zope.security.checker import CheckerPublic >>> from zope.traversing.browser.interfaces import IAbsoluteURL >>> from lazr.restful.security import protect_schema >>> class BaseAbsoluteURL: ... """A basic, extensible implementation of IAbsoluteURL.""" ... implements(IAbsoluteURL) ... ... def __init__(self, context, request): ... self.context = context ... self.request = request ... ... def __str__(self): ... return "http://api.cookbooks.dev/beta/" + self.context.path ... ... __call__ = __str__ >>> sm = getSiteManager() >>> sm.registerAdapter( ... BaseAbsoluteURL, [ITestDataObject, IBrowserRequest], ... IAbsoluteURL) >>> class Author: ... implements(IAuthor) ... def __init__(self, name): ... self.name = name ... self.favorite_recipe = None ... self.popularity = 1 ... ... @property ... def path(self): ... return 'authors/' + quote(self.name) >>> protect_schema(Author, IAuthor, write_permission=CheckerPublic) >>> class Comment: ... implements(IComment) ... ... def __init__(self, target, text): ... self.target = target ... self.text = text ... self.target.comments.append(self) ... >>> protect_schema(Comment, IComment, write_permission=CheckerPublic) >>> class CommentAbsoluteURL(BaseAbsoluteURL): ... """Code for generating the URL to a comment. ... ... The URL is based on the URL of the ICommentTarget on which ... this is a comment. ... """ ... adapts(IComment, IBrowserRequest) ... ... def __str__(self): ... base = getMultiAdapter((self.context.target, request), ... IAbsoluteURL)() ... return base + "/comments/%d" % ( ... self.context.target.comments.index(self.context)+1) ... __call__ = __str__ >>> sm.registerAdapter(CommentAbsoluteURL) >>> class Cookbook: ... implements(ICookbook) ... def __init__(self, name, author, cuisine=None): ... self.name = name ... self.author = author ... self.recipes = [] ... self.comments = [] ... self.cuisine = cuisine ... self.cover = None ... ... @property ... def path(self): ... return 'cookbooks/' + quote(self.name) ... ... def removeRecipe(self, recipe): ... self.recipes.remove(recipe) >>> protect_schema(Cookbook, ICookbook, write_permission=CheckerPublic) >>> from urllib import unquote >>> class CookbookTraversal: ... implements(IPublishTraverse) ... adapts(ICookbook, IBrowserRequest) ... ... traversing = None ... ... def __init__(self, context, request): ... self.context = context ... ... def publishTraverse(self, request, name): ... name = unquote(name) ... if self.traversing is not None: ... return getattr(self, 'traverse_' + self.traversing)(name) ... elif name in ['comments', 'recipes']: ... self.traversing = name ... return self ... else: ... raise NotFound(self.context, name) ... ... def traverse_comments(self, name): ... try: ... return self.context.comments[int(name)-1] ... except (IndexError, TypeError, ValueError): ... raise NotFound(self.context, 'comments/' + name) ... ... def traverse_recipes(self, name): ... name = unquote(name) ... for recipe in self.context.recipes: ... if recipe.dish.name == name: ... return recipe ... raise NotFound(self.context, 'recipes/' + name) >>> protect_schema(CookbookTraversal, IPublishTraverse) >>> sm.registerAdapter(CookbookTraversal) >>> class Dish: ... implements(IDish) ... def __init__(self, name): ... self.name = name ... self.recipes = [] ... @property ... def path(self): ... return 'dishes/' + quote(self.name) ... def removeRecipe(self, recipe): ... self.recipes.remove(recipe) >>> protect_schema(Dish, IDish, write_permission=CheckerPublic) >>> class Recipe: ... implements(IRecipe) ... path = '' ... def __init__(self, id, cookbook, dish, instructions, ... private=False): ... self.id = id ... self.cookbook = cookbook ... self.cookbook.recipes.append(self) ... self.dish = dish ... self.dish.recipes.append(self) ... self.instructions = instructions ... self.comments = [] ... self.private = private ... def delete(self): ... self.cookbook.removeRecipe(self) ... self.dish.removeRecipe(self) >>> protect_schema(Recipe, IRecipe, read_permission='zope.View', ... write_permission=CheckerPublic) >>> class RecipeAbsoluteURL(BaseAbsoluteURL): ... """Code for generating the URL to a recipe. ... ... The URL is based on the URL of the cookbook to which ... this recipe belongs. ... """ ... adapts(IRecipe, IBrowserRequest) ... ... def __str__(self): ... base = getMultiAdapter((self.context.cookbook, request), ... IAbsoluteURL)() ... return base + "/recipes/%s" % quote(self.context.dish.name) ... __call__ = __str__ >>> sm.registerAdapter(RecipeAbsoluteURL) >>> class RecipeTraversal: ... adapts(IRecipe, IBrowserRequest) ... implements(IPublishTraverse) ... ... saw_comments = False ... ... def __init__(self, context, request): ... self.context = context ... ... def publishTraverse(self, request, name): ... name = unquote(name) ... if self.saw_comments: ... try: ... return self.context.comments[int(name)-1] ... except (IndexError, TypeError, ValueError): ... raise NotFound(self.context, 'comments/' + name) ... elif name == 'comments': ... self.saw_comments = True ... return self ... else: ... raise NotFound(self.context, name) >>> protect_schema(RecipeTraversal, IPublishTraverse) >>> sm.registerAdapter(RecipeTraversal) Here are the "model objects" themselves: >>> A1 = Author(u"Julia Child") >>> A2 = Author(u"Irma S. Rombauer") >>> A3 = Author(u"James Beard") >>> AUTHORS = [A1, A2, A3] >>> C1 = Cookbook(u"Mastering the Art of French Cooking", A1) >>> C2 = Cookbook(u"The Joy of Cooking", A2) >>> C3 = Cookbook(u"James Beard's American Cookery", A3) >>> COOKBOOKS = [C1, C2, C3] >>> D1 = Dish("Roast chicken") >>> C1_D1 = Recipe(1, C1, D1, u"You can always judge...") >>> C2_D1 = Recipe(2, C2, D1, u"Draw, singe, stuff, and truss...") >>> C3_D1 = Recipe(3, C3, D1, u"A perfectly roasted chicken is...") >>> D2 = Dish("Baked beans") >>> C2_D2 = Recipe(4, C2, D2, "Preheat oven to...") >>> C3_D2 = Recipe(5, C3, D2, "Without doubt the most famous...", True) >>> D3 = Dish("Foies de voilaille en aspic") >>> C1_D3 = Recipe(6, C1, D3, "Chicken livers sauteed in butter...") >>> COM1 = Comment(C2_D1, "Clear and concise.") >>> COM2 = Comment(C2, "A kitchen staple.") >>> A1.favorite_recipe = C1_D1 >>> A2.favorite_recipe = C2_D2 >>> A3.favorite_recipe = C3_D2 Here's a simple CookbookSet with a predefined list of cookbooks. >>> from lazr.restful.simple import TraverseWithGet >>> class CookbookSet(BaseAbsoluteURL, TraverseWithGet): ... implements(ICookbookSet) ... path = 'cookbooks' ... ... def __init__(self): ... self.cookbooks = COOKBOOKS ... ... def newCookbook(self, author_name, title, cuisine): ... authors = AuthorSet() ... author = authors.get(None, author_name) ... if author is None: ... author = authors.newAuthor(author_name) ... cookbook = Cookbook(title, author, cuisine) ... self.cookbooks.append(cookbook) ... return cookbook ... ... def getAll(self): ... return self.cookbooks ... ... def get(self, request, name): ... match = [c for c in self.cookbooks if c.name == name] ... if len(match) > 0: ... return match[0] ... return None ... ... def findRecipes(self, name): ... """Find recipes for a given dish across cookbooks.""" ... matches = [] ... for c in self.cookbooks: ... for r in c.recipes: ... if r.dish.name == name: ... matches.append(r) ... break ... # A somewhat arbitrary and draconian bit of error handling ... # for the sake of demonstration. ... if len(matches) == 0: ... raise ValueError("No matches for %s" % name) ... return matches >>> protect_schema(CookbookSet, ICookbookSet) >>> sm.registerUtility(CookbookSet(), ICookbookSet) Here's a simple AuthorSet with predefined authors. >>> class AuthorSet(BaseAbsoluteURL, TraverseWithGet): ... implements(IAuthorSet) ... path = 'authors' ... ... def __init__(self): ... self.authors = AUTHORS ... ... def newAuthor(self, name): ... author = Author(name) ... self.authors.append(author) ... return author ... ... def getAllAuthors(self): ... return self.authors ... ... def get(self, request, name): ... match = [p for p in self.authors if p.name == name] ... if len(match) > 0: ... return match[0] ... return None >>> sm.registerAdapter( ... TraverseWithGet, [ITestDataObject, IBrowserRequest]) >>> protect_schema(AuthorSet, IAuthorSet) >>> sm.registerUtility(AuthorSet(), IAuthorSet) Here's a vocabulary of authors, for a field that presents a Choice among authors. >>> from zope.schema.interfaces import IVocabulary >>> class AuthorVocabulary: ... implements(IVocabulary) ... def __iter__(self): ... """Iterate over the authors.""" ... return AuthorSet().getAllAuthors().__iter__() ... ... def __len__(self): ... """Return the number of authors.""" ... return len(AuthorSet().getAllAuthors()) ... ... def getTerm(self, name): ... """Retrieve an author by name.""" ... return AuthorSet().get(name) Finally, a simple DishSet with predefined dishes. >>> class DishSet(BaseAbsoluteURL, TraverseWithGet): ... implements(IDishSet) ... path = 'dishes' ... def __init__(self): ... self.dishes = [D1, D2, D3] ... ... def getAll(self): ... return self.dishes ... ... def get(self, request, name): ... match = [d for d in self.dishes if d.name == name] ... if len(match) > 0: ... return match[0] ... return None >>> protect_schema(DishSet, IDishSet) >>> sm.registerUtility(DishSet(), IDishSet) ======== Security ======== The webservice uses the normal zope.security API to check for permission. For this example, let's register a simple policy that denies access to private recipes. >>> from zope.security.permission import Permission >>> from zope.security.management import setSecurityPolicy >>> from zope.security.simplepolicies import PermissiveSecurityPolicy >>> from zope.security.proxy import removeSecurityProxy >>> sm.registerUtility(Permission('zope.View'), name='zope.View') >>> class SimpleSecurityPolicy(PermissiveSecurityPolicy): ... def checkPermission(self, permission, object): ... if IRecipe.providedBy(object): ... return not removeSecurityProxy(object).private ... else: ... return True >>> setSecurityPolicy(SimpleSecurityPolicy) Web Service Infrastructure Initialization ========================================= The lazr.restful package contains a set of default adapters and definitions to implement the web service. >>> from zope.configuration import xmlconfig >>> zcmlcontext = xmlconfig.string(""" ... ... ... ... ... """) A IWebServiceConfiguration utility is also expected to be defined which defines common configuration option for the webservice. >>> from lazr.restful import directives >>> from lazr.restful.interfaces import IWebServiceConfiguration >>> from lazr.restful.simple import BaseWebServiceConfiguration >>> from lazr.restful.testing.webservice import WebServiceTestPublication >>> class WebServiceConfiguration(BaseWebServiceConfiguration): ... hostname = 'api.cookbooks.dev' ... use_https = False ... active_versions = ['beta', 'devel'] ... code_revision = 'test' ... max_batch_size = 100 ... directives.publication_class(WebServiceTestPublication) ... first_version_with_total_size_link = 'devel' >>> from grokcore.component.testing import grok_component >>> ignore = grok_component( ... 'WebServiceConfiguration', WebServiceConfiguration) >>> from zope.component import getUtility >>> webservice_configuration = getUtility(IWebServiceConfiguration) We also need to define a marker interface for each version of the web service, so that incoming requests can be marked with the appropriate version string. The configuration above defines two versions, 'beta' and 'devel'. >>> from lazr.restful.interfaces import IWebServiceClientRequest >>> class IWebServiceRequestBeta(IWebServiceClientRequest): ... pass >>> class IWebServiceRequestDevel(IWebServiceClientRequest): ... pass >>> versions = ((IWebServiceRequestBeta, 'beta'), ... (IWebServiceRequestDevel, 'devel')) >>> from lazr.restful import register_versioned_request_utility >>> for cls, version in versions: ... register_versioned_request_utility(cls, version) Defining the resources ====================== lazr.restful provides an interface, ``IEntry``, used by an individual model object exposed through a specific resource. This interface defines only one attribute ``schema`` which should contain a schema describing the data fields available in the entry. The same kind of fields defined by a model interface like ``IRecipe``. It is expected that the entry adapter also provides that schema itself. If there's not much to an interface, you can expose it through the web service exactly as it's defined, by defining a class that inherits from both the interface and ``IEntry``. Since ``IAuthor`` and ``IComment`` are so simple, we can define ``IAuthorEntry`` and ``ICommentEntry`` very simply. The only extra and unusual step we have to take is to annotate the interfaces with human-readable names for the objects we're exposing. >>> from zope.interface import taggedValue >>> from lazr.restful.interfaces import IEntry, LAZR_WEBSERVICE_NAME >>> class IAuthorEntry(IAuthor, IEntry): ... """The part of an author we expose through the web service.""" ... taggedValue( ... LAZR_WEBSERVICE_NAME, ... dict( ... singular="author", plural="authors", ... publish_web_link=True)) >>> class ICommentEntry(IComment, IEntry): ... """The part of a comment we expose through the web service.""" ... taggedValue( ... LAZR_WEBSERVICE_NAME, ... dict( ... singular="comment", plural="comments", ... publish_web_link=True)) Most of the time, it doesn't work to expose to the web service the same data model we expose internally. Usually there are fields we don't want to expose, synthetic fields we do want to expose, fields we want to expose as a different type under a different name, and so on. This is why we have ``IEntry`` in the first place: the ``IEntry`` interface defines the interface we _do_ want to expose through the web service. The reason we can't just define ``IDishEntry(IDish, IEntry)`` is that ``IDish`` defines the "recipes" collection as an ``Attribute``. ``Attribute`` is about as generic as "object", and doesn't convey any information about what kind of object is in the collection, or even that "recipes" is a collection at all. To expose the corresponding field to the web service we use ``CollectionField``. >>> from lazr.restful.fields import CollectionField >>> class IDishEntry(IEntry): ... "The part of a dish that we expose through the web service." ... recipes = CollectionField(value_type=Reference(schema=IRecipe)) ... taggedValue( ... LAZR_WEBSERVICE_NAME, ... dict( ... singular="dish", plural="dishes", ... publish_web_link=True)) In the following code block we define an interface that exposes the underlying ``Recipe``'s name but not its ID. References to associated objects (like the recipe's cookbook) are represented with the ``zope.schema.Object`` type: this makes it possible to serve a link from a recipe to its cookbook. >>> class IRecipeEntry(IEntry): ... "The part of a recipe that we expose through the web service." ... cookbook = Reference(schema=ICookbook) ... dish = Reference(schema=IDish) ... instructions = Text(title=u"Name", required=True) ... comments = CollectionField(value_type=Reference(schema=IComment)) ... taggedValue( ... LAZR_WEBSERVICE_NAME, ... dict( ... singular="recipe", plural="recipes", ... publish_web_link=True)) >>> from lazr.restful.fields import ReferenceChoice >>> class ICookbookEntry(IEntry): ... name = TextLine(title=u"Name", required=True) ... cuisine = TextLine(title=u"Cuisine", required=False, default=None) ... author = ReferenceChoice( ... schema=IAuthor, vocabulary=AuthorVocabulary()) ... recipes = CollectionField(value_type=Reference(schema=IRecipe)) ... comments = CollectionField(value_type=Reference(schema=IComment)) ... cover = Bytes(0, 5000, title=u"An image of the cookbook's cover.") ... taggedValue( ... LAZR_WEBSERVICE_NAME, ... dict( ... singular="cookbook", plural="cookbooks", ... publish_web_link=True)) The ``author`` field is a choice between ``Author`` objects. To make sure that the ``Author`` objects are properly marshalled to JSON, we need to define an adapter to ``IFieldMarshaller``. >>> from zope.schema.interfaces import IChoice >>> from lazr.restful.marshallers import ( ... ObjectLookupFieldMarshaller) >>> from lazr.restful.interfaces import ( ... IFieldMarshaller, IWebServiceClientRequest) >>> sm.registerAdapter( ... ObjectLookupFieldMarshaller, ... [IChoice, IWebServiceClientRequest, AuthorVocabulary], ... IFieldMarshaller) Implementing the resources ========================== Here's the implementation of ``IAuthorEntry``: a simple decorator on the original model object. It subclasses ``Entry``, a simple base class that defines a constructor. (See http://pypi.python.org/pypi/lazr.delegates for more on ``delegates()``.) >>> from zope.component import adapts >>> from zope.interface.verify import verifyObject >>> from lazr.delegates import delegates >>> from lazr.restful import Entry >>> from lazr.restful.testing.webservice import FakeRequest >>> from UserDict import UserDict >>> class FakeDict(UserDict): ... def __init__(self, interface): ... UserDict.__init__(self) ... self.interface = interface ... def __getitem__(self, key): ... return self.interface >>> class AuthorEntry(Entry): ... """An author, as exposed through the web service.""" ... adapts(IAuthor) ... delegates(IAuthorEntry) ... schema = IAuthorEntry ... # This dict is normally generated by lazr.restful, but since we ... # create the adapters manually here, we need to do the same for ... # this dict. ... _orig_interfaces = FakeDict(IAuthor) >>> request = FakeRequest() >>> verifyObject(IAuthorEntry, AuthorEntry(A1, request)) True The ``schema`` attribute points to the interface class that defines the attributes exposed through the web service. Above, ``schema`` is ``IAuthorEntry``, which exposes only ``name``. ``IEntry`` also defines an invariant that enforces that it can be adapted to the interface defined in the schema attribute. This is usually not a problem, since the schema is usually the interface itself. >>> IAuthorEntry.validateInvariants(AuthorEntry(A1, request)) But the invariant will complain if that isn't true. >>> class InvalidAuthorEntry(Entry): ... delegates(IAuthorEntry) ... schema = ICookbookEntry >>> verifyObject(IAuthorEntry, InvalidAuthorEntry(A1, request)) True >>> IAuthorEntry.validateInvariants(InvalidAuthorEntry(A1, request)) Traceback (most recent call last): ... Invalid: InvalidAuthorEntry doesn't provide its ICookbookEntry schema. Other entries are defined similarly. >>> class CookbookEntry(Entry): ... """A cookbook, as exposed through the web service.""" ... delegates(ICookbookEntry) ... schema = ICookbookEntry ... # This dict is normally generated by lazr.restful, but since we ... # create the adapters manually here, we need to do the same for ... # this dict. ... _orig_interfaces = FakeDict(ICookbook) >>> class DishEntry(Entry): ... """A dish, as exposed through the web service.""" ... delegates(IDishEntry) ... schema = IDishEntry ... # This dict is normally generated by lazr.restful, but since we ... # create the adapters manually here, we need to do the same for ... # this dict. ... _orig_interfaces = FakeDict(IDish) >>> class CommentEntry(Entry): ... """A comment, as exposed through the web service.""" ... delegates(ICommentEntry) ... schema = ICommentEntry ... # This dict is normally generated by lazr.restful, but since we ... # create the adapters manually here, we need to do the same for ... # this dict. ... _orig_interfaces = FakeDict(IComment) >>> class RecipeEntry(Entry): ... delegates(IRecipeEntry) ... schema = IRecipeEntry ... # This dict is normally generated by lazr.restful, but since we ... # create the adapters manually here, we need to do the same for ... # this dict. ... _orig_interfaces = FakeDict(IRecipe) We need to register these entries as a multiadapter adapter from (e.g.) ``IAuthor`` and ``IWebServiceClientRequest`` to (e.g.) ``IAuthorEntry``. In ZCML a registration would look like this. Since we're in the middle of a Python example we can do the equivalent in Python code for each entry class: >>> for entry_class, adapts_interface, provided_interface in [ ... [AuthorEntry, IAuthor, IAuthorEntry], ... [CookbookEntry, ICookbook, ICookbookEntry], ... [DishEntry, IDish, IDishEntry], ... [CommentEntry, IComment, ICommentEntry], ... [RecipeEntry, IRecipe, IRecipeEntry]]: ... sm.registerAdapter( ... entry_class, [adapts_interface, IWebServiceClientRequest], ... provided=provided_interface) lazr.restful also defines an interface and a base class for collections of objects. I'll use it to expose the ``AuthorSet`` collection and other top-level collections through the web service. A collection must define a method called find(), which returns the model objects in the collection. >>> from lazr.restful import Collection >>> from lazr.restful.interfaces import ICollection >>> class AuthorCollection(Collection): ... """A collection of authors, as exposed through the web service.""" ... ... entry_schema = IAuthorEntry ... ... def find(self): ... """Find all the authors.""" ... return self.context.getAllAuthors() >>> sm.registerAdapter(AuthorCollection, ... (IAuthorSet, IWebServiceClientRequest), ... provided=ICollection) >>> verifyObject(ICollection, AuthorCollection(AuthorSet(), request)) True >>> class CookbookCollection(Collection): ... """A collection of cookbooks, as exposed through the web service. ... """ ... adapts(ICookbookSet) ... ... entry_schema = ICookbookEntry ... ... def find(self): ... """Find all the cookbooks.""" ... return self.context.getAll() >>> sm.registerAdapter(CookbookCollection, ... (ICookbookSet, IWebServiceClientRequest), ... provided=ICollection) >>> class DishCollection(Collection): ... """A collection of dishes, as exposed through the web service.""" ... adapts(IDishSet) ... ... entry_schema = IDishEntry ... ... def find(self): ... """Find all the dishes.""" ... return self.context.getAll() >>> sm.registerAdapter(DishCollection, ... (IDishSet, IWebServiceClientRequest), ... provided=ICollection) Like ``Entry``, ``Collection`` is a simple base class that defines a constructor. The ``entry_schema`` attribute gives a ``Collection`` class knowledge about what kind of entry it's supposed to contain. >>> DishCollection.entry_schema We also need to define a collection of the recipes associated with a cookbook. We say that the collection of recipes is scoped to a cookbook. Scoped collections adapters are looked for based on the type of the scope, and the type of the entries contained in the scoped collection. There is a default ``ScopedCollection`` adapter that works whenever the scoped collection is available as an iterable attribute of the context. >>> from lazr.restful.interfaces import IScopedCollection >>> def scope_collection(parent, child, name): ... """A helper method that simulates a scoped collection lookup.""" ... parent_entry = getMultiAdapter((parent, request), IEntry) ... child_entry = getMultiAdapter((child, request), IEntry) ... scoped = getMultiAdapter((parent_entry, child_entry, request), ... IScopedCollection) ... scoped.relationship = parent_entry.schema.get(name) ... return scoped The default adapter works just fine with the collection of recipes for a cookbook. >>> scoped_collection = scope_collection(C1, C1_D1, 'recipes') >>> scoped_collection Like a regular collection, a scoped collection knows what kind of object is inside it. Recall that the 'recipes' collection of a cookbook was defined as one that contains objects with a schema of ``IRecipe``. This information is available to the ``ScopedCollection`` object. >>> scoped_collection.entry_schema Field ordering -------------- When an entry's fields are modified, it's important that the modifications happen in a deterministic order, to minimize (or at least make deterministic) bad interactions between fields. The helper function get_entry_fields_in_write_order() handles this. Ordinarily, fields are written to in the same order they are found in the underlying schema. >>> author_entry = getMultiAdapter((A1, request), IEntry) >>> from lazr.restful._resource import get_entry_fields_in_write_order >>> def print_fields_in_write_order(entry): ... for name, field in get_entry_fields_in_write_order(entry): ... print name >>> print_fields_in_write_order(author_entry) name favorite_recipe popularity The one exception is if a field is wrapped in a subclass of the Passthrough class defined by the lazr.delegates library. Classes generated through lazr.restful's annotations use a Passthrough subclass to control a field that triggers complex logic when its value changes. To minimize the risk of bad interactions, all the simple fields are changed before any of the complex fields. Here's a simple subclass of Passthrough. >>> from lazr.delegates import Passthrough >>> class MyPassthrough(Passthrough): ... pass When we replace 'favorite_recipe' with an instance of this subclass, that field shows up at the end of the list of fields. >>> old_favorite_recipe = AuthorEntry.favorite_recipe >>> AuthorEntry.favorite_recipe = MyPassthrough('favorite_recipe', A1) >>> print_fields_in_write_order(author_entry) name popularity favorite_recipe When we replace 'name' with a Passthrough subclass, it also shows up at the end--but it still shows up before 'favorite_recipe', because it comes before 'favorite_recipe' in the schema. >>> old_name = AuthorEntry.name >>> AuthorEntry.name = MyPassthrough('name', A1) >>> print_fields_in_write_order(author_entry) popularity name favorite_recipe Cleanup to restore the old AuthorEntry implementation: >>> AuthorEntry.favorite_recipe = old_favorite_recipe >>> AuthorEntry.name = old_name Custom operations ================= The ``CookbookSet`` class defines a method called 'findRecipes'. This is exposed through the cookbook collection resource as a custom operation called ``find_recipes``. Each custom operation is implemented as a class that implements ``IResourceGETOperation``. >>> from lazr.restful import ResourceGETOperation >>> from zope.publisher.interfaces.http import IHTTPApplicationRequest >>> from lazr.restful.fields import Reference >>> from lazr.restful.interfaces import IResourceGETOperation >>> class FindRecipesOperation(ResourceGETOperation): ... """An operation that searches for recipes across cookbooks.""" ... implements(IResourceGETOperation) ... adapts(ICookbookSet, IHTTPApplicationRequest) ... ... params = [ TextLine(__name__='name') ] ... return_type = CollectionField(value_type=Reference(schema=IRecipe)) ... ... def call(self, name): ... try: ... return self.context.findRecipes(name) ... except ValueError, e: ... self.request.response.setStatus(400) ... return str(e) To register the class we just defined as implementing the ``find_recipes`` operation, we need to register it as a named adapter providing ``IResourceGETOperation`` for the ``ICookbookSet`` interface. >>> sm.registerAdapter(FindRecipesOperation, name="find_recipes") The same underlying method is exposed through the recipe entry resource as a custom operation called ``find_similar_recipes``. >>> class FindSimilarRecipesOperation(ResourceGETOperation): ... """Finds recipes with the same name.""" ... implements(IResourceGETOperation) ... adapts(IRecipe, IHTTPApplicationRequest) ... params = [] ... return_type = CollectionField(value_type=Reference(schema=IRecipe)) ... ... def call(self): ... try: ... return CookbookSet().findRecipes(self.context.dish.name) ... except AssertionError, e: ... self.request.response.setStatus(400) ... return str(e) >>> sm.registerAdapter( ... FindSimilarRecipesOperation, name="find_similar_recipes") Named GET operations are read-only operations like searches, but resources can also expose named write operations, through POST. Here's a named factory operation for creating a new cookbook. >>> from lazr.restful.interfaces import IResourcePOSTOperation >>> from lazr.restful import ResourcePOSTOperation >>> class CookbookFactoryOperation(ResourcePOSTOperation): ... """An operation that creates a new cookbook.""" ... implements(IResourcePOSTOperation) ... adapts(ICookbookSet, IHTTPApplicationRequest) ... params = ( ... TextLine(__name__='author_name'), ... TextLine(__name__='title'), ... TextLine( ... __name__='cuisine', default=u'Brazilian', required=False), ... ) ... return_type = Reference(schema=IRecipe) ... ... def call(self, author_name, title, cuisine): ... cookbook = CookbookSet().newCookbook( ... author_name, title, cuisine) ... self.request.response.setStatus(201) ... self.request.response.setHeader( ... "Location", absoluteURL(cookbook, self.request)) ... return cookbook >>> sm.registerAdapter( ... CookbookFactoryOperation, name="create_cookbook") Here's a named POST operation that's not a factory operation: it makes a cookbook's cuisine sound more interesting. >>> class MakeMoreInterestingOperation(ResourcePOSTOperation): ... implements(IResourcePOSTOperation) ... adapts(ICookbook, IHTTPApplicationRequest) ... params = () ... return_type = None ... send_modification_event = True ... ... def call(self): ... cookbook = self.context ... cookbook.cuisine = "Nouvelle " + cookbook.cuisine >>> sm.registerAdapter( ... MakeMoreInterestingOperation, name="make_more_interesting") Operations are also used to implement DELETE on entries. This code implements DELETE for IRecipe objects. >>> from lazr.restful.interfaces import IResourceDELETEOperation >>> from lazr.restful import ResourceDELETEOperation >>> class RecipeDeleteOperation(ResourceDELETEOperation): ... implements(IResourceDELETEOperation) ... adapts(IRecipe, IHTTPApplicationRequest) ... params = () ... return_type = None ... ... def call(self): ... self.context.delete() >>> sm.registerAdapter( ... RecipeDeleteOperation, name="") Resource objects ================ lazr.restful ``Resource`` objects are the objects that actually handle incoming HTTP requests. There are a few very common types of HTTP resources, and LAZR defines classes for some of them. For instance, there's the "collection" resource that responds to GET (to get the items in the collection) and POST (to invoke named operations on the collection). lazr.restful implements this as a ``CollectionResource`` which uses the HTTP arguments to drive ``Collection`` methods like find(). Of course, a ``CollectionResource`` has to expose a collection _of_ something. That's why each ``CollectionResource`` is associated with some concrete implementation of ``ICollection``, like ``AuthorCollection``. All you have to do is define the behaviour of the collection, and ``CollectionResource`` takes care of exposing the collection through HTTP. Similarly, you can implement ``RecipeEntry`` to the ``IEntry`` interface, and expose it through the web as an ``EntryResource``. The Service Root Resource ========================= How are these ``Resource`` objects connected to the web? Through the ``ServiceRootResource``. This is a special resource that represents the root of the object tree. >>> from lazr.restful.interfaces import IServiceRootResource >>> from lazr.restful import ServiceRootResource >>> from zope.traversing.browser.interfaces import IAbsoluteURL >>> class MyServiceRootResource(ServiceRootResource, TraverseWithGet): ... implements(IAbsoluteURL) ... path = '' ... ... top_level_names = { ... 'dishes': DishSet(), ... 'cookbooks': CookbookSet(), ... 'authors': AuthorSet()} ... ... def get(self, request, name): ... return self.top_level_names.get(name) It's the responsibility of each web service to provide an implementation of ``IAbsoluteURL`` and ``IPublishTraverse`` for their service root resource. >>> sm.registerAdapter( ... BaseAbsoluteURL, [MyServiceRootResource, IBrowserRequest]) >>> app = MyServiceRootResource() >>> sm.registerUtility(app, IServiceRootResource) If you call the service root resource, and pass in an HTTP request, it will act as though you had performed a GET on the URL 'http://api.cookbooks.dev/beta/'. >>> webservice_configuration.root = app >>> from lazr.restful.testing.webservice import ( ... create_web_service_request) >>> request = create_web_service_request('/beta/') >>> ignore = request.traverse(app) The response document is a JSON document full of links to the top-level collections of authors, cookbooks, and dishes. It's the 'home page' for the web service. >>> import simplejson >>> response = app(request) >>> representation = simplejson.loads(unicode(response)) >>> representation["authors_collection_link"] u'http://api.cookbooks.dev/beta/authors' >>> representation["cookbooks_collection_link"] u'http://api.cookbooks.dev/beta/cookbooks' >>> representation["dishes_collection_link"] u'http://api.cookbooks.dev/beta/dishes' The standard ``absoluteURL()`` function can be used to generate URLs to content objects published on the web service. It works for the web service root, so long as you've given it an ``IAbsoluteURL`` implementation. >>> from zope.traversing.browser import absoluteURL >>> absoluteURL(app, request) 'http://api.cookbooks.dev/beta/' WADL documents ============== Every resource can serve a WADL representation of itself. The main WADL document is the WADL representation of the server root. It describes the capabilities of the web service as a whole. >>> wadl_headers = {'HTTP_ACCEPT' : 'application/vd.sun.wadl+xml'} >>> wadl_request = create_web_service_request( ... '/beta/', environ=wadl_headers) >>> wadl_resource = wadl_request.traverse(app) >>> print wadl_resource(wadl_request) ... If the resources are improperly configured, the WADL can't be generated. Here's an example, where ``DishCollection`` is registered as an adapter twice. Earlier it was registered as the adapter for ``IDishSet``; here it's also registered as the adapter for ``IAuthorSet``. The WADL generation doesn't know whether to describe ``DishCollection`` using the named operations defined against ``IAuthorSet`` or the named operations defined against ``IDishSet``, so there's an ``AssertionError``. >>> sm.registerAdapter(DishCollection, [IAuthorSet], ICollection) >>> print wadl_resource(wadl_request) Traceback (most recent call last): ... AssertionError: There must be one (and only one) adapter from DishCollection to ICollection. Collection resources ==================== The default root navigation defined in our model contains the top-level Set objects that should be published. When these sets are published on the web service, they will we wrapped in the appropriate ``CollectionResource``. The following example is equivalent to requesting 'http://api.cookbooks.dev/cookbooks/'. The code will traverse to the ``CookbookSet`` published normally at '/cookbooks' and it will be wrapped into a ``CollectionResource``. >>> request = create_web_service_request('/beta/cookbooks') >>> collection = request.traverse(app) >>> collection Calling the collection resource yields a JSON document, which can be parsed with standard tools. >>> def load_json(s): ... """Convert a JSON string to Unicode and then load it.""" ... return simplejson.loads(unicode(s)) >>> representation = load_json(collection()) >>> representation['resource_type_link'] u'http://api.cookbooks.dev/beta/#cookbooks' Pagination ========== ``Collections`` are paginated and served one page at a time. This particular collection is small enough to fit on one page; it's only got three entries. >>> sorted(representation.keys()) [u'entries', u'resource_type_link', u'start', u'total_size'] >>> len(representation['entries']) 3 >>> representation['total_size'] 3 But if we ask for a page size of two, we can see how pagination works. Here's page one, with two cookbooks on it. >>> request = create_web_service_request( ... '/beta/cookbooks', environ={'QUERY_STRING' : 'ws.size=2'}) >>> collection = request.traverse(app) >>> representation = load_json(collection()) >>> sorted(representation.keys()) [u'entries', u'next_collection_link', u'resource_type_link', u'start', u'total_size'] >>> representation['next_collection_link'] u'http://api.cookbooks.dev/beta/cookbooks?ws.start=2&ws.size=2' >>> len(representation['entries']) 2 >>> representation['total_size'] 3 Follow the ``next_collection_link`` and you'll end up at page two, which has the last cookbook on it. >>> request = create_web_service_request( ... '/beta/cookbooks', ... environ={'QUERY_STRING' : 'ws.start=2&ws.size=2'}) >>> collection = request.traverse(app) >>> representation = load_json(collection()) >>> sorted(representation.keys()) [u'entries', u'prev_collection_link', u'resource_type_link', u'start', u'total_size'] >>> representation['prev_collection_link'] u'http://api.cookbooks.dev/beta/cookbooks?ws.start=0&ws.size=2' >>> len(representation['entries']) 1 Custom operations ================= A collection may also expose a number of custom operations through GET. The cookbook collection exposes a custom GET operation called ``find_recipes``, which searches for recipes with a given name across cookbooks. >>> request = create_web_service_request( ... '/beta/cookbooks', ... environ={'QUERY_STRING' : ... 'ws.op=find_recipes&name=Roast%20chicken'}) >>> operation_resource = request.traverse(app) >>> chicken_recipes = load_json(operation_resource()) >>> sorted([c['instructions'] for c in chicken_recipes['entries']]) [u'A perfectly roasted chicken is...', u'Draw, singe, stuff, and truss...', u'You can always judge...'] Custom operations may include custom error checking. Error messages are passed along to the client. >>> request = create_web_service_request( ... '/beta/cookbooks', ... environ={'QUERY_STRING' : ... 'ws.op=find_recipes&name=NoSuchRecipe'}) >>> operation_resource = request.traverse(app) >>> print operation_resource() No matches for NoSuchRecipe Collections may also support named POST operations. These requests have two effects on the server side: they modify the dataset, and they may also trigger event notifications. Here are two simple handlers set up to print a message whenever we modify a cookbook or the cookbook set. >>> def modified_cookbook(object, event): ... """Print a message when triggered.""" ... print "You just modified a cookbook." >>> from lazr.lifecycle.interfaces import IObjectModifiedEvent >>> from lazr.restful.testing.event import TestEventListener >>> cookbook_listener = TestEventListener( ... ICookbook, IObjectModifiedEvent, modified_cookbook) >>> def modified_cookbook_set(object, event): ... """Print a message when triggered.""" ... print "You just modified the cookbook set." Here we create a new cookbook for an existing author. Because the operation's definition doesn't set send_modified_event to True, no event will be sent and modified_cookbook_set() won't be called. >>> body = ("ws.op=create_cookbook&title=Beard%20on%20Bread&" ... "author_name=James%20Beard") >>> request = create_web_service_request( ... '/beta/cookbooks', 'POST', body, ... {'CONTENT_TYPE' : 'application/x-www-form-urlencoded'}) >>> operation_resource = request.traverse(app) >>> result = operation_resource() >>> request.response.getStatus() 201 >>> request.response.getHeader('Location') 'http://api.cookbooks.dev/beta/cookbooks/Beard%20on%20Bread' Here we create a cookbook for a new author. >>> body = ("ws.op=create_cookbook&title=Everyday%20Greens&" ... "author_name=Deborah%20Madison") >>> request = create_web_service_request( ... '/beta/cookbooks', 'POST', body, ... {'CONTENT_TYPE' : 'application/x-www-form-urlencoded'}) >>> operation_resource = request.traverse(app) >>> result = operation_resource() >>> request.response.getStatus() 201 >>> request.response.getHeader('Location') 'http://api.cookbooks.dev/beta/cookbooks/Everyday%20Greens' The new Author object is created implicitly and is published as a resource afterwards. >>> path = '/beta/authors/Deborah%20Madison' >>> request = create_web_service_request(path) >>> author = request.traverse(app) >>> load_json(author())['name'] u'Deborah Madison' Here we modify a cookbook's cuisine using a named operation. Because this operation's definition does set send_modified_event to True, an event will be sent and modified_cookbook_set() will be called. >>> body = "ws.op=make_more_interesting" >>> request = create_web_service_request( ... '/beta/cookbooks/Everyday%20Greens', 'POST', body, ... {'CONTENT_TYPE' : 'application/x-www-form-urlencoded'}) >>> operation_resource = request.traverse(app) >>> result = operation_resource() You just modified a cookbook. >>> request.response.getStatus() 200 >>> path = '/beta/cookbooks/Everyday%20Greens' >>> request = create_web_service_request(path) >>> cookbook = request.traverse(app) >>> load_json(cookbook())['cuisine'] u'Nouvelle Brazilian' Entry resources =============== The collection resource is a list of entries. Each entry has some associated information (like 'name'), a ``self_link`` (the URL to the entry's resource), and possibly links to associated resources. >>> import operator >>> request = create_web_service_request('/beta/cookbooks') >>> collection = request.traverse(app) >>> representation = load_json(collection()) >>> entries = sorted(representation['entries'], ... key=operator.itemgetter('name')) >>> entries[0]['self_link'] u'http://api.cookbooks.dev/beta/cookbooks/Beard%20on%20Bread' Regular data fields are exposed with their given names. The 'name' field stays 'name'. >>> entries[0]['name'] u'Beard on Bread' Fields that are references to other objects -- ``Object``, ``Reference``, and ``ReferenceChoice`` -- are exposed as links to those objects. Each cookbook has such a link to its author. >>> entries[0]['author_link'] u'http://api.cookbooks.dev/beta/authors/James%20Beard' Fields that are references to externally hosted files (Bytes) are also exposed as links to those files. Each cookbook has such a link to its cover image. >>> entries[0]['cover_link'] u'http://api.cookbooks.dev/beta/cookbooks/Beard%20on%20Bread/cover' Fields that are references to collections of objects are exposed as links to those collections. Each cookbook has such a link to its recipes. >>> entries[0]['recipes_collection_link'] u'http://api.cookbooks.dev/beta/cookbooks/Beard%20on%20Bread/recipes' Calling the ``CollectionResource`` object makes it process the incoming request. Since this is a GET request, calling the resource publishes the resource to the web. A ``CollectionResource`` is made up of a bunch of ``EntryResources``, and the base ``EntryResource`` class knows how to use the entry schema class (in this case, ``IRecipeEntry``) to publish a JSON document. The same way collections are wrapped into ``CollectionResource``, navigating to an object that has an ``IEntry`` adapter, will wrap it into an ``EntryResource``. For instance, creating a new cookbook and making a request to its URL will wrap it into an ``EntryResource``. >>> body = ("ws.op=create_cookbook&title=Feijoada&" ... "author_name=Fernando%20Yokota") >>> request = create_web_service_request( ... '/beta/cookbooks', 'POST', body, ... {'CONTENT_TYPE' : 'application/x-www-form-urlencoded'}) >>> operation_resource = request.traverse(app) >>> result = operation_resource() >>> request.response.getHeader('Location') 'http://api.cookbooks.dev/beta/cookbooks/Feijoada' >>> request = create_web_service_request('/beta/cookbooks/Feijoada') >>> feijoada_resource = request.traverse(app) >>> feijoada_resource >>> feijoada = load_json(feijoada_resource()) Notice how the request above didn't specify the book's cuisine, but since that is not a required field our application used the default value (Brazilian) specified in ``CookbookFactoryOperation`` for it. >>> sorted(feijoada.items()) [(u'author_link', u'http://api.cookbooks.dev/beta/authors/Fernando%20Yokota'), (u'comments_collection_link', u'http://api.cookbooks.dev/beta/cookbooks/Feijoada/comments'), (u'cover_link', u'http://api.cookbooks.dev/beta/cookbooks/Feijoada/cover'), (u'cuisine', u'Brazilian'), (u'http_etag', u'...'), (u'name', u'Feijoada'), (u'recipes_collection_link', u'http://api.cookbooks.dev/beta/cookbooks/Feijoada/recipes'), (u'resource_type_link', u'http://api.cookbooks.dev/beta/#cookbook'), (u'self_link', u'http://api.cookbooks.dev/beta/cookbooks/Feijoada')] You can also traverse from an entry to an item in a scoped collection: >>> request = create_web_service_request( ... quote('/beta/cookbooks/The Joy of Cooking/recipes/Roast chicken')) >>> chicken_recipe_resource = request.traverse(app) >>> chicken_recipe = load_json(chicken_recipe_resource()) >>> sorted(chicken_recipe.items()) [(u'comments_collection_link', u'http://api...Joy%20of%20Cooking/recipes/Roast%20chicken/comments'), (u'cookbook_link', u'http://api.cookbooks.dev/beta/cookbooks/The%20Joy%20of%20Cooking'), (u'dish_link', u'http://api.cookbooks.dev/beta/dishes/Roast%20chicken'), (u'http_etag', u'...'), (u'instructions', u'Draw, singe, stuff, and truss...'), (u'self_link', u'http://api.../The%20Joy%20of%20Cooking/recipes/Roast%20chicken')] Another example traversing to a comment: >>> roast_chicken_comments_url = quote( ... '/beta/cookbooks/The Joy of Cooking/recipes/Roast chicken/comments') >>> request = create_web_service_request(roast_chicken_comments_url) >>> comments_resource = request.traverse(app) >>> comments = load_json(comments_resource()) >>> [c['text'] for c in comments['entries']] [u'Clear and concise.'] >>> request = create_web_service_request( ... roast_chicken_comments_url + '/1') >>> comment_one_resource = request.traverse(app) >>> comment_one = load_json(comment_one_resource()) >>> sorted(comment_one.items()) [(u'http_etag', u'...'), (u'resource_type_link', u'http://api.cookbooks.dev/beta/#comment'), (u'self_link', u'http://api...Joy%20of%20Cooking/recipes/Roast%20chicken/comments/1'), (u'text', u'Clear and concise.')] An entry may expose a number of custom operations through GET. The recipe entry exposes a custom GET operation called 'find_similar_recipes', which searches for recipes with the same name across cookbooks. >>> request = create_web_service_request( ... '/beta/cookbooks/The%20Joy%20of%20Cooking/recipes/Roast%20chicken', ... environ={'QUERY_STRING' : 'ws.op=find_similar_recipes'}) >>> operation_resource = request.traverse(app) >>> chicken_recipes = load_json(operation_resource()) >>> sorted([c['instructions'] for c in chicken_recipes['entries']]) [u'A perfectly roasted chicken is...', u'Draw, singe, stuff, and truss...', u'You can always judge...'] Named operation return values ============================= The return value of a named operation is serialized to a JSON data structure, and the response's Content-Type header is set to application/json. These examples show how different return values are serialized. >>> class DummyOperation(ResourceGETOperation): ... ... params = () ... result = None ... return_type = None ... ... def call(self): ... return self.result >>> def make_dummy_operation_request(result): ... request = create_web_service_request('/beta/') ... ignore = request.traverse(app) ... operation = DummyOperation(None, request) ... operation.result = result ... return request, operation Scalar Python values like strings and booleans are serialized as you'd expect. >>> request, operation = make_dummy_operation_request("A string.") >>> print operation() "A string." >>> request.response.getStatus() 200 >>> print request.response.getHeader('Content-Type') application/json >>> request, operation = make_dummy_operation_request(True) >>> operation() 'true' >>> request, operation = make_dummy_operation_request(10) >>> operation() '10' >>> request, operation = make_dummy_operation_request(None) >>> operation() 'null' >>> request, operation = make_dummy_operation_request(1.3) >>> operation() '1.3' When a named operation returns an object that has an ``IEntry`` implementation, the object is serialized to a JSON hash. >>> request, operation = make_dummy_operation_request(D2) >>> operation() '{...}' A named operation can return a data structure that incorporates objects with ``IEntry`` implementations. Here's a dictionary that contains a ``Dish`` object. The ``Dish`` object is serialized as a JSON dictionary within the larger dictionary. >>> request, operation = make_dummy_operation_request({'dish': D2}) >>> operation() '{"dish": {...}}' When a named operation returns a list or tuple of objects, we serve the whole thing as a JSON list. >>> request, operation = make_dummy_operation_request([1,2,3]) >>> operation() '[1, 2, 3]' >>> request, operation = make_dummy_operation_request((C1_D1, C2_D1)) >>> operation() '[{...}, {...}]' When a named operation returns a non-builtin object that provides the iterator protocol, we don't return the whole list. The object probably provides access to a potentially huge dataset, like a list of database results. In this case we do the same thing we do when serving a collection resource. We fetch one batch of results and represent it as a JSON hash containing a list of entries. >>> class DummyResultSet(object): ... results = [C1_D1, C2_D1] ... ... def __iter__(self): ... return iter(self.results) ... ... def __len__(self): ... return len(self.results) ... ... def __getitem__(self, index): ... return self.results[index] >>> recipes = DummyResultSet() >>> request, operation = make_dummy_operation_request(recipes) >>> operation() '{"total_size": 2, ... "entries": [{...}, {...}]}' When a named operation returns an object that has an ``ICollection`` implementation, the result is similar: we return a JSON hash describing one batch from the collection. >>> request, operation = make_dummy_operation_request(DishSet()) >>> operation() '{"total_size": ..., "start": ...}' If the return value can't be converted into JSON, you'll get an exception. >>> request, operation = make_dummy_operation_request(object()) >>> operation() Traceback (most recent call last): ... TypeError: Could not serialize object to JSON. >>> request, operation = make_dummy_operation_request( ... {'anobject' : object()}) >>> operation() Traceback (most recent call last): ... TypeError: Could not serialize object {'anobject': } to JSON. >>> request, operation = make_dummy_operation_request([object()]) >>> operation() Traceback (most recent call last): ... TypeError: Could not serialize object [] to JSON. ETags ===== Every entry resource has a short opaque string called an ETag that summarizes the resource's current state. The ETag is sent as the response header 'ETag'. >>> julia_object = A1 >>> julia_url = quote('/beta/authors/Julia Child') >>> get_request = create_web_service_request(julia_url) >>> ignored = get_request.traverse(app)() >>> etag_original = get_request.response.getHeader('ETag') The ETag is different across revisions of the software, but within a release it'll always the same for a given resource with a given state. >>> get_request = create_web_service_request(julia_url) >>> ignored = get_request.traverse(app)() >>> etag_after_get = get_request.response.getHeader('ETag') >>> etag_after_get == etag_original True A client can use a previously obtained ETag as the value of If-None-Match when making a request. If the ETags match, it means the resource hasn't changed since the client's last request. The server sends a response code of 304 ("Not Modified") instead of sending the same representation again. First, let's define a helper method to request a specific entry resource, and gather the entity-body and the response object into an easily accessible data structure. >>> def get_julia(etag=None): ... headers = {'CONTENT_TYPE' : 'application/json'} ... if etag is not None: ... headers['HTTP_IF_NONE_MATCH'] = etag ... get_request = create_web_service_request( ... julia_url, environ=headers) ... entity_body = get_request.traverse(app)() ... return dict(entity_body=entity_body, ... response=get_request.response) >>> print get_julia(etag_original)['response'].getStatus() 304 If the ETags don't match, the server assumes the client has an old representation, and sends the new representation. >>> print get_julia('bad etag')['entity_body'] {...} Change the state of the resource, and the ETag changes. >>> julia_object.favorite_recipe = C2_D2 >>> etag_after_modification = get_julia()['response'].getHeader('ETag') >>> etag_after_modification == etag_original False The client can't modify read-only fields, but they might be modified behind the scenes. If one of them changes, the ETag will change. >>> julia_object.popularity = 5 >>> etag_after_readonly_change = get_julia()['response'].getHeader( ... 'ETag') >>> etag_after_readonly_change == etag_original False compensate_for_mod_compress_etag_modification --------------------------------------------- Apache's mod_compress transparently modifies outgoing ETags, but doesn't remove the modifications when the ETags are sent back in. The configuration setting 'compensate_for_mod_compress_etag_modification' makes lazr.restful compensate for this behavior, so that you can use mod_compress to save bandwidth. Different versions of mod_compress modify outgoing ETags in different ways. lazr.restful handles both cases. >>> etag = get_julia()['response'].getHeader('ETag') >>> modified_etag_1 = etag + '-gzip' >>> modified_etag_2 = etag[:-1] + '-gzip' + etag[-1] Under normal circumstances, lazr.restful won't recognize an ETag modified by mod_compress. >>> print get_julia(modified_etag_1)['entity_body'] {...} When 'compensate_for_mod_compress_etag_modification' is set, lazr.restful will recognize an ETag modified by mod_compress. >>> c = webservice_configuration >>> print c.compensate_for_mod_compress_etag_modification False >>> c.compensate_for_mod_compress_etag_modification = True >>> print get_julia(modified_etag_1)['response'].getStatus() 304 >>> print get_julia(modified_etag_2)['response'].getStatus() 304 Of course, that doesn't mean lazr.restful will recognize any random ETag. >>> print get_julia(etag + "-not-gzip")['entity_body'] {...} Cleanup. >>> c.compensate_for_mod_compress_etag_modification = False Resource Visibility =================== Certain resources might not be visible to every user. In this example, certain recipes have been designated as private and can't be seen by unauthenticated users. For demonstration purposes, the recipe for "Baked beans" in "James Beard's American Cookery" has been marked as private. An unauthorized attempt to GET this resource will result in an error. >>> private_recipe_url = quote( ... "/beta/cookbooks/James Beard's American Cookery/recipes/" ... "Baked beans") >>> get_request = create_web_service_request(private_recipe_url) >>> recipe_resource = get_request.traverse(app) Traceback (most recent call last): ... Unauthorized: (, 'dish', ...) The recipe will not show up in collections: >>> recipes_url = quote( ... "/beta/cookbooks/James Beard's American Cookery/recipes") >>> get_request = create_web_service_request(recipes_url) >>> collection_resource = get_request.traverse(app) >>> collection = load_json(collection_resource()) The web service knows about two recipes from James Beard's American Cookery, but an unauthorized user can only see one of them. >>> len(collection['entries']) 1 Note that the 'total_size' of the collection is slightly inaccurate, having been generated before invisible recipes were filtered out. >>> collection['total_size'] 2 As it happens, the author "James Beard" has his 'favorite_recipe' attribute set to the "Baked beans" recipe. But an unauthorized user can't see anything about that recipe, not even its URL. >>> beard_url = quote('/beta/authors/James Beard') >>> get_request = create_web_service_request(beard_url) >>> author_resource = get_request.traverse(app) >>> author = load_json(author_resource()) The author's name is public information, so it's visible. But the link to his favorite recipe has been redacted. >>> author['name'] u'James Beard' >>> author['favorite_recipe_link'] u'tag:launchpad.net:2008:redacted' It's possible to use a representation that contains redacted information when sending a PUT or PATCH request back to the server. The server will know that the client isn't actually trying to set the field value to 'tag:launchpad.net:2008:redacted'. >>> headers = {'CONTENT_TYPE' : 'application/json'} >>> body = simplejson.dumps(author) >>> put_request = create_web_service_request( ... beard_url, body=body, environ=headers, method='PUT') >>> put_request.traverse(app)() '{...}' And since no special permission is necessary to _change_ a person's 'favorite_recipe', it's possible to set it to a visible recipe using PUT, even when its current value is redacted. >>> author['favorite_recipe_link'] = 'http://' + quote( ... 'api.cookbooks.dev/beta/cookbooks/' ... 'The Joy of Cooking/recipes/Roast chicken') >>> body = simplejson.dumps(author) >>> put_request = create_web_service_request( ... beard_url, body=body, environ=headers, method='PUT') >>> put_request.traverse(app)() '{...}' After that PUT, James Beard's 'favorite_recipe' attribute is no longer redacted. It's the value set by the PUT request. >>> get_request = create_web_service_request(beard_url) >>> author_resource = get_request.traverse(app) >>> author = load_json(author_resource()) >>> author['favorite_recipe_link'] u'http://api.cookbooks.dev/beta/cookbooks/The%20Joy%20of%20Cooking/recipes/Roast%20chicken' Finally, you can't set an attribute to a value that you wouldn't have permission to see: >>> author['favorite_recipe_link'] = ( ... 'http://api.cookbooks.dev' + private_recipe_url) >>> body = simplejson.dumps(author) >>> put_request = create_web_service_request( ... beard_url, body=body, environ=headers, method='PUT') >>> print put_request.traverse(app)() (, 'dish', ...) >>> print put_request.response.getStatus() 401 Stored file resources ===================== Binary files, such as the covers of cookbooks, are stored on an external server, but they have addresses within the web service. The mapping of binary resources to the actual hosting of them is handled through the ``IByteStorage`` interface. For this example, let's define simple implementation that serves all files from the /files path. >>> from lazr.restful.interfaces import IByteStorage >>> from lazr.restful.example.base.interfaces import ( ... IFileManagerBackedByteStorage) >>> from lazr.restful.example.base.root import SimpleByteStorage >>> protect_schema(SimpleByteStorage, IFileManagerBackedByteStorage) >>> sm.registerAdapter(SimpleByteStorage, provided=IByteStorage) A newly created cookbook has no cover. >>> cover_url = quote('/beta/cookbooks/The Joy of Cooking/cover') >>> get_request = create_web_service_request(cover_url) >>> file_resource = get_request.traverse(app) >>> file_resource() Traceback (most recent call last): ... NotFound: ... name: 'cover' >>> print C2.cover None A cookbook can be given a cover with PUT. >>> headers = {'CONTENT_TYPE' : 'image/png'} >>> body = 'Pretend this is an image.' >>> put_request = create_web_service_request( ... cover_url, body=body, environ=headers, method='PUT') >>> file_resource = put_request.traverse(app) >>> file_resource() >>> C2.cover.representation 'Pretend...' At this point it exists: >>> get_request = create_web_service_request(cover_url) >>> file_resource = get_request.traverse(app) >>> file_resource() >>> get_request.response.getStatus() 303 >>> print get_request.response.getHeader('Location') http://cookbooks.dev/.../filemanager/0 The cover can be deleted with DELETE. >>> delete_request = create_web_service_request( ... cover_url, method='DELETE') >>> file_resource = delete_request.traverse(app) >>> file_resource() >>> get_request = create_web_service_request(cover_url) >>> file_resource = get_request.traverse(app) >>> file_resource() Traceback (most recent call last): ... NotFound: ... name: 'cover' >>> print C2.cover None Field resources =============== An entry's primitive data fields are exposed as subordinate resources. >>> field_resource = create_web_service_request( ... '/beta/cookbooks/The%20Joy%20of%20Cooking/name').traverse(app) >>> print field_resource() "The Joy of Cooking" Requesting non available resources ================================== If the user tries to traverse to a nonexistent object, the result is a NotFound exception. Requesting a non-existent top-level collection: >>> create_web_service_request('/beta/nosuchcollection').traverse(app) Traceback (most recent call last): ... NotFound: ... name: u'nosuchcollection' Requesting a non-existent cookbook: >>> create_web_service_request('/beta/cookbooks/104').traverse(app) Traceback (most recent call last): ... NotFound: ... name: u'104' Requesting a non-existent comment: >>> create_web_service_request( ... '/beta/cookbooks/The%20Joy%20of%20Cooking/comments/10').traverse(app) Traceback (most recent call last): ... NotFound: ... name: u'comments/10' Manipulating entries ==================== Most entry resources support write operations by responding to PATCH requests. The entity-body of a PATCH request should be a JSON document with new values for some of the entry's attributes. Basically, a set of assertions about what the object *should* look like. A PATCH request will automatically result in a modification event being sent out about the modified object, which means that modify_cookbook() will be run. Here, we modify the name and the cuisine of one of the cookbooks. Note that the cuisine contains non-ASCII characters. >>> headers = {'CONTENT_TYPE' : 'application/json'} >>> body = '''{"name" : "The Joy of Cooking (revised)", ... "cuisine" : "\xd7\x97\xd7\x95\xd7\x9e\xd7\x95\xd7\xa1"}''' >>> patch_request = create_web_service_request( ... '/beta/cookbooks/The%20Joy%20of%20Cooking', body=body, ... environ=headers, method='PATCH') >>> joy_resource_patch = patch_request.traverse(app) >>> joy_resource_patch() You just modified a cookbook. '' >>> patch_request.response.getHeader('Location') 'http://api.../cookbooks/The%20Joy%20of%20Cooking%20%28revised%29' The new name is reflected in the cookbook's representation, and the cookbook's URL has changed as well. >>> request = create_web_service_request( ... '/beta/cookbooks/The%20Joy%20of%20Cooking%20%28revised%29') >>> joy_resource = request.traverse(app) >>> joy = load_json(joy_resource()) >>> joy['name'] u'The Joy of Cooking (revised)' An entry that responds to PATCH will also respond to PUT. With PUT you modify the document you got in response to a GET request, and send the whole thing back to the server, whereas with PATCH you're creating a new document that describes a subset of the entry's state. Here, we use PUT to change the cookbook's name back to what it was before. Note that we send the entire dictionary back to the server. Note also that another modification event is sent out and intercepted by the modified_cookbook() listener. >>> joy['name'] = 'The Joy of Cooking' >>> body = simplejson.dumps(joy) >>> put_request = create_web_service_request( ... '/beta/cookbooks/The%20Joy%20of%20Cooking%20%28revised%29', ... body=body, environ=headers, method='PUT') >>> joy_resource_put = put_request.traverse(app) >>> joy_resource_put() You just modified a cookbook. '' Now that we've proved our point, let's disable the event handler so it doesn't keep printing those messages. >>> cookbook_listener.unregister() The cookbook's URL has changed back to what it was before. >>> put_request.response.getStatus() 301 >>> put_request.response.getHeader('Location') 'http://api.cookbooks.dev/beta/cookbooks/The%20Joy%20of%20Cooking' So has the cookbook's name. >>> joy_resource = create_web_service_request( ... '/beta/cookbooks/The%20Joy%20of%20Cooking').traverse(app) >>> joy = load_json(joy_resource()) >>> joy['name'] u'The Joy of Cooking' It's also possible to change the relationships between objects. Here, we change a cookbook's author. Since all objects are identified by their URLs, we make the change by modifying the cookbook's 'author_link' field to point to another author. >>> def change_joy_author(new_author_link, host='api.cookbooks.dev'): ... representation = {'author_link' : new_author_link} ... resource = create_web_service_request( ... '/beta/cookbooks/The%20Joy%20of%20Cooking', ... body=simplejson.dumps(representation), environ=headers, ... method='PATCH', hostname=host).traverse(app) ... return resource() >>> path = '/beta/authors/Julia%20Child' >>> change_joy_author(u'http://api.cookbooks.dev' + path) '{...}' >>> joy_resource = create_web_service_request( ... '/beta/cookbooks/The%20Joy%20of%20Cooking').traverse(app) >>> joy = load_json(joy_resource()) >>> joy['author_link'] u'http://api.cookbooks.dev/beta/authors/Julia%20Child' When identifying an object by URL, make sure the hostname of your URL matches the hostname you're requesting. If they don't match, your request will fail. >>> print change_joy_author(u'http://not.the.same.host' + path) author_link: No such object... One possible source of hostname mismatches is the HTTP port. If the web service is served from a strange port, you'll need to specify that port in the URLs you send. >>> print change_joy_author(u'http://api.cookbooks.dev' + path, ... host='api.cookbooks.dev:9000') author_link: No such object... >>> print change_joy_author(u'http://api.cookbooks.dev:9000' + path, ... host='api.cookbooks.dev:9000') {...} You don't have to specify the default port in the URLs you send, even if you specified it when you made the request. >>> print change_joy_author(u'http://api.cookbooks.dev' + path, ... host='api.cookbooks.dev:80') {...} >>> print change_joy_author(u'http://api.cookbooks.dev:80' + path, ... host='api.cookbooks.dev') {...} >>> print change_joy_author(u'https://api.cookbooks.dev' + path, ... host='api.cookbooks.dev:443') author_link: No such object... >>> webservice_configuration.use_https = True >>> print change_joy_author(u'https://api.cookbooks.dev' + path, ... host='api.cookbooks.dev:443') {...} >>> webservice_configuration.use_https = False If an entry has an IResourceDELETEOperation registered for it, you can activate that operation and delete the entry by sending a DELETE request. >>> from urllib import quote >>> recipe_url = quote('/beta/cookbooks/Mastering the Art of ' ... 'French Cooking/recipes/Foies de voilaille en aspic') Now you see it... >>> resource = create_web_service_request( ... recipe_url, method='GET').traverse(app) >>> print resource() {...} >>> resource = create_web_service_request( ... recipe_url, method='DELETE').traverse(app) >>> ignored = resource() Now you don't. >>> resource = create_web_service_request( ... recipe_url, method='GET').traverse(app) Traceback (most recent call last): ... NotFound: ... name: u'recipes/Foies de voilaille en aspic' Within a template ================= A number of TALES adapters give different views on resource objects. The is_entry() function is a conditional that returns true when given an object that can be adapted to IEntry. >>> from lazr.restful.testing.tales import test_tales >>> test_tales("context/webservice:is_entry", context=A1) True >>> test_tales("context/webservice:is_entry", context=AUTHORS) False The json() function converts generic Python data structures to JSON, as well as objects that can be adapted to IEntry. It converts markup characters (<, >, &) into their respective Unicode escape sequences, since entities within