diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/mailman/__init__.py | 7 | ||||
| -rw-r--r-- | src/mailman/config/configure.zcml | 5 | ||||
| -rw-r--r-- | src/mailman/interfaces/membership.py | 3 | ||||
| -rw-r--r-- | src/mailman/model/listmanager.py | 1 | ||||
| -rw-r--r-- | src/mailman/rest/adapters.py | 41 | ||||
| -rw-r--r-- | src/mailman/rest/docs/domains.txt | 7 | ||||
| -rw-r--r-- | src/mailman/rest/docs/lists.txt | 7 | ||||
| -rw-r--r-- | src/mailman/rest/docs/membership.txt | 64 | ||||
| -rw-r--r-- | src/mailman/rest/webservice.py | 179 | ||||
| -rw-r--r-- | src/mailman/tests/test_documentation.py | 42 |
10 files changed, 230 insertions, 126 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/config/configure.zcml b/src/mailman/config/configure.zcml index 5cfdf41fe..e0188633b 100644 --- a/src/mailman/config/configure.zcml +++ b/src/mailman/config/configure.zcml @@ -58,4 +58,9 @@ factory="mailman.app.registrar.Registrar" /> + <utility + factory="mailman.rest.adapters.SubscriptionService" + provides="mailman.interfaces.membership.ISubscriptionService" + /> + </configure> diff --git a/src/mailman/interfaces/membership.py b/src/mailman/interfaces/membership.py index bcef2eb09..f42516ad1 100644 --- a/src/mailman/interfaces/membership.py +++ b/src/mailman/interfaces/membership.py @@ -48,6 +48,9 @@ class ISubscriptionService(Interface): :rtype: list of `IMember` """ + def __iter__(): + """See `get_members()`.""" + def join(fqdn_listname, address, real_name=None, delivery_mode=None): """Subscribe to a mailing list. diff --git a/src/mailman/model/listmanager.py b/src/mailman/model/listmanager.py index 1bb2366b5..cc3226b80 100644 --- a/src/mailman/model/listmanager.py +++ b/src/mailman/model/listmanager.py @@ -96,6 +96,7 @@ class ListManager: 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. diff --git a/src/mailman/rest/adapters.py b/src/mailman/rest/adapters.py index 585918e6b..2ae5d497e 100644 --- a/src/mailman/rest/adapters.py +++ b/src/mailman/rest/adapters.py @@ -29,56 +29,27 @@ 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 +from mailman.interfaces.rest import APIValueError 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,6 +67,10 @@ 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`.""" diff --git a/src/mailman/rest/docs/domains.txt b/src/mailman/rest/docs/domains.txt index cbb9e9fce..0bce5fa54 100644 --- a/src/mailman/rest/docs/domains.txt +++ b/src/mailman/rest/docs/domains.txt @@ -121,14 +121,11 @@ 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', { ... 'email_host': 'lists.example.com', ... }) - URL: http://localhost:8001/3.0/domains content-length: 0 date: ... location: http://localhost:8001/3.0/domains/lists.example.com @@ -160,13 +157,11 @@ 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 date: ... location: http://localhost:8001/3.0/domains/my.example.com diff --git a/src/mailman/rest/docs/lists.txt b/src/mailman/rest/docs/lists.txt index bb0824f7a..ede0706fd 100644 --- a/src/mailman/rest/docs/lists.txt +++ b/src/mailman/rest/docs/lists.txt @@ -35,14 +35,11 @@ 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 date: ... location: http://localhost:8001/3.0/lists/test-two@example.com @@ -74,7 +71,6 @@ 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): @@ -84,7 +80,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..776713f5f 100644 --- a/src/mailman/rest/docs/membership.txt +++ b/src/mailman/rest/docs/membership.txt @@ -183,14 +183,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. @@ -215,15 +215,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 +239,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 +265,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 +275,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 +314,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/webservice.py b/src/mailman/rest/webservice.py index 40e4a5ef6..d7f7d231e 100644 --- a/src/mailman/rest/webservice.py +++ b/src/mailman/rest/webservice.py @@ -39,15 +39,19 @@ from wsgiref.simple_server import ( from zope.component import getUtility from zope.interface import implements +from mailman.app.membership import delete_member from mailman.config import config from mailman.core.system import system +from mailman.interfaces.address import InvalidEmailAddressError from mailman.interfaces.domain import ( BadDomainSpecificationError, IDomain, IDomainManager) -from mailman.interfaces.listmanager import ListAlreadyExistsError, IListManager +from mailman.interfaces.listmanager import ( + IListManager, ListAlreadyExistsError, NoSuchListError) from mailman.interfaces.mailinglist import IMailingList -from mailman.interfaces.member import IMember +from mailman.interfaces.member import ( + AlreadySubscribedError, IMember, MemberRole) from mailman.interfaces.membership import ISubscriptionService -from mailman.interfaces.rest import IResolvePathNames +from mailman.interfaces.rest import APIValueError, IResolvePathNames #from mailman.rest.publication import AdminWebServicePublication @@ -92,12 +96,18 @@ class TopLevel(resource.Resource): def lists(self, request, segments): if len(segments) == 0: return AllLists() - elif len(segments) == 1: - return AList(segments[0]), [] else: - return http.bad_request() + list_name = segments.pop(0) + return AList(list_name), segments + @resource.child() + def members(self, request, segments): + if len(segments) == 0: + return AllMembers() + return http.bad_request() + + class _DomainBase(resource.Resource): """Shared base class for domain representations.""" @@ -143,12 +153,9 @@ class AllDomains(_DomainBase): # introspection of the target method, or via descriptors. domain_manager = getUtility(IDomainManager) try: - # Hmmm... webob gives this to us as a string, but we need - # unicodes. For backward compatibility with lazr.restful style - # requests, ignore any ws.op parameter. + # webob gives this to us as a string, but we need unicodes. kws = dict((key, unicode(value)) - for key, value in request.POST.items() - if key != 'ws.op') + for key, value in request.POST.items()) domain = domain_manager.add(**kws) except BadDomainSpecificationError: return http.bad_request([], 'Domain exists') @@ -181,6 +188,7 @@ class AllDomains(_DomainBase): return http.ok([], json.dumps(response)) + class _ListBase(resource.Resource): """Shared base class for mailing list representations.""" @@ -200,19 +208,43 @@ class _ListBase(resource.Resource): return list_data +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. + 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 AList(_ListBase): """A mailing list.""" - def __init__(self, mlist): - self._mlist = mlist + 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.""" - mlist = getUtility(IListManager).get(self._mlist) - if mlist is None: + if self._mlist is None: return http.not_found() - return http.ok([], json.dumps(self._format_list(mlist))) + return http.ok([], json.dumps(self._format_list(self._mlist))) + + @resource.child(member_matcher) + def member(self, request, segments, role, address): + return AMember(self._mlist, role, address) class AllLists(_ListBase): @@ -225,12 +257,9 @@ class AllLists(_ListBase): # introspection of the target method, or via descriptors. list_manager = getUtility(IListManager) try: - # Hmmm... webob gives this to us as a string, but we need - # unicodes. For backward compatibility with lazr.restful style - # requests, ignore any ws.op parameter. + # webob gives this to us as a string, but we need unicodes. kws = dict((key, unicode(value)) - for key, value in request.POST.items() - if key != 'ws.op') + for key, value in request.POST.items()) mlist = list_manager.new(**kws) except ListAlreadyExistsError: return http.bad_request([], b'Mailing list exists') @@ -266,6 +295,114 @@ class AllLists(_ListBase): return http.ok([], json.dumps(response)) + +class _MemberBase(resource.Resource): + """Shared base class for member representations.""" + + def _format_member(self, member): + """Format the data for a single member.""" + enum, dot, role = str(member.role).partition('.') + member_data = dict( + resource_type_link='http://localhost:8001/3.0/#member', + self_link='http://localhost:8001/3.0/lists/{0}/{1}/{2}'.format( + member.mailing_list, role, member.address.address), + ) + etag = hashlib.sha1(repr(member_data)).hexdigest() + member_data['http_etag'] = '"{0}"'.format(etag) + return member_data + + +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([], json.dumps(self._format_member(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.""" + # XXX 2010-02-23 barry Sanity check the POST arguments by + # introspection of the target method, or via descriptors. + service = getUtility(ISubscriptionService) + try: + # webob gives this to us as a string, but we need unicodes. + kws = dict((key, unicode(value)) + for key, value in request.POST.items()) + member = service.join(**kws) + 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 APIValueError as error: + return http.bad_request([], str(error)) + # wsgiref wants headers to be bytes, not unicodes. + location = b'http://localhost:8001/3.0/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): + """Return the /members end-point.""" + members = list(getUtility(ISubscriptionService)) + if len(members) == 0: + return http.ok( + [], json.dumps(dict(resource_type_link= + 'http://localhost:8001/3.0/#members', + start=None, + total_size=0))) + entries = [] + response = dict( + resource_type_link='http://localhost:8001/3.0/#members', + start=0, + total_size=len(members), + entries=entries, + ) + for member in members: + member_data = self._format_member(member) + entries.append(member_data) + return http.ok([], json.dumps(response)) + + + + ## class AdminWebServiceRootResource(RootResource): ## """The lazr.restful non-versioned root resource.""" 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]): |
