diff options
| author | Barry Warsaw | 2010-03-03 22:23:39 -0500 |
|---|---|---|
| committer | Barry Warsaw | 2010-03-03 22:23:39 -0500 |
| commit | 088bc1bbb2f21bdfb7df9a52fde841323263d541 (patch) | |
| tree | ea7d2dab552b973cd9e3055a2287ac51a774cda9 /src | |
| parent | 392f53559b0baacab1fc4cf48d9452f42d318565 (diff) | |
| parent | 1bee98cb58461121af5c4482beb247c6cd503a20 (diff) | |
| download | mailman-088bc1bbb2f21bdfb7df9a52fde841323263d541.tar.gz mailman-088bc1bbb2f21bdfb7df9a52fde841323263d541.tar.zst mailman-088bc1bbb2f21bdfb7df9a52fde841323263d541.zip | |
Diffstat (limited to 'src')
39 files changed, 1006 insertions, 1018 deletions
diff --git a/src/mailman/__init__.py b/src/mailman/__init__.py index 6bb4e0933..5682e46fd 100644 --- a/src/mailman/__init__.py +++ b/src/mailman/__init__.py @@ -28,13 +28,6 @@ import os import sys -# lazr.restful uses the sha module, but that's deprecated in Python 2.6 in -# favor of the hashlib module. -import warnings -warnings.filterwarnings( - 'ignore', category=DeprecationWarning, module='lazr.restful._resource') - - # This is a namespace package. try: import pkg_resources diff --git a/src/mailman/app/lifecycle.py b/src/mailman/app/lifecycle.py index 829112791..2b063d21d 100644 --- a/src/mailman/app/lifecycle.py +++ b/src/mailman/app/lifecycle.py @@ -47,7 +47,21 @@ log = logging.getLogger('mailman.error') def create_list(fqdn_listname, owners=None): - """Create the named list and apply styles.""" + """Create the named list and apply styles. + + The mailing may not exist yet, but the domain specified in `fqdn_listname` + must exist. + + :param fqdn_listname: The fully qualified name for the new mailing list. + :type fqdn_listname: string + :param owners: The mailing list owners. + :type owners: list of string email addresses + :return: The new mailing list. + :rtype: `IMailingList` + :raises `BadDomainSpecificationError`: when the hostname part of + `fqdn_listname` does not exist. + :raises `ListAlreadyExistsError`: when the mailing list already exists. + """ if owners is None: owners = [] validate(fqdn_listname) diff --git a/src/mailman/bin/mailman.py b/src/mailman/bin/mailman.py index 2bec7a1d4..e8e9f38ac 100644 --- a/src/mailman/bin/mailman.py +++ b/src/mailman/bin/mailman.py @@ -47,8 +47,11 @@ def main(): Copyright 1998-2010 by the Free Software Foundation, Inc. http://www.list.org """), - formatter_class=argparse.RawDescriptionHelpFormatter, - version=MAILMAN_VERSION_FULL) + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument( + '-v', '--version', + action='version', version=MAILMAN_VERSION_FULL, + help=_('Print this version string and exit')) parser.add_argument( '-C', '--config', help=_("""\ diff --git a/src/mailman/bin/qrunner.py b/src/mailman/bin/qrunner.py index 7f5f321f5..d75eea4e5 100644 --- a/src/mailman/bin/qrunner.py +++ b/src/mailman/bin/qrunner.py @@ -28,6 +28,7 @@ __all__ = [ import sys import signal import logging +import traceback from mailman.config import config from mailman.core.i18n import _ @@ -154,8 +155,8 @@ def make_qrunner(name, slice, range, once=False): if config.options.options.subproc: # Exit with SIGTERM exit code so the master watcher won't try to # restart us. - print >> sys.stderr, _('Cannot import runner module: $module_name') - print >> sys.stderr, error + print >> sys.stderr, _('Cannot import runner module: $class_path') + traceback.print_exc() sys.exit(signal.SIGTERM) else: raise diff --git a/src/mailman/commands/cli_lists.py b/src/mailman/commands/cli_lists.py index 0882e1030..38c1823a1 100644 --- a/src/mailman/commands/cli_lists.py +++ b/src/mailman/commands/cli_lists.py @@ -133,12 +133,12 @@ class Create: self.parser = parser command_parser.add_argument( '--language', - type='unicode', metavar='CODE', help=_("""\ + type=unicode, metavar='CODE', help=_("""\ Set the list's preferred language to CODE, which must be a registered two letter language code.""")) command_parser.add_argument( '-o', '--owner', - type='unicode', action='append', default=[], + type=unicode, action='append', default=[], dest='owners', metavar='OWNER', help=_("""\ Specify a listowner email address. If the address is not currently registered with Mailman, the address is registered and diff --git a/src/mailman/config/configure.zcml b/src/mailman/config/configure.zcml index 5e8de8527..a7d12e7a2 100644 --- a/src/mailman/config/configure.zcml +++ b/src/mailman/config/configure.zcml @@ -2,7 +2,7 @@ <configure xmlns="http://namespaces.zope.org/zope"> - <include package="mailman.rest" file="configure.zcml"/> + <include package="zope.component" file="meta.zcml"/> <adapter for="mailman.interfaces.mailinglist.IMailingList" @@ -56,4 +56,9 @@ factory="mailman.app.registrar.Registrar" /> + <utility + factory="mailman.rest.adapters.SubscriptionService" + provides="mailman.interfaces.membership.ISubscriptionService" + /> + </configure> diff --git a/src/mailman/config/schema.cfg b/src/mailman/config/schema.cfg index 824c265fe..4f3475b3f 100644 --- a/src/mailman/config/schema.cfg +++ b/src/mailman/config/schema.cfg @@ -291,13 +291,13 @@ port: 8001 # Whether or not requests to the web service are secured through SSL. use_https: no -# Default view permission for the admin web service. -view_permission: None - # Whether or not to show tracebacks in an HTTP response for a request that # raised an exception. show_tracebacks: yes +# The API version number for the current API. +api_version: 3.0 + [language.master] # Template for language definitions. The section name must be [language.xx] diff --git a/src/mailman/core/system.py b/src/mailman/core/system.py index b848fbdb7..6048fa187 100644 --- a/src/mailman/core/system.py +++ b/src/mailman/core/system.py @@ -28,7 +28,6 @@ __all__ = [ import sys from zope.interface import implements -from zope.location.interfaces import ILocation from mailman import version from mailman.interfaces.system import ISystem @@ -36,7 +35,7 @@ from mailman.interfaces.system import ISystem class System: - implements(ISystem, ILocation) + implements(ISystem) @property def mailman_version(self): diff --git a/src/mailman/interfaces/address.py b/src/mailman/interfaces/address.py index 2b074ddab..d54ea64c3 100644 --- a/src/mailman/interfaces/address.py +++ b/src/mailman/interfaces/address.py @@ -30,7 +30,6 @@ __all__ = [ ] -from lazr.restful.declarations import error_status from zope.interface import Interface, Attribute from mailman.interfaces.errors import MailmanError @@ -53,7 +52,6 @@ class AddressNotLinkedError(AddressError): """The address is not linked to the user.""" -@error_status(400) class InvalidEmailAddressError(AddressError): """Email address is invalid.""" diff --git a/src/mailman/interfaces/domain.py b/src/mailman/interfaces/domain.py index 340cddd79..b7fc1c91f 100644 --- a/src/mailman/interfaces/domain.py +++ b/src/mailman/interfaces/domain.py @@ -23,62 +23,44 @@ __metaclass__ = type __all__ = [ 'BadDomainSpecificationError', 'IDomain', - 'IDomainCollection', 'IDomainManager', ] -from lazr.restful.declarations import ( - collection_default_content, error_status, export_as_webservice_collection, - export_as_webservice_entry, export_factory_operation, exported) from zope.interface import Interface, Attribute -from zope.schema import TextLine from mailman.core.errors import MailmanError from mailman.core.i18n import _ -@error_status(400) class BadDomainSpecificationError(MailmanError): """The specification of a virtual domain is invalid or duplicated.""" + def __init__(self, domain): + super(BadDomainSpecificationError, self).__init__(domain) + self.domain = domain + class IDomain(Interface): """Interface representing domains.""" - export_as_webservice_entry() - - email_host = exported(TextLine( - title=_('Email host name'), - description=_('The host name for email for this domain.'), - )) + email_host = Attribute('The host name for email for this domain.') - url_host = exported(TextLine( - title=_('Web host name'), - description=_('The host name for the web interface for this domain.') - )) + url_host = Attribute( + 'The host name for the web interface for this domain.') - base_url = exported(TextLine( - title=_('Base URL'), - description=_("""\ - The base url for the Mailman server at this domain, which includes the - scheme and host name."""), - )) + base_url = Attribute("""\ + The base url for the Mailman server at this domain, which includes the + scheme and host name.""") - description = exported(TextLine( - title=_('Description'), - description=_('The human readable description of the domain name.'), - )) + description = Attribute( + 'The human readable description of the domain name.') - contact_address = exported(TextLine( - title=_('Contact address'), - description=_("""\ - The contact address for the human at this domain. - - E.g. postmaster@example.com"""), - )) + contact_address = Attribute("""\ + The contact address for the human at this domain. + E.g. postmaster@example.com""") def confirm_url(token=''): """The url used for various forms of confirmation. @@ -158,42 +140,3 @@ class IDomainManager(Interface): :return: True if this domain is known. :rtype: bool """ - - - -class IDomainCollection(Interface): - """The set of domains available via the REST API.""" - - export_as_webservice_collection(IDomain) - - @collection_default_content() - def get_domains(): - """The list of all domains. - - :return: The list of all known domains. - :rtype: list of `IDomain` - """ - - @export_factory_operation( - IDomain, - ('email_host', 'description', 'base_url', 'contact_address')) - def new(email_host, description=None, base_url=None, contact_address=None): - """Add a new domain. - - :param email_host: The email host name for the domain. - :type email_host: string - :param description: The description of the domain. - :type description: string - :param base_url: The base url, including the scheme for the web - interface of the domain. If not given, it defaults to - http://`email_host`/ - :type base_url: string - :param contact_address: The email contact address for the human - managing the domain. If not given, defaults to - postmaster@`email_host` - :type contact_address: string - :return: The new domain object - :rtype: `IDomain` - :raises `BadDomainSpecificationError`: when the `email_host` is - already registered. - """ diff --git a/src/mailman/interfaces/listmanager.py b/src/mailman/interfaces/listmanager.py index 5958d677a..f24230852 100644 --- a/src/mailman/interfaces/listmanager.py +++ b/src/mailman/interfaces/listmanager.py @@ -27,9 +27,6 @@ __all__ = [ ] -from lazr.restful.declarations import ( - collection_default_content, error_status, export_as_webservice_collection, - export_factory_operation) from zope.interface import Interface, Attribute from mailman.interfaces.errors import MailmanError @@ -37,7 +34,6 @@ from mailman.interfaces.mailinglist import IMailingList -@error_status(400) class ListAlreadyExistsError(MailmanError): """Attempted to create a mailing list that already exists. @@ -46,7 +42,6 @@ class ListAlreadyExistsError(MailmanError): """ -@error_status(400) class NoSuchListError(MailmanError): """Attempt to access a mailing list that does not exist.""" @@ -68,8 +63,6 @@ class IListManager(Interface): `mylist@example.com`. """ - export_as_webservice_collection(IMailingList) - def create(fqdn_listname): """Create a mailing list with the given name. @@ -100,32 +93,19 @@ class IListManager(Interface): """An iterator over all the mailing list objects managed by this list manager.""") + def __iter__(): + """An iterator over all the mailing lists. + + :return: iterator over `IMailingList`. + """ + names = Attribute( """An iterator over the fully qualified list names of all mailing lists managed by this list manager.""") - @collection_default_content() def get_mailing_lists(): """The list of all mailing lists. :return: The list of all known mailing lists. :rtype: list of `IMailingList` """ - - @export_factory_operation(IMailingList, ('fqdn_listname',)) - def new(fqdn_listname): - """Add a new maling list. - - The mailing may not exist yet, but the domain specified in - `fqdn_listname` must exist. - - :param fqdn_listname: The fully qualified name for the new - mailing list. - :type fqdn_listname: string - :return: The new mailing list - :rtype: `IMailingList` - :raises `BadDomainSpecificationError`: when the hostname part of - `fqdn_listname` does not exist. - :raises `ListAlreadyExistsError`: when the mailing list already - exists. - """ diff --git a/src/mailman/interfaces/mailinglist.py b/src/mailman/interfaces/mailinglist.py index 0efe00625..2e2083e72 100644 --- a/src/mailman/interfaces/mailinglist.py +++ b/src/mailman/interfaces/mailinglist.py @@ -30,11 +30,8 @@ __all__ = [ ] -from lazr.restful.declarations import ( - export_as_webservice_entry, exported) from munepy import Enum from zope.interface import Interface, Attribute -from zope.schema import TextLine from mailman.core.i18n import _ @@ -71,53 +68,40 @@ class DigestFrequency(Enum): class IMailingList(Interface): """A mailing list.""" - # Use a different singular and plural name for the resource type than - # lazr.restful gives it as a default (which is normally taken from the - # interface name). - export_as_webservice_entry('list', 'lists') - # List identity - list_name = exported(TextLine( - title=_('Short name'), - description=_("""\ + list_name = Attribute("""\ The read-only short name of the mailing list. Note that where a Mailman installation supports multiple domains, this short name may not be unique. Use the fqdn_listname attribute for a guaranteed unique id for the mailing list. This short name is always the local part of the posting email address. For example, if messages are posted to mylist@example.com, then the list_name is 'mylist'. - """))) - host_name = exported(TextLine( - title=_('Host name'), - description=_("""\ + """) + host_name = Attribute("""\ The read-only domain name 'hosting' this mailing list. This is always the domain name part of the posting email address, and it may bear no relationship to the web url used to access this mailing list. For example, if messages are posted to mylist@example.com, then the host_name is 'example.com'. - """))) + """) - fqdn_listname = exported(TextLine( - title=_('Fully qualified list name'), - description=_("""\ + fqdn_listname = Attribute("""\ The read-only fully qualified name of the mailing list. This is the guaranteed unique id for the mailing list, and it is always the address to which messages are posted, e.g. mylist@example.com. It is always comprised of the list_name + '@' + host_name. - """))) + """) domain = Attribute( """The `IDomain` that this mailing list is defined in.""") - real_name = exported(TextLine( - title=_('Real name'), - description=_("""\ + real_name = Attribute("""\ The short human-readable descriptive name for the mailing list. By default, this is the capitalized `list_name`, but it can be changed to anything. This is used in locations such as the message footers and Subject prefix. - """))) + """) list_id = Attribute( """The RFC 2919 List-ID header value.""") diff --git a/src/mailman/interfaces/member.py b/src/mailman/interfaces/member.py index 871957922..df5ccc935 100644 --- a/src/mailman/interfaces/member.py +++ b/src/mailman/interfaces/member.py @@ -32,8 +32,6 @@ __all__ = [ ] -from lazr.restful.declarations import ( - error_status, export_as_webservice_entry, exported) from munepy import Enum from zope.interface import Interface, Attribute @@ -78,7 +76,6 @@ class MembershipError(MailmanError): """Base exception for all membership errors.""" -@error_status(400) class AlreadySubscribedError(MembershipError): """The member is already subscribed to the mailing list with this role.""" @@ -93,7 +90,6 @@ class AlreadySubscribedError(MembershipError): self._address, self._role, self._fqdn_listname) -@error_status(400) class MembershipIsBannedError(MembershipError): """The address is not allowed to subscribe to the mailing list.""" @@ -107,7 +103,6 @@ class MembershipIsBannedError(MembershipError): self._address, self._mlist) -@error_status(400) class NotAMemberError(MembershipError): """The address is not a member of the mailing list.""" @@ -125,8 +120,6 @@ class NotAMemberError(MembershipError): class IMember(Interface): """A member of a mailing list.""" - export_as_webservice_entry() - mailing_list = Attribute( """The mailing list subscribed to.""") diff --git a/src/mailman/interfaces/membership.py b/src/mailman/interfaces/membership.py index 6e6176e8f..f42516ad1 100644 --- a/src/mailman/interfaces/membership.py +++ b/src/mailman/interfaces/membership.py @@ -25,11 +25,7 @@ __all__ = [ ] -from lazr.restful.declarations import ( - collection_default_content, export_as_webservice_collection, - export_write_operation, operation_parameters) from zope.interface import Interface -from zope.schema import TextLine from mailman.core.i18n import _ from mailman.interfaces.member import IMember @@ -39,9 +35,6 @@ from mailman.interfaces.member import IMember class ISubscriptionService(Interface): """Subscription services for the REST API.""" - export_as_webservice_collection(IMember) - - @collection_default_content() def get_members(): """Return a sequence of all members of all mailing lists. @@ -55,13 +48,9 @@ class ISubscriptionService(Interface): :rtype: list of `IMember` """ - @operation_parameters( - fqdn_listname=TextLine(), - address=TextLine(), - real_name=TextLine(), - delivery_mode=TextLine(), - ) - @export_write_operation() + def __iter__(): + """See `get_members()`.""" + def join(fqdn_listname, address, real_name=None, delivery_mode=None): """Subscribe to a mailing list. @@ -94,11 +83,6 @@ class ISubscriptionService(Interface): :raises ValueError: when `delivery_mode` is invalid. """ - @operation_parameters( - fqdn_listname=TextLine(), - address=TextLine(), - ) - @export_write_operation() def leave(fqdn_listname, address): """Unsubscribe from a mailing list. diff --git a/src/mailman/interfaces/rest.py b/src/mailman/interfaces/rest.py deleted file mode 100644 index f5eb59bc9..000000000 --- a/src/mailman/interfaces/rest.py +++ /dev/null @@ -1,46 +0,0 @@ -# Copyright (C) 2009-2010 by the Free Software Foundation, Inc. -# -# This file is part of GNU Mailman. -# -# GNU Mailman is free software: you can redistribute it and/or modify it under -# the terms of the GNU General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# GNU Mailman 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 General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along with -# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. - -"""Interfaces for the RESTful admin server.""" - -from __future__ import absolute_import, unicode_literals - -__metaclass__ = type -__all__ = [ - 'APIValueError', - 'IResolvePathNames', - ] - - -from lazr.restful.declarations import error_status -from zope.interface import Interface - -from mailman.core.errors import MailmanError - - - -@error_status(400) -class APIValueError(MailmanError, ValueError): - """A `ValueError` from the REST API.""" - - - -class IResolvePathNames(Interface): - """A marker interface objects that implement simple traversal.""" - - def get(name): - """Traverse to a contained object.""" diff --git a/src/mailman/interfaces/system.py b/src/mailman/interfaces/system.py index 9f2e275fa..39156315f 100644 --- a/src/mailman/interfaces/system.py +++ b/src/mailman/interfaces/system.py @@ -25,9 +25,7 @@ __all__ = [ ] -from lazr.restful.declarations import export_as_webservice_entry, exported -from zope.interface import Interface -from zope.schema import TextLine +from zope.interface import Interface, Attribute from mailman.core.i18n import _ @@ -36,14 +34,6 @@ from mailman.core.i18n import _ class ISystem(Interface): """Information about the Mailman system.""" - export_as_webservice_entry() + mailman_version = Attribute('The GNU Mailman version.') - mailman_version = exported(TextLine( - title=_('Mailman version'), - description=_('The GNU Mailman version.'), - )) - - python_version = exported(TextLine( - title=_('Python version'), - description=_('The Python version.'), - )) + python_version = Attribute('The Python version.') diff --git a/src/mailman/model/listmanager.py b/src/mailman/model/listmanager.py index b8bfa85ee..e123c47f4 100644 --- a/src/mailman/model/listmanager.py +++ b/src/mailman/model/listmanager.py @@ -32,7 +32,6 @@ from zope.interface import implements from mailman.config import config from mailman.interfaces.address import InvalidEmailAddressError from mailman.interfaces.listmanager import IListManager, ListAlreadyExistsError -from mailman.interfaces.rest import IResolvePathNames from mailman.model.mailinglist import MailingList @@ -40,7 +39,7 @@ from mailman.model.mailinglist import MailingList class ListManager: """An implementation of the `IListManager` interface.""" - implements(IListManager, IResolvePathNames) + implements(IListManager) # pylint: disable-msg=R0201 def create(self, fqdn_listname): @@ -80,18 +79,19 @@ class ListManager: for fqdn_listname in self.names: yield self.get(fqdn_listname) + def __iter__(self): + """See `IListManager`.""" + for fqdn_listname in self.names: + yield self.get(fqdn_listname) + @property def names(self): """See `IListManager`.""" for mlist in config.db.store.find(MailingList): yield '{0}@{1}'.format(mlist.list_name, mlist.host_name) + # XXX 2010-02-24 barry Get rid of this. def get_mailing_lists(self): """See `IListManager`.""" # lazr.restful will not allow this to be a generator. return list(self.mailing_lists) - - def new(self, fqdn_listname): - """See `IListManager.""" - from mailman.app.lifecycle import create_list - return create_list(fqdn_listname) diff --git a/src/mailman/queue/docs/rest.txt b/src/mailman/queue/docs/rest.txt index b46f0f304..2df4da9e4 100644 --- a/src/mailman/queue/docs/rest.txt +++ b/src/mailman/queue/docs/rest.txt @@ -14,7 +14,6 @@ The RESTful server can be used to access basic version information. http_etag: "..." mailman_version: GNU Mailman 3.0... (...) python_version: ... - resource_type_link: http://localhost:8001/3.0/#system self_link: http://localhost:8001/3.0/system diff --git a/src/mailman/queue/rest.py b/src/mailman/queue/rest.py index a14f0b081..a7c9727e2 100644 --- a/src/mailman/queue/rest.py +++ b/src/mailman/queue/rest.py @@ -32,7 +32,7 @@ import signal import logging from mailman.queue import Runner -from mailman.rest.webservice import make_server +from mailman.rest.wsgiapp import make_server log = logging.getLogger('mailman.http') diff --git a/src/mailman/rest/adapters.py b/src/mailman/rest/adapters.py index 585918e6b..6acfe3866 100644 --- a/src/mailman/rest/adapters.py +++ b/src/mailman/rest/adapters.py @@ -29,56 +29,26 @@ from operator import attrgetter from zope.component import getUtility from zope.interface import implements -from zope.publisher.interfaces import NotFound from mailman.app.membership import add_member, delete_member from mailman.core.constants import system_preferences from mailman.interfaces.address import InvalidEmailAddressError -from mailman.interfaces.domain import IDomainCollection, IDomainManager from mailman.interfaces.listmanager import IListManager, NoSuchListError -from mailman.interfaces.member import DeliveryMode, NotAMemberError +from mailman.interfaces.member import DeliveryMode from mailman.interfaces.membership import ISubscriptionService -from mailman.interfaces.rest import APIValueError, IResolvePathNames - - - -class DomainCollection: - """Sets of known domains.""" - - implements(IDomainCollection, IResolvePathNames) - - __name__ = 'domains' - - def get_domains(self): - """See `IDomainCollection`.""" - # lazr.restful requires the return value to be a concrete list. - return sorted(getUtility(IDomainManager), key=attrgetter('email_host')) - - def get(self, name): - """See `IResolvePathNames`.""" - domain = getUtility(IDomainManager).get(name) - if domain is None: - raise NotFound(self, name) - return domain - - def new(self, email_host, description=None, base_url=None, - contact_address=None): - """See `IDomainCollection`.""" - value = getUtility(IDomainManager).add( - email_host, description, base_url, contact_address) - return value class SubscriptionService: """Subscription services for the REST API.""" - implements(ISubscriptionService, IResolvePathNames) + implements(ISubscriptionService) __name__ = 'members' def get_members(self): """See `ISubscriptionService`.""" + # XXX 2010-02-24 barry Clean this up. # lazr.restful requires the return value to be a concrete list. members = [] address_of_member = attrgetter('address.address') @@ -96,21 +66,20 @@ class SubscriptionService: key=address_of_member)) return members + def __iter__(self): + for member in self.get_members(): + yield member + def join(self, fqdn_listname, address, real_name= None, delivery_mode=None): """See `ISubscriptionService`.""" mlist = getUtility(IListManager).get(fqdn_listname) if mlist is None: raise NoSuchListError(fqdn_listname) - # Convert from string to enum. Turn Python's ValueErrors into one - # suitable for the REST API. - try: - mode = (DeliveryMode.regular - if delivery_mode is None - else DeliveryMode(delivery_mode)) - except ValueError: - raise APIValueError( - 'Invalid delivery_mode: {0}'.format(delivery_mode)) + # Convert from string to enum. + mode = (DeliveryMode.regular + if delivery_mode is None + else DeliveryMode(delivery_mode)) if real_name is None: real_name, at, domain = address.partition('@') if len(at) == 0: diff --git a/src/mailman/rest/configuration.py b/src/mailman/rest/configuration.py deleted file mode 100644 index 30e2607cb..000000000 --- a/src/mailman/rest/configuration.py +++ /dev/null @@ -1,87 +0,0 @@ -# Copyright (C) 2009-2010 by the Free Software Foundation, Inc. -# -# This file is part of GNU Mailman. -# -# GNU Mailman is free software: you can redistribute it and/or modify it under -# the terms of the GNU General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# GNU Mailman 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 General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along with -# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. - -"""Mailman admin web service configuration.""" - -from __future__ import absolute_import, unicode_literals - -__metaclass__ = type -__all__ = [ - 'AdminWebServiceConfiguration', - ] - - -from lazr.config import as_boolean -from lazr.restful.interfaces import IWebServiceConfiguration -from lazr.restful.wsgi import BaseWSGIWebServiceConfiguration -from zope.interface import implements - -from mailman import version -from mailman.config import config - - - -# pylint: disable-msg=W0232,R0201 -class AdminWebServiceConfiguration(BaseWSGIWebServiceConfiguration): - """A configuration object for the Mailman admin web service.""" - - implements(IWebServiceConfiguration) - - @property - def view_permission(self): - """See `IWebServiceConfiguration`.""" - if config.webservice.view_permission.lower() == 'none': - return None - return config.webservice.view_permission - - path_override = None - - @property - def use_https(self): - """See `IWebServiceConfiguration`.""" - return as_boolean(config.webservice.use_https) - - # We currently have only one active version; the first entry in this list - # should match the major.minor Mailman version. The second entry is just - # an alias for the 'floating' development version. - active_versions = [ - '{0.MAJOR_REV}.{0.MINOR_REV}'.format(version), - 'dev', - ] - code_revision = version.VERSION - - @property - def show_tracebacks(self): - """See `IWebServiceConfiguration`.""" - return config.webservice.show_tracebacks - - default_batch_size = 50 - max_batch_size = 300 - - def get_request_user(self): - """See `IWebServiceConfiguration`.""" - return None - - @property - def hostname(self): - """See `IWebServiceConfiguration`.""" - return config.webservice.hostname - - @property - def port(self): - """See `IWebServiceConfiguration`.""" - return int(config.webservice.port) diff --git a/src/mailman/rest/configure.zcml b/src/mailman/rest/configure.zcml deleted file mode 100644 index 6e8b3727d..000000000 --- a/src/mailman/rest/configure.zcml +++ /dev/null @@ -1,116 +0,0 @@ -<!-- -*- xml -*- --> -<configure - xmlns="http://namespaces.zope.org/zope" - xmlns:webservice="http://namespaces.canonical.com/webservice"> - - <include package="zope.component" file="meta.zcml"/> - <include package="zope.security" file="meta.zcml"/> - <include package="lazr.restful.example.wsgi" file="site.zcml"/> - - <webservice:register module="mailman.interfaces.domain" /> - <webservice:register module="mailman.interfaces.listmanager" /> - <webservice:register module="mailman.interfaces.membership" /> - <webservice:register module="mailman.interfaces.rest" /> - <webservice:register module="mailman.interfaces.system" /> - - <!-- XXX 2010-01-01 This can't be included without breaking - zope.configuration - <webservice:register module="mailman.interfaces.member" /> - --> - - <adapter factory="zope.publisher.http.HTTPCharsets" /> - - <adapter - for="mailman.interfaces.domain.IDomain - lazr.restful.simple.Request" - provides="zope.traversing.browser.interfaces.IAbsoluteURL" - factory="mailman.rest.urls.DomainURLMapper" - /> - - <adapter - for="zope.interface.Interface - lazr.restful.simple.Request" - provides="zope.traversing.browser.interfaces.IAbsoluteURL" - factory="mailman.rest.urls.FallbackURLMapper" - /> - - <!-- - XXX 2010-02-16 barry Why is this necessary? Without this, lazr.restful - does not adapt the ISystem to the FallbackURLMapper. I don't know why - that happens because the above generic mapper should do the trick. - --> - - <adapter - for="mailman.interfaces.system.ISystem - lazr.restful.simple.Request" - provides="zope.traversing.browser.interfaces.IAbsoluteURL" - factory="mailman.rest.urls.FallbackURLMapper" - /> - <adapter - for="mailman.interfaces.mailinglist.IMailingList - lazr.restful.simple.Request" - provides="zope.traversing.browser.interfaces.IAbsoluteURL" - factory="mailman.rest.urls.MailingListURLMapper" - /> - - <adapter - for="mailman.interfaces.member.IMember - lazr.restful.simple.Request" - provides="zope.traversing.browser.interfaces.IAbsoluteURL" - factory="mailman.rest.urls.MemberURLMapper" - /> - - <adapter - for="zope.publisher.interfaces.NotFound - lazr.restful.simple.Request" - provides="zope.interface.Interface" - factory="lazr.restful.error.NotFoundView" - name="index.html" - /> - - <!-- - XXX 2009-12-28 Why are these necessary? NotAMemberError and - AlreadySubscribedError are decorated with @error_status(400) so they - /should/ already be adaptable to WebServiceExceptionView. For some reason - though rest/membership.txt fails without these. - --> - - <adapter - for="mailman.interfaces.member.NotAMemberError - lazr.restful.simple.Request" - provides="zope.interface.Interface" - factory="lazr.restful.error.WebServiceExceptionView" - name="index.html" - /> - - <adapter - for="mailman.interfaces.member.AlreadySubscribedError - lazr.restful.simple.Request" - provides="zope.interface.Interface" - factory="lazr.restful.error.WebServiceExceptionView" - name="index.html" - /> - - <!-- Utilities --> - - <utility - factory="mailman.rest.webservice.AdminWebServiceRootResource" - provides="lazr.restful.interfaces.IServiceRootResource" - /> - - <utility - factory="mailman.rest.configuration.AdminWebServiceConfiguration" - provides="lazr.restful.interfaces.IWebServiceConfiguration" - /> - - <utility - factory="mailman.rest.adapters.DomainCollection" - provides="mailman.interfaces.domain.IDomainCollection" - /> - - <utility - factory="mailman.rest.adapters.SubscriptionService" - provides="mailman.interfaces.membership.ISubscriptionService" - /> - -</configure> diff --git a/src/mailman/rest/docs/basic.txt b/src/mailman/rest/docs/basic.txt index 79abbfde1..643d6d906 100644 --- a/src/mailman/rest/docs/basic.txt +++ b/src/mailman/rest/docs/basic.txt @@ -16,7 +16,6 @@ returned. http_etag: "..." mailman_version: GNU Mailman 3.0... (...) python_version: ... - resource_type_link: http://localhost:8001/3.0/#system self_link: http://localhost:8001/3.0/system @@ -26,8 +25,7 @@ Non-existent links When you try to access an admin link that doesn't exist, you get the appropriate HTTP 404 Not Found error. - >>> from urllib2 import urlopen - >>> urlopen('http://localhost:8001/3.0/does-not-exist') + >>> dump_json('http://localhost:8001/3.0/does-not-exist') Traceback (most recent call last): ... HTTPError: HTTP Error 404: Not Found diff --git a/src/mailman/rest/docs/domains.txt b/src/mailman/rest/docs/domains.txt index ed9c6bc2f..b8e0170b0 100644 --- a/src/mailman/rest/docs/domains.txt +++ b/src/mailman/rest/docs/domains.txt @@ -16,8 +16,8 @@ The REST API can be queried for the set of known domains, of which there are initially none. >>> dump_json('http://localhost:8001/3.0/domains') - resource_type_link: http://localhost:8001/3.0/#domains - start: None + http_etag: "..." + start: 0 total_size: 0 Once a domain is added though, it is accessible through the API. @@ -36,10 +36,9 @@ Once a domain is added though, it is accessible through the API. description: An example domain email_host: example.com http_etag: "..." - resource_type_link: http://localhost:8001/3.0/#domain self_link: http://localhost:8001/3.0/domains/example.com url_host: lists.example.com - resource_type_link: http://localhost:8001/3.0/#domains + http_etag: "..." start: 0 total_size: 1 @@ -68,7 +67,6 @@ At the top level, all domains are returned as separate entries. description: An example domain email_host: example.com http_etag: "..." - resource_type_link: http://localhost:8001/3.0/#domain self_link: http://localhost:8001/3.0/domains/example.com url_host: lists.example.com entry 1: @@ -77,7 +75,6 @@ At the top level, all domains are returned as separate entries. description: None email_host: example.org http_etag: "..." - resource_type_link: http://localhost:8001/3.0/#domain self_link: http://localhost:8001/3.0/domains/example.org url_host: mail.example.org entry 2: @@ -86,10 +83,9 @@ At the top level, all domains are returned as separate entries. description: Porkmasters email_host: lists.example.net http_etag: "..." - resource_type_link: http://localhost:8001/3.0/#domain self_link: http://localhost:8001/3.0/domains/lists.example.net url_host: example.net - resource_type_link: http://localhost:8001/3.0/#domains + http_etag: "..." start: 0 total_size: 3 @@ -106,7 +102,6 @@ self_links from the above collection. description: Porkmasters email_host: lists.example.net http_etag: "..." - resource_type_link: http://localhost:8001/3.0/#domain self_link: http://localhost:8001/3.0/domains/lists.example.net url_host: example.net @@ -121,22 +116,15 @@ But we get a 404 for a non-existent domain. Creating new domains ==================== -New domains can be created by posting to the 'domains' url. However -lazr.restful requires us to use a 'named operation' instead of posting -directly to the URL. +New domains can be created by posting to the 'domains' url. >>> dump_json('http://localhost:8001/3.0/domains', { - ... 'ws.op': 'new', ... 'email_host': 'lists.example.com', ... }) - URL: http://localhost:8001/3.0/domains content-length: 0 - content-type: text/plain;charset=utf-8 date: ... location: http://localhost:8001/3.0/domains/lists.example.com - server: WSGIServer/... Python/... - x-content-type-warning: guessed from content - x-powered-by: Zope (www.zope.org), Python (www.python.org) + ... Now the web service knows about our new domain. @@ -146,7 +134,6 @@ Now the web service knows about our new domain. description: None email_host: lists.example.com http_etag: "..." - resource_type_link: http://localhost:8001/3.0/#domain self_link: http://localhost:8001/3.0/domains/lists.example.com url_host: lists.example.com @@ -164,20 +151,15 @@ You can also create a new domain with a description, a base url, and a contact address. >>> dump_json('http://localhost:8001/3.0/domains', { - ... 'ws.op': 'new', ... 'email_host': 'my.example.com', ... 'description': 'My new domain', ... 'base_url': 'http://allmy.example.com', ... 'contact_address': 'helpme@example.com' ... }) - URL: http://localhost:8001/3.0/domains content-length: 0 - content-type: text/plain;charset=utf-8 date: ... location: http://localhost:8001/3.0/domains/my.example.com - server: WSGIServer/... Python/... - x-content-type-warning: guessed from content - x-powered-by: Zope (www.zope.org), Python (www.python.org) + ... >>> dump_json('http://localhost:8001/3.0/domains/my.example.com') base_url: http://allmy.example.com @@ -185,7 +167,6 @@ address. description: My new domain email_host: my.example.com http_etag: "..." - resource_type_link: http://localhost:8001/3.0/#domain self_link: http://localhost:8001/3.0/domains/my.example.com url_host: allmy.example.com diff --git a/src/mailman/rest/docs/helpers.txt b/src/mailman/rest/docs/helpers.txt new file mode 100644 index 000000000..7b9aa9863 --- /dev/null +++ b/src/mailman/rest/docs/helpers.txt @@ -0,0 +1,143 @@ +================ +REST API helpers +================ + +There are a number of helpers that make building out the REST API easier. + + +Resource paths +============== + +For example, most resources don't have to worry about where they are rooted. +They only need to know where they are relative to the root URI, and this +function can return them the full path to the resource. + + >>> from mailman.rest.helpers import path_to + >>> print path_to('system') + http://localhost:8001/3.0/system + +Parameters like the scheme, host, port, and API version number can be set in +the configuration file. + + >>> config.push('helpers', """ + ... [webservice] + ... hostname: geddy + ... port: 2112 + ... use_https: yes + ... api_version: 4.2 + ... """) + + >>> print path_to('system') + https://geddy:2112/4.2/system + + +Etags +===== + +HTTP etags are a way for clients to decide whether their copy of a resource +has changed or not. Mailman's REST API calculates this in a cheap and dirty +way. Pass in the dictionary representing the resource and that dictionary +gets modified to contain the etag under the `http_etag` key. + + >>> from mailman.rest.helpers import etag + >>> resource = dict(geddy='bass', alex='guitar', neil='drums') + >>> json_data = etag(resource) + >>> print resource['http_etag'] + "43942176d8d5bb4414ccf35e2720ccd5251e66da" + +For convenience, the etag function also returns the JSON representation of the +dictionary after tagging, since that's almost always what you want. + + >>> import json + >>> data = json.loads(json_data) + + # This is pretty close to what we want, so it's convenient to use. + >>> dump_msgdata(data) + alex : guitar + geddy : bass + http_etag: "43942176d8d5bb4414ccf35e2720ccd5251e66da" + neil : drums + + +POST unpacking +============== + +Another helper unpacks POST request variables, validating and converting their +values. + + >>> from mailman.rest.helpers import Validator + >>> validator = Validator(one=int, two=unicode, three=bool) + + >>> class FakeRequest: + ... POST = {} + >>> FakeRequest.POST = dict(one='1', two='two', three='yes') + +On valid input, the validator can be used as a **kw argument. + + >>> def print_request(one, two, three): + ... print repr(one), repr(two), repr(three) + >>> print_request(**validator(FakeRequest)) + 1 u'two' True + +On invalid input, an exception is raised. + + >>> FakeRequest.POST['one'] = 'hello' + >>> print_request(**validator(FakeRequest)) + Traceback (most recent call last): + ... + ValueError: Cannot convert parameters: one + +On missing input, an exception is raised. + + >>> del FakeRequest.POST['one'] + >>> print_request(**validator(FakeRequest)) + Traceback (most recent call last): + ... + ValueError: Missing parameters: one + +If more than one key is missing, it will be reflected in the error message. + + >>> del FakeRequest.POST['two'] + >>> print_request(**validator(FakeRequest)) + Traceback (most recent call last): + ... + ValueError: Missing parameters: one, two + +Extra keys are also not allowed. + + >>> FakeRequest.POST = dict(one='1', two='two', three='yes', + ... four='', five='') + >>> print_request(**validator(FakeRequest)) + Traceback (most recent call last): + ... + ValueError: Unexpected parameters: five, four + +However, if optional keys are missing, it's okay. + + >>> validator = Validator(one=int, two=unicode, three=bool, + ... four=int, five=int, + ... _optional=('four', 'five')) + + >>> FakeRequest.POST = dict(one='1', two='two', three='yes', + ... four='4', five='5') + >>> def print_request(one, two, three, four=None, five=None): + ... print repr(one), repr(two), repr(three), repr(four), repr(five) + >>> print_request(**validator(FakeRequest)) + 1 u'two' True 4 5 + + >>> del FakeRequest.POST['four'] + >>> print_request(**validator(FakeRequest)) + 1 u'two' True None 5 + + >>> del FakeRequest.POST['five'] + >>> print_request(**validator(FakeRequest)) + 1 u'two' True None None + +But if the optional values are present, they must of course also be valid. + + >>> FakeRequest.POST = dict(one='1', two='two', three='yes', + ... four='no', five='maybe') + >>> print_request(**validator(FakeRequest)) + Traceback (most recent call last): + ... + ValueError: Cannot convert parameters: five, four diff --git a/src/mailman/rest/docs/lists.txt b/src/mailman/rest/docs/lists.txt index 460ca3585..6abf29a1d 100644 --- a/src/mailman/rest/docs/lists.txt +++ b/src/mailman/rest/docs/lists.txt @@ -7,8 +7,8 @@ top level collection that can return all the mailing lists. There aren't any yet though. >>> dump_json('http://localhost:8001/3.0/lists') - resource_type_link: http://localhost:8001/3.0/#lists - start: None + http_etag: "..." + start: 0 total_size: 0 Create a mailing list in a domain and it's accessible via the API. @@ -24,9 +24,8 @@ Create a mailing list in a domain and it's accessible via the API. http_etag: "..." list_name: test-one real_name: Test-one - resource_type_link: http://localhost:8001/3.0/#list self_link: http://localhost:8001/3.0/lists/test-one@example.com - resource_type_link: http://localhost:8001/3.0/#lists + http_etag: "..." start: 0 total_size: 1 @@ -35,21 +34,15 @@ Creating lists via the API ========================== New mailing lists can also be created through the API, by posting to the -'lists' URL. However lazr.restful requires us to use a 'named operation' -instead of posting directly to the URL. +'lists' URL. >>> dump_json('http://localhost:8001/3.0/lists', { - ... 'ws.op': 'new', ... 'fqdn_listname': 'test-two@example.com', ... }) - URL: http://localhost:8001/3.0/lists content-length: 0 - content-type: text/plain;charset=utf-8 date: ... location: http://localhost:8001/3.0/lists/test-two@example.com - server: WSGIServer/... Python/... - x-content-type-warning: guessed from content - x-powered-by: Zope (www.zope.org), Python (www.python.org) + ... The mailing list exists in the database. @@ -70,14 +63,12 @@ It is also available via the location given in the response. http_etag: "..." list_name: test-two real_name: Test-two - resource_type_link: http://localhost:8001/3.0/#list self_link: http://localhost:8001/3.0/lists/test-two@example.com However, you are not allowed to create a mailing list in a domain that does not exist. >>> dump_json('http://localhost:8001/3.0/lists', { - ... 'ws.op': 'new', ... 'fqdn_listname': 'test-three@example.org', ... }) Traceback (most recent call last): @@ -87,7 +78,6 @@ not exist. Nor can you create a mailing list that already exists. >>> dump_json('http://localhost:8001/3.0/lists', { - ... 'ws.op': 'new', ... 'fqdn_listname': 'test-one@example.com', ... }) Traceback (most recent call last): diff --git a/src/mailman/rest/docs/membership.txt b/src/mailman/rest/docs/membership.txt index f14bb0203..70d4384ce 100644 --- a/src/mailman/rest/docs/membership.txt +++ b/src/mailman/rest/docs/membership.txt @@ -9,8 +9,8 @@ returns all the members of all known mailing lists. There are no mailing lists and no members yet. >>> dump_json('http://localhost:8001/3.0/members') - resource_type_link: http://localhost:8001/3.0/#members - start: None + http_etag: "..." + start: 0 total_size: 0 We create a mailing list, which starts out with no members. @@ -19,8 +19,8 @@ We create a mailing list, which starts out with no members. >>> transaction.commit() >>> dump_json('http://localhost:8001/3.0/members') - resource_type_link: http://localhost:8001/3.0/#members - start: None + http_etag: "..." + start: 0 total_size: 0 @@ -49,9 +49,8 @@ the REST interface. >>> dump_json('http://localhost:8001/3.0/members') entry 0: http_etag: ... - resource_type_link: http://localhost:8001/3.0/#member self_link: http://localhost:8001/3.0/lists/test-one@example.com/member/bperson@example.com - resource_type_link: http://localhost:8001/3.0/#members + http_etag: "..." start: 0 total_size: 1 @@ -62,13 +61,11 @@ the REST interface. >>> dump_json('http://localhost:8001/3.0/members') entry 0: http_etag: ... - resource_type_link: http://localhost:8001/3.0/#member self_link: http://localhost:8001/3.0/lists/test-one@example.com/member/bperson@example.com entry 1: http_etag: ... - resource_type_link: http://localhost:8001/3.0/#member self_link: http://localhost:8001/3.0/lists/test-one@example.com/member/cperson@example.com - resource_type_link: http://localhost:8001/3.0/#members + http_etag: "..." start: 0 total_size: 2 @@ -80,17 +77,14 @@ subscribes, she is returned first. >>> dump_json('http://localhost:8001/3.0/members') entry 0: http_etag: ... - resource_type_link: http://localhost:8001/3.0/#member self_link: http://localhost:8001/3.0/lists/test-one@example.com/member/aperson@example.com entry 1: http_etag: ... - resource_type_link: http://localhost:8001/3.0/#member self_link: http://localhost:8001/3.0/lists/test-one@example.com/member/bperson@example.com entry 2: http_etag: ... - resource_type_link: http://localhost:8001/3.0/#member self_link: http://localhost:8001/3.0/lists/test-one@example.com/member/cperson@example.com - resource_type_link: http://localhost:8001/3.0/#members + http_etag: "..." start: 0 total_size: 3 @@ -104,25 +98,20 @@ address. Anna and Cris subscribe to this new mailing list. >>> dump_json('http://localhost:8001/3.0/members') entry 0: http_etag: ... - resource_type_link: http://localhost:8001/3.0/#member self_link: http://localhost:8001/3.0/lists/alpha@example.com/member/aperson@example.com entry 1: http_etag: ... - resource_type_link: http://localhost:8001/3.0/#member self_link: http://localhost:8001/3.0/lists/alpha@example.com/member/cperson@example.com entry 2: http_etag: ... - resource_type_link: http://localhost:8001/3.0/#member self_link: http://localhost:8001/3.0/lists/test-one@example.com/member/aperson@example.com entry 3: http_etag: ... - resource_type_link: http://localhost:8001/3.0/#member self_link: http://localhost:8001/3.0/lists/test-one@example.com/member/bperson@example.com entry 4: http_etag: ... - resource_type_link: http://localhost:8001/3.0/#member self_link: http://localhost:8001/3.0/lists/test-one@example.com/member/cperson@example.com - resource_type_link: http://localhost:8001/3.0/#members + http_etag: "..." start: 0 total_size: 5 @@ -140,33 +129,26 @@ test-one mailing list. >>> dump_json('http://localhost:8001/3.0/members') entry 0: http_etag: ... - resource_type_link: http://localhost:8001/3.0/#member self_link: http://localhost:8001/3.0/lists/alpha@example.com/moderator/dperson@example.com entry 1: http_etag: ... - resource_type_link: http://localhost:8001/3.0/#member self_link: http://localhost:8001/3.0/lists/alpha@example.com/member/aperson@example.com entry 2: http_etag: ... - resource_type_link: http://localhost:8001/3.0/#member self_link: http://localhost:8001/3.0/lists/alpha@example.com/member/cperson@example.com entry 3: http_etag: ... - resource_type_link: http://localhost:8001/3.0/#member self_link: http://localhost:8001/3.0/lists/test-one@example.com/owner/cperson@example.com entry 4: http_etag: ... - resource_type_link: http://localhost:8001/3.0/#member self_link: http://localhost:8001/3.0/lists/test-one@example.com/member/aperson@example.com entry 5: http_etag: ... - resource_type_link: http://localhost:8001/3.0/#member self_link: http://localhost:8001/3.0/lists/test-one@example.com/member/bperson@example.com entry 6: http_etag: ... - resource_type_link: http://localhost:8001/3.0/#member self_link: http://localhost:8001/3.0/lists/test-one@example.com/member/cperson@example.com - resource_type_link: http://localhost:8001/3.0/#members + http_etag: "..." start: 0 total_size: 7 @@ -183,14 +165,14 @@ delivery. Since Elly's email address is not yet known to Mailman, a user is created for her. >>> dump_json('http://localhost:8001/3.0/members', { - ... 'ws.op': 'join', ... 'fqdn_listname': 'alpha@example.com', ... 'address': 'eperson@example.com', ... 'real_name': 'Elly Person', ... }) - http_etag: ... - resource_type_link: http://localhost:8001/3.0/#member - self_link: http://localhost:8001/3.0/lists/alpha@example.com/member/eperson@example.com + content-length: 0 + date: ... + location: http://localhost:8001/3.0/lists/alpha@example.com/member/eperson@example.com + ... Elly is now a member of the mailing list. @@ -206,7 +188,6 @@ Elly is now a member of the mailing list. ... entry 3: http_etag: ... - resource_type_link: http://localhost:8001/3.0/#member self_link: http://localhost:8001/3.0/lists/alpha@example.com/member/eperson@example.com ... @@ -215,15 +196,16 @@ Leaving a mailing list ====================== Elly decides she does not want to be a member of the mailing list after all, -so she unsubscribes from the mailing list. +so she leaves from the mailing list. # Ensure our previous reads don't keep the database lock. >>> transaction.abort() - >>> dump_json('http://localhost:8001/3.0/members', { - ... 'ws.op': 'leave', - ... 'fqdn_listname': 'alpha@example.com', - ... 'address': 'eperson@example.com', - ... }) + >>> dump_json('http://localhost:8001/3.0/lists/alpha@example.com' + ... '/member/eperson@example.com', + ... method='DELETE') + content-length: 0 + ... + status: 200 Elly is no longer a member of the mailing list. @@ -238,15 +220,16 @@ Fred joins the alpha mailing list but wants MIME digest delivery. >>> transaction.abort() >>> dump_json('http://localhost:8001/3.0/members', { - ... 'ws.op': 'join', ... 'fqdn_listname': 'alpha@example.com', ... 'address': 'fperson@example.com', ... 'real_name': 'Fred Person', ... 'delivery_mode': 'mime_digests', ... }) - http_etag: ... - resource_type_link: http://localhost:8001/3.0/#member - self_link: http://localhost:8001/3.0/lists/alpha@example.com/member/fperson@example.com + content-length: 0 + ... + location: http://localhost:8001/3.0/lists/alpha@example.com/member/fperson@example.com + ... + status: 201 >>> fred = user_manager.get_user('fperson@example.com') >>> memberships = list(fred.memberships.members) @@ -263,7 +246,6 @@ Corner cases For some reason Elly tries to join a mailing list that does not exist. >>> dump_json('http://localhost:8001/3.0/members', { - ... 'ws.op': 'join', ... 'fqdn_listname': 'beta@example.com', ... 'address': 'eperson@example.com', ... 'real_name': 'Elly Person', @@ -274,43 +256,35 @@ For some reason Elly tries to join a mailing list that does not exist. Then, she tries to leave a mailing list that does not exist. - >>> dump_json('http://localhost:8001/3.0/members', { - ... 'ws.op': 'leave', - ... 'fqdn_listname': 'beta@example.com', - ... 'address': 'eperson@example.com', - ... 'real_name': 'Elly Person', - ... }) + >>> dump_json('http://localhost:8001/3.0/lists/beta@example.com' + ... '/members/eperson@example.com', + ... method='DELETE') Traceback (most recent call last): ... - HTTPError: HTTP Error 400: Bad Request + HTTPError: HTTP Error 404: Not Found She then tries to leave a mailing list with a bogus address. - >>> dump_json('http://localhost:8001/3.0/members', { - ... 'ws.op': 'leave', - ... 'fqdn_listname': 'alpha@example.com', - ... 'address': 'elly', - ... }) + >>> dump_json('http://localhost:8001/3.0/lists/alpha@example.com' + ... '/members/elly', + ... method='DELETE') Traceback (most recent call last): ... - HTTPError: HTTP Error 400: Bad Request + HTTPError: HTTP Error 404: Not Found For some reason, Elly tries to leave the mailing list again, but she's already been unsubscribed. - >>> dump_json('http://localhost:8001/3.0/members', { - ... 'ws.op': 'leave', - ... 'fqdn_listname': 'alpha@example.com', - ... 'address': 'eperson@example.com', - ... }) + >>> dump_json('http://localhost:8001/3.0/lists/alpha@example.com' + ... '/members/eperson@example.com', + ... method='DELETE') Traceback (most recent call last): ... - HTTPError: HTTP Error 400: Bad Request + HTTPError: HTTP Error 404: Not Found Anna tries to join a mailing list she's already a member of. >>> dump_json('http://localhost:8001/3.0/members', { - ... 'ws.op': 'join', ... 'fqdn_listname': 'alpha@example.com', ... 'address': 'aperson@example.com', ... }) @@ -321,7 +295,6 @@ Anna tries to join a mailing list she's already a member of. Gwen tries to join the alpha mailing list using an invalid delivery mode. >>> dump_json('http://localhost:8001/3.0/members', { - ... 'ws.op': 'join', ... 'fqdn_listname': 'alpha@example.com', ... 'address': 'gperson@example.com', ... 'real_name': 'Gwen Person', diff --git a/src/mailman/rest/domains.py b/src/mailman/rest/domains.py new file mode 100644 index 000000000..8bc68a6c1 --- /dev/null +++ b/src/mailman/rest/domains.py @@ -0,0 +1,99 @@ +# Copyright (C) 2010 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman 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 General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""REST for domains.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'ADomain', + 'AllDomains', + ] + + +from restish import http, resource +from zope.component import getUtility + +from mailman.interfaces.domain import ( + BadDomainSpecificationError, IDomainManager) +from mailman.rest.helpers import CollectionMixin, Validator, etag, path_to + + + +class _DomainBase(resource.Resource, CollectionMixin): + """Shared base class for domain representations.""" + + def _resource_as_dict(self, domain): + """See `CollectionMixin`.""" + return dict( + base_url=domain.base_url, + contact_address=domain.contact_address, + description=domain.description, + email_host=domain.email_host, + self_link=path_to('domains/{0}'.format(domain.email_host)), + url_host=domain.url_host, + ) + + def _get_collection(self, request): + """See `CollectionMixin`.""" + return list(getUtility(IDomainManager)) + + +class ADomain(_DomainBase): + """A domain.""" + + def __init__(self, domain): + self._domain = domain + + @resource.GET() + def domain(self, request): + """Return a single domain end-point.""" + domain = getUtility(IDomainManager).get(self._domain) + if domain is None: + return http.not_found() + return http.ok([], self._resource_as_json(domain)) + + +class AllDomains(_DomainBase): + """The domains.""" + + @resource.POST() + def create(self, request): + """Create a new domain.""" + domain_manager = getUtility(IDomainManager) + try: + validator = Validator(email_host=unicode, + description=unicode, + base_url=unicode, + contact_address=unicode, + _optional=('description', 'base_url', + 'contact_address')) + domain = domain_manager.add(**validator(request)) + except BadDomainSpecificationError: + return http.bad_request([], b'Domain exists') + except ValueError as error: + return http.bad_request([], str(error)) + location = path_to('domains/{0}'.format(domain.email_host)) + # Include no extra headers or body. + return http.created(location, [], None) + + @resource.GET() + def collection(self, request): + """/domains""" + resource = self._make_collection(request) + return http.ok([], etag(resource)) diff --git a/src/mailman/rest/helpers.py b/src/mailman/rest/helpers.py new file mode 100644 index 000000000..369eebffa --- /dev/null +++ b/src/mailman/rest/helpers.py @@ -0,0 +1,164 @@ +# Copyright (C) 2010 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman 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 General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""Web service helpers.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'ContainerMixin', + 'etag', + 'path_to', + ] + + +import json +import hashlib + +from lazr.config import as_boolean +from mailman.config import config + +COMMASPACE = ', ' + + + +def path_to(resource): + """Return the url path to a resource. + + :param resource: The canonical path to the resource, relative to the + system base URI. + :type resource: string + :return: The full path to the resource. + :rtype: bytes + """ + return b'{0}://{1}:{2}/{3}/{4}'.format( + ('https' if as_boolean(config.webservice.use_https) else 'http'), + config.webservice.hostname, + config.webservice.port, + config.webservice.api_version, + (resource[1:] if resource.startswith('/') else resource), + ) + + + +def etag(resource): + """Calculate the etag and return a JSON representation. + + The input is a dictionary representing the resource. This dictionary must + not contain an `http_etag` key. This function calculates the etag by + using the sha1 hexdigest of the repr of the dictionary. It then inserts + this value under the `http_etag` key, and returns the JSON representation + of the modified dictionary. + + :param resource: The original resource representation. + :type resource: dictionary + :return: JSON representation of the modified dictionary. + :rtype string + """ + assert 'http_etag' not in resource, 'Resource already etagged' + etag = hashlib.sha1(repr(resource)).hexdigest() + resource['http_etag'] = '"{0}"'.format(etag) + return json.dumps(resource) + + + +class CollectionMixin: + """Mixin class for common collection-ish things.""" + + def _resource_as_dict(self, resource): + """Return the dictionary representation of a resource. + + This must be implemented by subclasses. + + :param resource: The resource object. + :type resource: object + :return: The representation of the resource. + :rtype: dict + """ + raise NotImplementedError + + def _resource_as_json(self, resource): + """Return the JSON formatted representation of the resource.""" + return etag(self._resource_as_dict(resource)) + + def _get_collection(self, request): + """Return the collection as a concrete list. + + This must be implemented by subclasses. + + :param request: A restish request. + :return: The collection + :rtype: list + """ + raise NotImplementedError + + def _make_collection(self, request): + """Provide the collection to restish.""" + collection = self._get_collection(request) + if len(collection) == 0: + return dict(start=0, total_size=0) + else: + entries = [self._resource_as_dict(resource) + for resource in collection] + # Tag the resources but use the dictionaries. + [etag(resource) for resource in entries] + # Create the collection resource + return dict( + start=0, + total_size=len(collection), + entries=entries, + ) + + + +class Validator: + """A validator of parameter input.""" + + def __init__(self, **kws): + if '_optional' in kws: + self._optional = set(kws.pop('_optional')) + else: + self._optional = set() + self._converters = kws.copy() + + def __call__(self, request): + values = {} + extras = set() + cannot_convert = set() + for key, value in request.POST.items(): + try: + values[key] = self._converters[key](value) + except KeyError: + extras.add(key) + except (TypeError, ValueError): + cannot_convert.add(key) + # Make sure there are no unexpected values. + if len(extras) != 0: + extras = COMMASPACE.join(sorted(extras)) + raise ValueError('Unexpected parameters: {0}'.format(extras)) + # Make sure everything could be converted. + if len(cannot_convert) != 0: + bad = COMMASPACE.join(sorted(cannot_convert)) + raise ValueError('Cannot convert parameters: {0}'.format(bad)) + # Make sure nothing's missing. + value_keys = set(values) + required_keys = set(self._converters) - self._optional + if value_keys & required_keys != required_keys: + missing = COMMASPACE.join(sorted(required_keys - value_keys)) + raise ValueError('Missing parameters: {0}'.format(missing)) + return values diff --git a/src/mailman/rest/initialize.py b/src/mailman/rest/initialize.py deleted file mode 100644 index 953ba4248..000000000 --- a/src/mailman/rest/initialize.py +++ /dev/null @@ -1,35 +0,0 @@ -# Copyright (C) 2009-2010 by the Free Software Foundation, Inc. -# -# This file is part of GNU Mailman. -# -# GNU Mailman is free software: you can redistribute it and/or modify it under -# the terms of the GNU General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# GNU Mailman 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 General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along with -# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. - -"""Admin web service initialization.""" - -from __future__ import absolute_import, unicode_literals - -__metaclass__ = type -__all__ = [ - 'initialize', - ] - - -from zope.configuration import xmlconfig - - - -def initialize(): - """Initialize the admin web service and the Zope Component Architecture.""" - import mailman.rest - xmlconfig.file('configure.zcml', mailman.rest) diff --git a/src/mailman/rest/lists.py b/src/mailman/rest/lists.py new file mode 100644 index 000000000..947869cd9 --- /dev/null +++ b/src/mailman/rest/lists.py @@ -0,0 +1,127 @@ +# Copyright (C) 2010 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman 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 General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""REST for mailing lists.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'AList', + 'AllLists', + ] + + +from restish import http, resource +from zope.component import getUtility + +from mailman.app.lifecycle import create_list +from mailman.interfaces.domain import BadDomainSpecificationError +from mailman.interfaces.listmanager import ( + IListManager, ListAlreadyExistsError) +from mailman.interfaces.member import MemberRole +from mailman.rest.helpers import CollectionMixin, Validator, etag, path_to +from mailman.rest.members import AMember + + + +def member_matcher(request, segments): + """A matcher of member URLs inside mailing lists. + + e.g. /member/aperson@example.org + """ + if len(segments) != 2: + return None + try: + role = MemberRole[segments[0]] + except ValueError: + # Not a valid role. + return None + # No more segments. + # XXX 2010-02-25 barry Matchers are undocumented in restish; they return a + # 3-tuple of (match_args, match_kws, segments). + return (), dict(role=role, address=segments[1]), () + +# XXX 2010-02-24 barry Seems like contrary to the documentation, matchers +# cannot be plain function, because matchers must have a .score attribute. +# OTOH, I think they support regexps, so that might be a better way to go. +member_matcher.score = () + + + +class _ListBase(resource.Resource, CollectionMixin): + """Shared base class for mailing list representations.""" + + def _resource_as_dict(self, mlist): + """See `CollectionMixin`.""" + return dict( + fqdn_listname=mlist.fqdn_listname, + host_name=mlist.host_name, + list_name=mlist.list_name, + real_name=mlist.real_name, + self_link=path_to('lists/{0}'.format(mlist.fqdn_listname)), + ) + + def _get_collection(self, request): + """See `CollectionMixin`.""" + return list(getUtility(IListManager)) + + +class AList(_ListBase): + """A mailing list.""" + + def __init__(self, list_name): + self._mlist = getUtility(IListManager).get(list_name) + + @resource.GET() + def mailing_list(self, request): + """Return a single mailing list end-point.""" + if self._mlist is None: + return http.not_found() + return http.ok([], self._resource_as_json(self._mlist)) + + @resource.child(member_matcher) + def member(self, request, segments, role, address): + return AMember(self._mlist, role, address) + + +class AllLists(_ListBase): + """The mailing lists.""" + + @resource.POST() + def create(self, request): + """Create a new mailing list.""" + try: + validator = Validator(fqdn_listname=unicode) + mlist = create_list(**validator(request)) + except ListAlreadyExistsError: + return http.bad_request([], b'Mailing list exists') + except BadDomainSpecificationError as error: + return http.bad_request([], b'Domain does not exist {0}'.format( + error.domain)) + except ValueError as error: + return http.bad_request([], str(error)) + # wsgiref wants headers to be bytes, not unicodes. + location = path_to('lists/{0}'.format(mlist.fqdn_listname)) + # Include no extra headers or body. + return http.created(location, [], None) + + @resource.GET() + def collection(self, request): + """/lists""" + resource = self._make_collection(request) + return http.ok([], etag(resource)) diff --git a/src/mailman/rest/members.py b/src/mailman/rest/members.py new file mode 100644 index 000000000..a5a3dd02e --- /dev/null +++ b/src/mailman/rest/members.py @@ -0,0 +1,128 @@ +# Copyright (C) 2010 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman 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 General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""REST for members.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'AMember', + 'AllMembers', + ] + + +from restish import http, resource +from zope.component import getUtility + +from mailman.app.membership import delete_member +from mailman.interfaces.address import InvalidEmailAddressError +from mailman.interfaces.listmanager import NoSuchListError +from mailman.interfaces.member import ( + AlreadySubscribedError, DeliveryMode, MemberRole) +from mailman.interfaces.membership import ISubscriptionService +from mailman.rest.helpers import CollectionMixin, Validator, etag, path_to + + + +class _MemberBase(resource.Resource, CollectionMixin): + """Shared base class for member representations.""" + + def _resource_as_dict(self, member): + """See `CollectionMixin`.""" + enum, dot, role = str(member.role).partition('.') + return dict( + self_link=path_to('lists/{0}/{1}/{2}'.format( + member.mailing_list, role, member.address.address)), + ) + + def _get_collection(self, request): + """See `CollectionMixin`.""" + return list(getUtility(ISubscriptionService)) + + +class AMember(_MemberBase): + """A member.""" + + def __init__(self, mailing_list, role, address): + self._mlist = mailing_list + self._role = role + self._address = address + # XXX 2010-02-24 barry There should be a more direct way to get a + # member out of a mailing list. + if self._role is MemberRole.member: + roster = self._mlist.members + elif self._role is MemberRole.owner: + roster = self._mlist.owners + elif self._role is MemberRole.moderator: + roster = self._mlist.moderators + else: + raise AssertionError( + 'Undefined MemberRole: {0}'.format(self._role)) + self._member = roster.get_member(self._address) + + @resource.GET() + def member(self, request): + """Return a single member end-point.""" + return http.ok([], self._resource_as_json(self._member)) + + @resource.DELETE() + def delete(self, request): + """Delete the member (i.e. unsubscribe).""" + # Leaving a list is a bit different than deleting a moderator or + # owner. Handle the former case first. For now too, we will not send + # an admin or user notification. + if self._role is MemberRole.member: + delete_member(self._mlist, self._address, False, False) + else: + self._member.unsubscribe() + return http.ok([], '') + + +class AllMembers(_MemberBase): + """The members.""" + + @resource.POST() + def create(self, request): + """Create a new member.""" + service = getUtility(ISubscriptionService) + try: + validator = Validator(fqdn_listname=unicode, + address=unicode, + real_name=unicode, + delivery_mode=unicode, + _optional=('real_name', 'delivery_mode')) + member = service.join(**validator(request)) + except AlreadySubscribedError: + return http.bad_request([], b'Member already subscribed') + except NoSuchListError: + return http.bad_request([], b'No such list') + except InvalidEmailAddressError: + return http.bad_request([], b'Invalid email address') + except ValueError as error: + return http.bad_request([], str(error)) + # wsgiref wants headers to be bytes, not unicodes. + location = path_to('lists/{0}/member/{1}'.format( + member.mailing_list, member.address.address)) + # Include no extra headers or body. + return http.created(location, [], None) + + @resource.GET() + def container(self, request): + """/members""" + resource = self._make_collection(request) + return http.ok([], etag(resource)) diff --git a/src/mailman/rest/publication.py b/src/mailman/rest/publication.py deleted file mode 100644 index becca8fa6..000000000 --- a/src/mailman/rest/publication.py +++ /dev/null @@ -1,65 +0,0 @@ -# Copyright (C) 2009-2010 by the Free Software Foundation, Inc. -# -# This file is part of GNU Mailman. -# -# GNU Mailman is free software: you can redistribute it and/or modify it under -# the terms of the GNU General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# GNU Mailman 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 General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along with -# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. - -"""Publication hooks.""" - -from __future__ import absolute_import, unicode_literals - -__metaclass__ = type -__all__ = [ - 'AdminWebServicePublication', - ] - - -import logging - -from lazr.restful.simple import Publication -from zope.publisher.interfaces import NotFound - -from mailman.config import config -from mailman.interfaces.rest import IResolvePathNames - -log = logging.getLogger('mailman.http') - - - -class AdminWebServicePublication(Publication): - """Very simple implementation of `IPublication`.""" - - def traverseName(self, request, ob, name): - """See `IPublication`.""" - missing = object() - resolver = IResolvePathNames(ob, missing) - if resolver is missing: - raise NotFound(ob, name, request) - next_step = resolver.get(name) - if next_step is None: - raise NotFound(ob, name, request) - return next_step - - def handleException(self, application, request, exc_info, - retry_allowed=True): - """See `IPublication`.""" - # Any in-progress transaction must be aborted. - config.db.abort() - super(AdminWebServicePublication, self).handleException( - application, request, exc_info, retry_allowed) - - def endRequest(self, request, ob): - """Ends the interaction.""" - config.db.commit() - super(AdminWebServicePublication, self).endRequest(request, ob) diff --git a/src/mailman/rest/root.py b/src/mailman/rest/root.py new file mode 100644 index 000000000..6835586b8 --- /dev/null +++ b/src/mailman/rest/root.py @@ -0,0 +1,94 @@ +# Copyright (C) 2010 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman 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 General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""The root of the REST API.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'Root', + ] + + +from restish import http, resource + +from mailman.config import config +from mailman.core.system import system +from mailman.rest.domains import ADomain, AllDomains +from mailman.rest.helpers import etag, path_to +from mailman.rest.lists import AList, AllLists +from mailman.rest.members import AllMembers + + + +class Root(resource.Resource): + """The RESTful root resource. + + At the root of the tree are the API version numbers. Everything else + lives underneath those. Currently there is only one API version number, + and we start at 3.0 to match the Mailman version number. That may not + always be the case though. + """ + @resource.child(config.webservice.api_version) + def api_version(self, request, segments): + return TopLevel() + + +class TopLevel(resource.Resource): + """Top level collections and entries.""" + + @resource.child() + def system(self, request, segments): + """/<api>/system""" + resource = dict( + mailman_version=system.mailman_version, + python_version=system.python_version, + self_link=path_to('system'), + ) + return http.ok([], etag(resource)) + + @resource.child() + def domains(self, request, segments): + """/<api>/domains + /<api>/domains/<domain> + """ + if len(segments) == 0: + return AllDomains() + elif len(segments) == 1: + return ADomain(segments[0]), [] + else: + return http.bad_request() + + @resource.child() + def lists(self, request, segments): + """/<api>/lists + /<api>/lists/<list> + /<api>/lists/<list>/... + """ + if len(segments) == 0: + return AllLists() + else: + list_name = segments.pop(0) + return AList(list_name), segments + + @resource.child() + def members(self, request, segments): + """/<api>/members""" + if len(segments) == 0: + return AllMembers() + return http.bad_request() diff --git a/src/mailman/rest/security.py b/src/mailman/rest/security.py deleted file mode 100644 index d33a6554c..000000000 --- a/src/mailman/rest/security.py +++ /dev/null @@ -1,37 +0,0 @@ -# Copyright (C) 2009-2010 by the Free Software Foundation, Inc. -# -# This file is part of GNU Mailman. -# -# GNU Mailman is free software: you can redistribute it and/or modify it under -# the terms of the GNU General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# GNU Mailman 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 General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along with -# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. - -"""Default security policy for the admin web service.""" - -from __future__ import absolute_import, unicode_literals - -__metaclass__ = type -__all__ = [ - 'AdminWebServiceSecurityPolicy', - ] - - -from zope.security.simplepolicies import PermissiveSecurityPolicy - - - -class AdminWebServiceSecurityPolicy(PermissiveSecurityPolicy): - """A very basic wide-open security policy.""" - - def checkPermission(self, permission, obj): - """By default, allow all access!""" - return True diff --git a/src/mailman/rest/urls.py b/src/mailman/rest/urls.py deleted file mode 100644 index 566696f82..000000000 --- a/src/mailman/rest/urls.py +++ /dev/null @@ -1,142 +0,0 @@ -# Copyright (C) 2009-2010 by the Free Software Foundation, Inc. -# -# This file is part of GNU Mailman. -# -# GNU Mailman is free software: you can redistribute it and/or modify it under -# the terms of the GNU General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# GNU Mailman 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 General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along with -# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. - -"""Mappers from objects to absolute URLs.""" - -from __future__ import absolute_import, unicode_literals - -__metaclass__ = type -__all__ = [ - 'DomainURLMapper', - 'FallbackURLMapper', - 'MailingListURLMapper', - 'MemberURLMapper', - ] - - -import logging - -from zope.component import getUtility -from zope.interface import implements -from zope.traversing.browser.interfaces import IAbsoluteURL - -from mailman.config import config -from mailman.core.system import system -from mailman.interfaces.listmanager import IListManager -from mailman.interfaces.membership import ISubscriptionService -from mailman.rest.configuration import AdminWebServiceConfiguration -from mailman.rest.webservice import AdminWebServiceApplication - -log = logging.getLogger('mailman.http') - - - -class BasicURLMapper: - """Base absolute URL mapper.""" - - implements(IAbsoluteURL) - - def __init__(self, context, request): - """Initialize with respect to a context and request.""" - self.context = context - self.request = request - self.webservice_config = AdminWebServiceConfiguration() - # XXX 2010-02-16 barry This kind of sucks, but I don't understand how - # to reconcile the way we used to do things with the way lazr.restful - # as of 0.9.18 wants to do multiversion webservices. And really, I - # don't care because right now I don't have any need for - # multiversioned services. lazr.restful forced me to think about it - # though, so this just hardcodes the version part of the resource URL - # path to the first (i.e. numbered) version. - self.version = self.webservice_config.active_versions[0] - self.schema = ('https' if self.webservice_config.use_https else 'http') - self.hostname = self.webservice_config.hostname - self.port = self.webservice_config.port - - - -class FallbackURLMapper(BasicURLMapper): - """Generic absolute url mapper.""" - - def __call__(self): - """Return the semi-hard-coded URL to the service root.""" - path = self._lookup(self.context) - return '{0.schema}://{0.hostname}:{0.port}/{0.version}/{1}'.format( - self, path) - - def _lookup(self, ob): - """Return the path component for the object. - - :param ob: The object we're looking for. - :type ob: anything - :return: The path component. - :rtype: string - :raises KeyError: if no path component can be found. - """ - log.debug('generic url mapper lookup: %s', ob) - # Special cases. - if isinstance(ob, AdminWebServiceApplication): - return '' - urls = { - system: 'system', - getUtility(IListManager): 'lists', - getUtility(ISubscriptionService): 'members', - } - return urls[ob] - - - -class TopLevelURLMapper(BasicURLMapper): - """A simple mapper for top level objects.""" - - implements(IAbsoluteURL) - - format_string = None - - def __call__(self): - """Return the hard-coded URL to the resource.""" - return self.format_string.format(self) - - -class DomainURLMapper(TopLevelURLMapper): - """Mapper of `IDomains` to `IAbsoluteURL`.""" - - format_string = ( - '{0.schema}://{0.hostname}:{0.port}/{0.version}/' - 'domains/{0.context.email_host}') - - -class MailingListURLMapper(TopLevelURLMapper): - """Mapper of `IMailingList` to `IAbsoluteURL`.""" - - format_string = ( - '{0.schema}://{0.hostname}:{0.port}/{0.version}/' - 'lists/{0.context.fqdn_listname}') - - -class MemberURLMapper(TopLevelURLMapper): - """Mapper of `IMember` to `IAbsoluteURL`.""" - - def __init__(self, context, request): - super(MemberURLMapper, self).__init__(context, request) - # Use a shorted version of the MemberRole string. - enum, dot, self.role = str(self.context.role).partition('.') - - format_string = ( - '{0.schema}://{0.hostname}:{0.port}/{0.version}/' - 'lists/{0.context.mailing_list}/' - '{0.role}/{0.context.address.address}') diff --git a/src/mailman/rest/webservice.py b/src/mailman/rest/webservice.py deleted file mode 100644 index 5c42635cb..000000000 --- a/src/mailman/rest/webservice.py +++ /dev/null @@ -1,131 +0,0 @@ -# Copyright (C) 2009-2010 by the Free Software Foundation, Inc. -# -# This file is part of GNU Mailman. -# -# GNU Mailman is free software: you can redistribute it and/or modify it under -# the terms of the GNU General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) -# any later version. -# -# GNU Mailman 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 General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along with -# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. - -"""Module stuff.""" - -from __future__ import absolute_import, unicode_literals - -__metaclass__ = type -__all__ = [ - 'AdminWebServiceApplication', - 'AdminWebServiceRequest', - 'make_server', - ] - - -import logging - -# Don't use wsgiref.simple_server.make_server() because we need to override -# BaseHTTPRequestHandler.log_message() so that logging output will go to the -# proper Mailman logger instead of stderr, as is the default. -from wsgiref.simple_server import WSGIServer, WSGIRequestHandler - -from lazr.restful import register_versioned_request_utility -from lazr.restful.interfaces import ( - IServiceRootResource, IWebServiceClientRequest) -from lazr.restful.simple import Request, RootResource -from lazr.restful.wsgi import WSGIApplication -from zope.component import getUtility -from zope.interface import implements -from zope.publisher.publish import publish - -from mailman.config import config -from mailman.core.system import system -from mailman.interfaces.domain import IDomain, IDomainCollection -from mailman.interfaces.listmanager import IListManager -from mailman.interfaces.mailinglist import IMailingList -from mailman.interfaces.member import IMember -from mailman.interfaces.membership import ISubscriptionService -from mailman.interfaces.rest import IResolvePathNames -from mailman.rest.publication import AdminWebServicePublication - -log = logging.getLogger('mailman.http') - - - -# Marker interfaces for multiversion lazr.restful. -# -# XXX 2010-02-16 barry Gah! lazr.restful's multiversion.txt document says -# these classes should get generated, and the registrations should happen, -# automatically. This is not the case AFAICT. Why?! - -class I30Version(IWebServiceClientRequest): - pass - - -class IDevVersion(IWebServiceClientRequest): - pass - - - -class AdminWebServiceRootResource(RootResource): - """The lazr.restful non-versioned root resource.""" - - implements(IResolvePathNames) - - # XXX 2010-02-16 barry lazr.restful really wants this class to exist and - # be a subclass of RootResource. Our own traversal really wants this to - # implement IResolvePathNames. RootResource says to override - # _build_top_level_objects() to return the top-level objects, but that - # appears to never be called by lazr.restful, so you've got me. I don't - # understand this, which sucks, so just ensure that it doesn't do anything - # useful so if/when I do understand this, I can resolve the conflict - # between the way lazr.restful wants us to do things and the way our - # traversal wants to do things. - def _build_top_level_objects(self): - """See `RootResource`.""" - raise NotImplementedError('Magic suddenly got invoked') - - def get(self, name): - """See `IResolvePathNames`.""" - top_names = dict( - domains=getUtility(IDomainCollection), - lists=getUtility(IListManager), - members=getUtility(ISubscriptionService), - system=system, - ) - return top_names.get(name) - - -class AdminWebServiceApplication(WSGIApplication): - """A WSGI application for the admin REST interface.""" - - # The only thing we need to override is the publication class. - publication_class = AdminWebServicePublication - - -class AdminWebServiceWSGIRequestHandler(WSGIRequestHandler): - """Handler class which just logs output to the right place.""" - - def log_message(self, format, *args): - """See `BaseHTTPRequestHandler`.""" - log.info('%s - - %s', self.address_string(), format % args) - - - -def make_server(): - """Create the WSGI admin REST server.""" - # XXX 2010-02-16 barry Gah! lazr.restful's multiversion.txt document says - # these classes should get generated, and the registrations should happen, - # automatically. This is not the case AFAICT. Why?! - register_versioned_request_utility(I30Version, '3.0') - register_versioned_request_utility(IDevVersion, 'dev') - host = config.webservice.hostname - port = int(config.webservice.port) - server = WSGIServer((host, port), AdminWebServiceWSGIRequestHandler) - server.set_app(AdminWebServiceApplication) - return server diff --git a/src/mailman/rest/wsgiapp.py b/src/mailman/rest/wsgiapp.py new file mode 100644 index 000000000..4ee674fa4 --- /dev/null +++ b/src/mailman/rest/wsgiapp.py @@ -0,0 +1,87 @@ +# Copyright (C) 2010 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman 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 General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""Basic WSGI Application object for REST server.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'make_application', + 'make_server', + ] + + +import logging + +from restish.app import RestishApp +from wsgiref.simple_server import WSGIRequestHandler +from wsgiref.simple_server import make_server as wsgi_server + +from mailman.config import config +from mailman.rest.root import Root + + +log = logging.getLogger('mailman.http') + + + +class AdminWebServiceWSGIRequestHandler(WSGIRequestHandler): + """Handler class which just logs output to the right place.""" + + def log_message(self, format, *args): + """See `BaseHTTPRequestHandler`.""" + log.info('%s - - %s', self.address_string(), format % args) + + +class AdminWebServiceApplication(RestishApp): + """Connect the restish WSGI application to Mailman's database.""" + + def __call__(self, environ, start_response): + """See `RestishApp`.""" + try: + response = super(AdminWebServiceApplication, self).__call__( + environ, start_response) + except: + config.db.abort() + raise + else: + config.db.commit() + return response + + + +def make_application(): + """Create the WSGI application. + + Use this if you want to integrate Mailman's REST server with your own WSGI + server. + """ + return AdminWebServiceApplication(Root()) + + +def make_server(): + """Create the Mailman REST server. + + Use this if you just want to run Mailman's wsgiref-based REST server. + """ + host = config.webservice.hostname + port = int(config.webservice.port) + server = wsgi_server( + host, port, make_application(), + handler_class=AdminWebServiceWSGIRequestHandler) + return server diff --git a/src/mailman/tests/test_documentation.py b/src/mailman/tests/test_documentation.py index 6bfcf014a..61ddae2c2 100644 --- a/src/mailman/tests/test_documentation.py +++ b/src/mailman/tests/test_documentation.py @@ -37,8 +37,9 @@ import doctest import unittest from email import message_from_string +from httplib2 import Http from urllib import urlencode -from urllib2 import urlopen +from urllib2 import HTTPError import mailman @@ -110,30 +111,37 @@ def dump_msgdata(msgdata, *additional_skips): print '{0:{2}}: {1}'.format(key, msgdata[key], longest) -def dump_json(url, data=None): +def dump_json(url, data=None, method=None): """Print the JSON dictionary read from a URL. :param url: The url to open, read, and print. :type url: string :param data: Data to use to POST to a URL. :type data: dict + :param method: Alternative HTTP method to use. + :type method: str """ - if data is None: - fp = urlopen(url) - else: - fp = urlopen(url, urlencode(data)) + if data is not None: + data = urlencode(data) + headers = {} + if method is None: + if data is None: + method = 'GET' + else: + method = 'POST' + headers['Content-Type'] = 'application/x-www-form-urlencoded' + method = method.upper() + response, content = Http().request(url, method, data, headers) + # If we did not get a 2xx status code, make this look like a urllib2 + # exception, for backward compatibility with existing doctests. + if response.status // 100 != 2: + raise HTTPError(url, response.status, response.reason, response, None) # fp does not support the context manager protocol. - try: - raw_data = fp.read() - if len(raw_data) == 0: - print 'URL:', fp.geturl() - info = fp.info() - for header in sorted(info): - print '{0}: {1}'.format(header, info[header]) - return - data = json.loads(raw_data) - finally: - fp.close() + if len(content) == 0: + for header in sorted(response): + print '{0}: {1}'.format(header, response[header]) + return + data = json.loads(content) for key in sorted(data): if key == 'entries': for i, entry in enumerate(data[key]): |
