summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/mailman/__init__.py7
-rw-r--r--src/mailman/config/configure.zcml5
-rw-r--r--src/mailman/interfaces/membership.py3
-rw-r--r--src/mailman/model/listmanager.py1
-rw-r--r--src/mailman/rest/adapters.py41
-rw-r--r--src/mailman/rest/docs/domains.txt7
-rw-r--r--src/mailman/rest/docs/lists.txt7
-rw-r--r--src/mailman/rest/docs/membership.txt64
-rw-r--r--src/mailman/rest/webservice.py179
-rw-r--r--src/mailman/tests/test_documentation.py42
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]):