From 0fa9260a3d23b7ba17de727e66bcc27a0aeacc29 Mon Sep 17 00:00:00 2001 From: Barry Warsaw Date: Tue, 23 Feb 2010 14:31:41 -0500 Subject: Rip out lazr.restful and replace it with restish. This simpifies a lot of the REST architecture, at the expense of a few features, and less support. So far so good though. --- src/mailman/bin/qrunner.py | 5 +- src/mailman/config/configure.zcml | 4 +- src/mailman/core/system.py | 3 +- src/mailman/interfaces/address.py | 2 - src/mailman/interfaces/domain.py | 83 ++---------- src/mailman/interfaces/listmanager.py | 9 -- src/mailman/interfaces/mailinglist.py | 32 ++--- src/mailman/interfaces/member.py | 7 - src/mailman/interfaces/membership.py | 19 --- src/mailman/interfaces/rest.py | 2 - src/mailman/interfaces/system.py | 16 +-- src/mailman/rest/docs/basic.txt | 3 +- src/mailman/rest/docs/domains.txt | 11 +- src/mailman/rest/webservice.py | 233 +++++++++++++++++++++++++--------- 14 files changed, 205 insertions(+), 224 deletions(-) (limited to 'src') 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/config/configure.zcml b/src/mailman/config/configure.zcml index 5e8de8527..5cfdf41fe 100644 --- a/src/mailman/config/configure.zcml +++ b/src/mailman/config/configure.zcml @@ -2,7 +2,9 @@ - + + + >> 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..cbb9e9fce 100644 --- a/src/mailman/rest/docs/domains.txt +++ b/src/mailman/rest/docs/domains.txt @@ -126,17 +126,13 @@ lazr.restful requires us to use a 'named operation' instead of posting directly to the 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. @@ -172,12 +168,9 @@ address. ... }) 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 diff --git a/src/mailman/rest/webservice.py b/src/mailman/rest/webservice.py index 5c42635cb..8a1e919bf 100644 --- a/src/mailman/rest/webservice.py +++ b/src/mailman/rest/webservice.py @@ -27,85 +27,185 @@ __all__ = [ ] +import json +import hashlib 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 restish.app import RestishApp +from restish import http, resource +from wsgiref.simple_server import ( + make_server as wsgi_server, 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.domain import ( + BadDomainSpecificationError, IDomain, IDomainManager) 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 +#from mailman.rest.publication import AdminWebServicePublication + +COMMASPACE = ', ' 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 Root(resource.Resource): + """The RESTful root resource.""" -class I30Version(IWebServiceClientRequest): - pass + @resource.child('3.0') + def api_version(self, request, segments): + return TopLevel() -class IDevVersion(IWebServiceClientRequest): - pass +class TopLevel(resource.Resource): + """Top level collections and entries.""" - - -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, + @resource.child() + def system(self, request, segments): + response = dict( + mailman_version=system.mailman_version, + python_version=system.python_version, + resource_type_link='http://localhost:8001/3.0/#system', + self_link='http://localhost:8001/3.0/system', ) - return top_names.get(name) + etag = hashlib.sha1(repr(response)).hexdigest() + response['http_etag'] = '"{0}"'.format(etag) + return http.ok([], json.dumps(response)) + + @resource.child() + def domains(self, request, segments): + if len(segments) == 0: + return AllDomains() + elif len(segments) == 1: + return ADomain(segments[0]), [] + else: + return http.bad_request() + + +class _DomainBase(resource.Resource): + """Shared base class for domain representations.""" + + def _format_domain(self, domain): + """Format the data for a single domain.""" + domain_data = dict( + base_url=domain.base_url, + contact_address=domain.contact_address, + description=domain.description, + email_host=domain.email_host, + resource_type_link='http://localhost:8001/3.0/#domain', + self_link='http://localhost:8001/3.0/domains/{0}'.format( + domain.email_host), + url_host=domain.url_host, + ) + etag = hashlib.sha1(repr(domain_data)).hexdigest() + domain_data['http_etag'] = '"{0}"'.format(etag) + return domain_data + + +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([], json.dumps(self._format_domain(domain))) + + +class AllDomains(_DomainBase): + """The domains.""" + + @resource.POST() + def create(self, request): + """Create a new domain.""" + # XXX 2010-02-23 barry Sanity check the POST arguments by + # 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. + kws = dict((key, unicode(value)) + for key, value in request.POST.items() + if key != 'ws.op') + domain = domain_manager.add(**kws) + except BadDomainSpecificationError: + return http.bad_request([], 'Domain exists') + # wsgiref wants headers to be strings, not unicodes. + location = 'http://localhost:8001/3.0/domains/{0}'.format( + domain.email_host) + # Include no extra headers or body. + return http.created(str(location), [], None) + + @resource.GET() + def container(self, request): + """Return the /domains end-point.""" + domains = list(getUtility(IDomainManager)) + if len(domains) == 0: + return http.ok( + [], json.dumps(dict(resource_type_link= + 'http://localhost:8001/3.0/#domains', + start=None, + total_size=0))) + entries = [] + response = dict( + resource_type_link='http://localhost:8001/3.0/#domains', + start=0, + total_size=len(domains), + entries=entries, + ) + for domain in domains: + domain_data = self._format_domain(domain) + entries.append(domain_data) + return http.ok([], json.dumps(response)) + +## class AdminWebServiceRootResource(RootResource): +## """The lazr.restful non-versioned root resource.""" -class AdminWebServiceApplication(WSGIApplication): - """A WSGI application for the admin REST interface.""" +## implements(IResolvePathNames) - # The only thing we need to override is the publication class. - publication_class = AdminWebServicePublication +## # 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): @@ -116,16 +216,29 @@ class AdminWebServiceWSGIRequestHandler(WSGIRequestHandler): log.info('%s - - %s', self.address_string(), format % args) +class AdminWebServiceApplication(RestishApp): + """Interpose in the restish request processor.""" + + 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_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') + app = AdminWebServiceApplication(Root()) host = config.webservice.hostname port = int(config.webservice.port) - server = WSGIServer((host, port), AdminWebServiceWSGIRequestHandler) - server.set_app(AdminWebServiceApplication) + server = wsgi_server( + host, port, app, + handler_class=AdminWebServiceWSGIRequestHandler) return server -- cgit v1.3.1 From 34d4c62d414acbd7300efdb536070db3ac849979 Mon Sep 17 00:00:00 2001 From: Barry Warsaw Date: Tue, 23 Feb 2010 16:37:48 -0500 Subject: * Make the lists.txt doctest pass with restish. * Put the domain name on the BadDomainSpecificationError * Give the IListManager an __iter__() --- src/mailman/interfaces/domain.py | 4 ++ src/mailman/interfaces/listmanager.py | 42 ++++++++------ src/mailman/model/listmanager.py | 15 +++-- src/mailman/rest/docs/lists.txt | 5 +- src/mailman/rest/webservice.py | 102 ++++++++++++++++++++++++++++++++-- 5 files changed, 138 insertions(+), 30 deletions(-) (limited to 'src') diff --git a/src/mailman/interfaces/domain.py b/src/mailman/interfaces/domain.py index a8770b664..b7fc1c91f 100644 --- a/src/mailman/interfaces/domain.py +++ b/src/mailman/interfaces/domain.py @@ -37,6 +37,10 @@ from mailman.core.i18n import _ 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): diff --git a/src/mailman/interfaces/listmanager.py b/src/mailman/interfaces/listmanager.py index f47fe658e..f83791079 100644 --- a/src/mailman/interfaces/listmanager.py +++ b/src/mailman/interfaces/listmanager.py @@ -73,6 +73,25 @@ class IListManager(Interface): :raise `ListAlreadyExistsError` if the named list already exists. """ + + def new(fqdn_listname): + """Add a new mailing list. + + The mailing may not exist yet, but the domain specified in + `fqdn_listname` must exist. This is a higher level interface than + create() and should generally be used instead of that method. + + :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. + """ + def get(fqdn_listname): """Return the mailing list with the given name, if it exists. @@ -93,6 +112,12 @@ 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.""") @@ -103,20 +128,3 @@ class IListManager(Interface): :return: The list of all known mailing lists. :rtype: list of `IMailingList` """ - - 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/model/listmanager.py b/src/mailman/model/listmanager.py index b8bfa85ee..1bb2366b5 100644 --- a/src/mailman/model/listmanager.py +++ b/src/mailman/model/listmanager.py @@ -59,6 +59,11 @@ class ListManager: config.db.store.add(mlist) return mlist + def new(self, fqdn_listname): + """See `IListManager.""" + from mailman.app.lifecycle import create_list + return create_list(fqdn_listname) + def get(self, fqdn_listname): """See `IListManager`.""" listname, at, hostname = fqdn_listname.partition('@') @@ -80,6 +85,11 @@ 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`.""" @@ -90,8 +100,3 @@ class ListManager: """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/rest/docs/lists.txt b/src/mailman/rest/docs/lists.txt index 460ca3585..bb0824f7a 100644 --- a/src/mailman/rest/docs/lists.txt +++ b/src/mailman/rest/docs/lists.txt @@ -44,12 +44,9 @@ instead of posting directly to the URL. ... }) 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. diff --git a/src/mailman/rest/webservice.py b/src/mailman/rest/webservice.py index 8a1e919bf..40e4a5ef6 100644 --- a/src/mailman/rest/webservice.py +++ b/src/mailman/rest/webservice.py @@ -43,7 +43,7 @@ from mailman.config import config from mailman.core.system import system from mailman.interfaces.domain import ( BadDomainSpecificationError, IDomain, IDomainManager) -from mailman.interfaces.listmanager import IListManager +from mailman.interfaces.listmanager import ListAlreadyExistsError, IListManager from mailman.interfaces.mailinglist import IMailingList from mailman.interfaces.member import IMember from mailman.interfaces.membership import ISubscriptionService @@ -88,6 +88,15 @@ class TopLevel(resource.Resource): else: return http.bad_request() + @resource.child() + def lists(self, request, segments): + if len(segments) == 0: + return AllLists() + elif len(segments) == 1: + return AList(segments[0]), [] + else: + return http.bad_request() + class _DomainBase(resource.Resource): """Shared base class for domain representations.""" @@ -143,11 +152,11 @@ class AllDomains(_DomainBase): domain = domain_manager.add(**kws) except BadDomainSpecificationError: return http.bad_request([], 'Domain exists') - # wsgiref wants headers to be strings, not unicodes. - location = 'http://localhost:8001/3.0/domains/{0}'.format( + # wsgiref wants headers to be bytes, not unicodes. + location = b'http://localhost:8001/3.0/domains/{0}'.format( domain.email_host) # Include no extra headers or body. - return http.created(str(location), [], None) + return http.created(location, [], None) @resource.GET() def container(self, request): @@ -172,6 +181,91 @@ class AllDomains(_DomainBase): return http.ok([], json.dumps(response)) +class _ListBase(resource.Resource): + """Shared base class for mailing list representations.""" + + def _format_list(self, mlist): + """Format the mailing list for a single domain.""" + list_data = dict( + fqdn_listname=mlist.fqdn_listname, + host_name=mlist.host_name, + list_name=mlist.list_name, + real_name=mlist.real_name, + resource_type_link='http://localhost:8001/3.0/#list', + self_link='http://localhost:8001/3.0/lists/{0}'.format( + mlist.fqdn_listname), + ) + etag = hashlib.sha1(repr(list_data)).hexdigest() + list_data['http_etag'] = '"{0}"'.format(etag) + return list_data + + +class AList(_ListBase): + """A mailing list.""" + + def __init__(self, mlist): + self._mlist = mlist + + @resource.GET() + def mailing_list(self, request): + """Return a single mailing list end-point.""" + mlist = getUtility(IListManager).get(self._mlist) + if mlist is None: + return http.not_found() + return http.ok([], json.dumps(self._format_list(mlist))) + + +class AllLists(_ListBase): + """The mailing lists.""" + + @resource.POST() + def create(self, request): + """Create a new mailing list.""" + # XXX 2010-02-23 barry Sanity check the POST arguments by + # 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. + kws = dict((key, unicode(value)) + for key, value in request.POST.items() + if key != 'ws.op') + mlist = list_manager.new(**kws) + 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)) + # wsgiref wants headers to be bytes, not unicodes. + location = b'http://localhost:8001/3.0/lists/{0}'.format( + mlist.fqdn_listname) + # Include no extra headers or body. + return http.created(location, [], None) + + @resource.GET() + def container(self, request): + """Return the /lists end-point.""" + mlists = list(getUtility(IListManager)) + if len(mlists) == 0: + return http.ok( + [], json.dumps(dict(resource_type_link= + 'http://localhost:8001/3.0/#lists', + start=None, + total_size=0))) + entries = [] + response = dict( + resource_type_link='http://localhost:8001/3.0/#lists', + start=0, + total_size=len(mlists), + entries=entries, + ) + for mlist in mlists: + list_data = self._format_list(mlist) + entries.append(list_data) + return http.ok([], json.dumps(response)) + + ## class AdminWebServiceRootResource(RootResource): ## """The lazr.restful non-versioned root resource.""" -- cgit v1.3.1 From e6f2ab3cbc90733ec0d12b833437f2f3a8cec4ed Mon Sep 17 00:00:00 2001 From: Barry Warsaw Date: Wed, 24 Feb 2010 12:46:12 -0500 Subject: Complete the conversion to restish. All the tests pass and we're now actually RESTful. We have to use httplib2 to get DELETE and PUT. This is a prototype and could still use much cleanup. --- setup.py | 1 + src/mailman/__init__.py | 7 -- src/mailman/config/configure.zcml | 5 + src/mailman/interfaces/membership.py | 3 + src/mailman/model/listmanager.py | 1 + src/mailman/rest/adapters.py | 41 ++------ src/mailman/rest/docs/domains.txt | 7 +- src/mailman/rest/docs/lists.txt | 7 +- src/mailman/rest/docs/membership.txt | 64 +++++------- src/mailman/rest/webservice.py | 179 ++++++++++++++++++++++++++++---- src/mailman/tests/test_documentation.py | 42 +++++--- 11 files changed, 231 insertions(+), 126 deletions(-) (limited to 'src') diff --git a/setup.py b/setup.py index 14e694b4d..ed7d23d18 100644 --- a/setup.py +++ b/setup.py @@ -96,6 +96,7 @@ case second `m'. Any other spelling is incorrect.""", install_requires = [ 'argparse', 'flufl.i18n', + 'httplib2', 'lazr.config', 'lazr.smtptest', 'locknix', 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" /> + + 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]): -- cgit v1.3.1 From 3fd1dc135b0ad865e48b9038546ba1e1452b5897 Mon Sep 17 00:00:00 2001 From: Barry Warsaw Date: Thu, 25 Feb 2010 17:24:27 -0500 Subject: Remove some files that are not needed for restish implementation. --- src/mailman/config/configure.zcml | 2 - src/mailman/rest/configure.zcml | 116 -------------------------------------- src/mailman/rest/initialize.py | 35 ------------ src/mailman/rest/publication.py | 65 --------------------- src/mailman/rest/security.py | 37 ------------ 5 files changed, 255 deletions(-) delete mode 100644 src/mailman/rest/configure.zcml delete mode 100644 src/mailman/rest/initialize.py delete mode 100644 src/mailman/rest/publication.py delete mode 100644 src/mailman/rest/security.py (limited to 'src') diff --git a/src/mailman/config/configure.zcml b/src/mailman/config/configure.zcml index e0188633b..a7d12e7a2 100644 --- a/src/mailman/config/configure.zcml +++ b/src/mailman/config/configure.zcml @@ -4,8 +4,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 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 . - -"""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/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 . - -"""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/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 . - -"""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 -- cgit v1.3.1 From 296d14ea3081c0041a7445a2b157e57f5214c708 Mon Sep 17 00:00:00 2001 From: Barry Warsaw Date: Thu, 25 Feb 2010 19:01:32 -0500 Subject: Refactoring the REST support by removing unnecessary stuff and moving other stuff around. We no longer need APIValueError or IResolvePathNames. Also, refactor the creation of the REST server so that it could be used with other WSGI frameworks. --- src/mailman/interfaces/rest.py | 44 -------------------- src/mailman/model/listmanager.py | 3 +- src/mailman/queue/rest.py | 2 +- src/mailman/rest/adapters.py | 14 ++----- src/mailman/rest/webservice.py | 86 ++------------------------------------- src/mailman/rest/wsgiapp.py | 87 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 96 insertions(+), 140 deletions(-) delete mode 100644 src/mailman/interfaces/rest.py create mode 100644 src/mailman/rest/wsgiapp.py (limited to 'src') diff --git a/src/mailman/interfaces/rest.py b/src/mailman/interfaces/rest.py deleted file mode 100644 index 9ec7914ad..000000000 --- a/src/mailman/interfaces/rest.py +++ /dev/null @@ -1,44 +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 . - -"""Interfaces for the RESTful admin server.""" - -from __future__ import absolute_import, unicode_literals - -__metaclass__ = type -__all__ = [ - 'APIValueError', - 'IResolvePathNames', - ] - - -from zope.interface import Interface - -from mailman.core.errors import MailmanError - - - -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/model/listmanager.py b/src/mailman/model/listmanager.py index cc3226b80..3d0448e8e 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): 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 2ae5d497e..6acfe3866 100644 --- a/src/mailman/rest/adapters.py +++ b/src/mailman/rest/adapters.py @@ -36,7 +36,6 @@ from mailman.interfaces.address import InvalidEmailAddressError from mailman.interfaces.listmanager import IListManager, NoSuchListError from mailman.interfaces.member import DeliveryMode from mailman.interfaces.membership import ISubscriptionService -from mailman.interfaces.rest import APIValueError @@ -77,15 +76,10 @@ class SubscriptionService: 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/webservice.py b/src/mailman/rest/webservice.py index d7f7d231e..9db29f133 100644 --- a/src/mailman/rest/webservice.py +++ b/src/mailman/rest/webservice.py @@ -21,9 +21,7 @@ from __future__ import absolute_import, unicode_literals __metaclass__ = type __all__ = [ - 'AdminWebServiceApplication', - 'AdminWebServiceRequest', - 'make_server', + 'Root', ] @@ -31,10 +29,8 @@ import json import hashlib import logging -from restish.app import RestishApp from restish import http, resource -from wsgiref.simple_server import ( - make_server as wsgi_server, WSGIRequestHandler) +from wsgiref.simple_server import make_server as wsgi_server from zope.component import getUtility from zope.interface import implements @@ -51,8 +47,6 @@ from mailman.interfaces.mailinglist import IMailingList from mailman.interfaces.member import ( AlreadySubscribedError, IMember, MemberRole) from mailman.interfaces.membership import ISubscriptionService -from mailman.interfaces.rest import APIValueError, IResolvePathNames -#from mailman.rest.publication import AdminWebServicePublication COMMASPACE = ', ' @@ -370,7 +364,7 @@ class AllMembers(_MemberBase): return http.bad_request([], b'No such list') except InvalidEmailAddressError: return http.bad_request([], b'Invalid email address') - except APIValueError as error: + except ValueError 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( @@ -399,77 +393,3 @@ class AllMembers(_MemberBase): 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.""" - -## 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) - - -class AdminWebServiceApplication(RestishApp): - """Interpose in the restish request processor.""" - - 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_server(): - """Create the WSGI admin REST server.""" - app = AdminWebServiceApplication(Root()) - host = config.webservice.hostname - port = int(config.webservice.port) - server = wsgi_server( - host, port, app, - handler_class=AdminWebServiceWSGIRequestHandler) - return server diff --git a/src/mailman/rest/wsgiapp.py b/src/mailman/rest/wsgiapp.py new file mode 100644 index 000000000..cfb8cba50 --- /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 . + +"""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.webservice 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 -- cgit v1.3.1 From 33dc7b55e48c6e343a6a5e7518a3c8cb4e70b347 Mon Sep 17 00:00:00 2001 From: Barry Warsaw Date: Thu, 25 Feb 2010 19:41:58 -0500 Subject: More refactoring: * Rework the [webservice] section to be more useful to restish. * Get rid of the resource_type_link * Add and test some helpers. * Move the root resources to a different module. --- src/mailman/config/schema.cfg | 6 +-- src/mailman/queue/docs/rest.txt | 1 - src/mailman/rest/docs/basic.txt | 1 - src/mailman/rest/docs/helpers.txt | 59 ++++++++++++++++++++++++++ src/mailman/rest/helpers.py | 73 ++++++++++++++++++++++++++++++++ src/mailman/rest/root.py | 88 +++++++++++++++++++++++++++++++++++++++ src/mailman/rest/webservice.py | 50 ---------------------- src/mailman/rest/wsgiapp.py | 2 +- 8 files changed, 224 insertions(+), 56 deletions(-) create mode 100644 src/mailman/rest/docs/helpers.txt create mode 100644 src/mailman/rest/helpers.py create mode 100644 src/mailman/rest/root.py (limited to 'src') 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/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/rest/docs/basic.txt b/src/mailman/rest/docs/basic.txt index 6d928ab85..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 diff --git a/src/mailman/rest/docs/helpers.txt b/src/mailman/rest/docs/helpers.txt new file mode 100644 index 000000000..9304bbb17 --- /dev/null +++ b/src/mailman/rest/docs/helpers.txt @@ -0,0 +1,59 @@ +================ +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 diff --git a/src/mailman/rest/helpers.py b/src/mailman/rest/helpers.py new file mode 100644 index 000000000..d1afdb0d9 --- /dev/null +++ b/src/mailman/rest/helpers.py @@ -0,0 +1,73 @@ +# 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 . + +"""Web service helpers.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'etag', + 'path_to', + ] + + +import json +import hashlib + +from lazr.config import as_boolean +from mailman.config import config + + + +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: string + """ + return '{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) diff --git a/src/mailman/rest/root.py b/src/mailman/rest/root.py new file mode 100644 index 000000000..e3ba1b62e --- /dev/null +++ b/src/mailman/rest/root.py @@ -0,0 +1,88 @@ +# 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 . + +"""The root of the REST API.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'Root', + ] + + +import json +import hashlib + +from restish import http, resource + +from mailman.config import config +from mailman.core.system import system +from mailman.rest.helpers import etag, path_to +from mailman.rest.webservice import ( + ADomain, AList, AllDomains, AllLists, 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): + """//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): + 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): + if len(segments) == 0: + return AllLists() + else: + 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() diff --git a/src/mailman/rest/webservice.py b/src/mailman/rest/webservice.py index 9db29f133..03c2097af 100644 --- a/src/mailman/rest/webservice.py +++ b/src/mailman/rest/webservice.py @@ -21,7 +21,6 @@ from __future__ import absolute_import, unicode_literals __metaclass__ = type __all__ = [ - 'Root', ] @@ -37,7 +36,6 @@ 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) @@ -53,54 +51,6 @@ COMMASPACE = ', ' log = logging.getLogger('mailman.http') - -class Root(resource.Resource): - """The RESTful root resource.""" - - @resource.child('3.0') - def api_version(self, request, segments): - return TopLevel() - - -class TopLevel(resource.Resource): - """Top level collections and entries.""" - - @resource.child() - def system(self, request, segments): - response = dict( - mailman_version=system.mailman_version, - python_version=system.python_version, - resource_type_link='http://localhost:8001/3.0/#system', - self_link='http://localhost:8001/3.0/system', - ) - etag = hashlib.sha1(repr(response)).hexdigest() - response['http_etag'] = '"{0}"'.format(etag) - return http.ok([], json.dumps(response)) - - @resource.child() - def domains(self, request, segments): - 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): - if len(segments) == 0: - return AllLists() - else: - 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.""" diff --git a/src/mailman/rest/wsgiapp.py b/src/mailman/rest/wsgiapp.py index cfb8cba50..4ee674fa4 100644 --- a/src/mailman/rest/wsgiapp.py +++ b/src/mailman/rest/wsgiapp.py @@ -33,7 +33,7 @@ from wsgiref.simple_server import WSGIRequestHandler from wsgiref.simple_server import make_server as wsgi_server from mailman.config import config -from mailman.rest.webservice import Root +from mailman.rest.root import Root log = logging.getLogger('mailman.http') -- cgit v1.3.1 From ea7bb1a31c51a59949310c9837020908476e2c0d Mon Sep 17 00:00:00 2001 From: Barry Warsaw Date: Thu, 25 Feb 2010 20:11:28 -0500 Subject: Refactor domains into a separate file. --- src/mailman/rest/docs/domains.txt | 13 ++--- src/mailman/rest/domains.py | 107 ++++++++++++++++++++++++++++++++++++++ src/mailman/rest/helpers.py | 4 +- src/mailman/rest/root.py | 14 +++-- src/mailman/rest/webservice.py | 81 ----------------------------- 5 files changed, 122 insertions(+), 97 deletions(-) create mode 100644 src/mailman/rest/domains.py (limited to 'src') diff --git a/src/mailman/rest/docs/domains.txt b/src/mailman/rest/docs/domains.txt index 0bce5fa54..86c868530 100644 --- a/src/mailman/rest/docs/domains.txt +++ b/src/mailman/rest/docs/domains.txt @@ -16,7 +16,7 @@ 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 + http_etag: "f62b4cab6d4bfa731d5ff091dde29d318a3234a8" start: None total_size: 0 @@ -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 @@ -139,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 @@ -173,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/domains.py b/src/mailman/rest/domains.py new file mode 100644 index 000000000..ed63834b9 --- /dev/null +++ b/src/mailman/rest/domains.py @@ -0,0 +1,107 @@ +# 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 . + +"""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 etag, path_to + + + +class _DomainBase(resource.Resource): + """Shared base class for domain representations.""" + + def _domain_data(self, domain): + """Return the domain data for a single domain.""" + 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 _format_domain(self, domain): + """Format the data for a single domain.""" + return etag(self._domain_data(domain)) + + +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._format_domain(domain)) + + +class AllDomains(_DomainBase): + """The domains.""" + + @resource.POST() + def create(self, request): + """Create a new domain.""" + # XXX 2010-02-23 barry Sanity check the POST arguments by + # introspection of the target method, or via descriptors. + domain_manager = getUtility(IDomainManager) + 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()) + domain = domain_manager.add(**kws) + except BadDomainSpecificationError: + return http.bad_request([], 'Domain exists') + location = path_to('domains/{0}'.format(domain.email_host)) + # Include no extra headers or body. + return http.created(location, [], None) + + @resource.GET() + def container(self, request): + """Return the /domains end-point.""" + domains = list(getUtility(IDomainManager)) + if len(domains) == 0: + resource = dict(start=None, total_size=0) + return http.ok([], etag(resource)) + domain_data = [self._domain_data(domain) for domain in domains] + # Tag this domain data, but ignore the results. + [etag(data) for data in domain_data] + resource = dict( + start=0, + total_size=len(domains), + entries=domain_data, + ) + return http.ok([], etag(resource)) diff --git a/src/mailman/rest/helpers.py b/src/mailman/rest/helpers.py index d1afdb0d9..fac4ae4f1 100644 --- a/src/mailman/rest/helpers.py +++ b/src/mailman/rest/helpers.py @@ -41,9 +41,9 @@ def path_to(resource): system base URI. :type resource: string :return: The full path to the resource. - :rtype: string + :rtype: bytes """ - return '{0}://{1}:{2}/{3}/{4}'.format( + return b'{0}://{1}:{2}/{3}/{4}'.format( ('https' if as_boolean(config.webservice.use_https) else 'http'), config.webservice.hostname, config.webservice.port, diff --git a/src/mailman/rest/root.py b/src/mailman/rest/root.py index e3ba1b62e..e524f3632 100644 --- a/src/mailman/rest/root.py +++ b/src/mailman/rest/root.py @@ -25,16 +25,14 @@ __all__ = [ ] -import json -import hashlib - 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.webservice import ( - ADomain, AList, AllDomains, AllLists, AllMembers) + AList, AllLists, AllMembers) @@ -66,6 +64,9 @@ class TopLevel(resource.Resource): @resource.child() def domains(self, request, segments): + """//domains + //domains/ + """ if len(segments) == 0: return AllDomains() elif len(segments) == 1: @@ -75,6 +76,10 @@ class TopLevel(resource.Resource): @resource.child() def lists(self, request, segments): + """//lists + //lists/ + //lists//... + """ if len(segments) == 0: return AllLists() else: @@ -83,6 +88,7 @@ class TopLevel(resource.Resource): @resource.child() def members(self, request, segments): + """//members""" if len(segments) == 0: return AllMembers() return http.bad_request() diff --git a/src/mailman/rest/webservice.py b/src/mailman/rest/webservice.py index 03c2097af..403948de8 100644 --- a/src/mailman/rest/webservice.py +++ b/src/mailman/rest/webservice.py @@ -51,87 +51,6 @@ COMMASPACE = ', ' log = logging.getLogger('mailman.http') - -class _DomainBase(resource.Resource): - """Shared base class for domain representations.""" - - def _format_domain(self, domain): - """Format the data for a single domain.""" - domain_data = dict( - base_url=domain.base_url, - contact_address=domain.contact_address, - description=domain.description, - email_host=domain.email_host, - resource_type_link='http://localhost:8001/3.0/#domain', - self_link='http://localhost:8001/3.0/domains/{0}'.format( - domain.email_host), - url_host=domain.url_host, - ) - etag = hashlib.sha1(repr(domain_data)).hexdigest() - domain_data['http_etag'] = '"{0}"'.format(etag) - return domain_data - - -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([], json.dumps(self._format_domain(domain))) - - -class AllDomains(_DomainBase): - """The domains.""" - - @resource.POST() - def create(self, request): - """Create a new domain.""" - # XXX 2010-02-23 barry Sanity check the POST arguments by - # introspection of the target method, or via descriptors. - domain_manager = getUtility(IDomainManager) - 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()) - domain = domain_manager.add(**kws) - except BadDomainSpecificationError: - return http.bad_request([], 'Domain exists') - # wsgiref wants headers to be bytes, not unicodes. - location = b'http://localhost:8001/3.0/domains/{0}'.format( - domain.email_host) - # Include no extra headers or body. - return http.created(location, [], None) - - @resource.GET() - def container(self, request): - """Return the /domains end-point.""" - domains = list(getUtility(IDomainManager)) - if len(domains) == 0: - return http.ok( - [], json.dumps(dict(resource_type_link= - 'http://localhost:8001/3.0/#domains', - start=None, - total_size=0))) - entries = [] - response = dict( - resource_type_link='http://localhost:8001/3.0/#domains', - start=0, - total_size=len(domains), - entries=entries, - ) - for domain in domains: - domain_data = self._format_domain(domain) - entries.append(domain_data) - return http.ok([], json.dumps(response)) - - class _ListBase(resource.Resource): """Shared base class for mailing list representations.""" -- cgit v1.3.1 From b2ae2b7795719f17cb2b6c7ed14e5b4c7c8eae47 Mon Sep 17 00:00:00 2001 From: Barry Warsaw Date: Thu, 25 Feb 2010 20:38:25 -0500 Subject: Refactor both the mailing lists and membership REST wrappers. --- src/mailman/rest/docs/lists.txt | 6 +- src/mailman/rest/docs/membership.txt | 33 ++---- src/mailman/rest/domains.py | 8 +- src/mailman/rest/lists.py | 140 +++++++++++++++++++++++ src/mailman/rest/members.py | 138 +++++++++++++++++++++++ src/mailman/rest/root.py | 4 +- src/mailman/rest/webservice.py | 210 ----------------------------------- 7 files changed, 293 insertions(+), 246 deletions(-) create mode 100644 src/mailman/rest/lists.py create mode 100644 src/mailman/rest/members.py (limited to 'src') diff --git a/src/mailman/rest/docs/lists.txt b/src/mailman/rest/docs/lists.txt index ede0706fd..d04fed0b3 100644 --- a/src/mailman/rest/docs/lists.txt +++ b/src/mailman/rest/docs/lists.txt @@ -7,7 +7,7 @@ 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 + http_etag: "..." start: None total_size: 0 @@ -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 @@ -64,7 +63,6 @@ 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 diff --git a/src/mailman/rest/docs/membership.txt b/src/mailman/rest/docs/membership.txt index 776713f5f..e86f598f1 100644 --- a/src/mailman/rest/docs/membership.txt +++ b/src/mailman/rest/docs/membership.txt @@ -9,7 +9,7 @@ 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 + http_etag: "..." start: None total_size: 0 @@ -19,7 +19,7 @@ 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 + http_etag: "..." start: None 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 @@ -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 ... diff --git a/src/mailman/rest/domains.py b/src/mailman/rest/domains.py index ed63834b9..32f010534 100644 --- a/src/mailman/rest/domains.py +++ b/src/mailman/rest/domains.py @@ -96,12 +96,12 @@ class AllDomains(_DomainBase): if len(domains) == 0: resource = dict(start=None, total_size=0) return http.ok([], etag(resource)) - domain_data = [self._domain_data(domain) for domain in domains] - # Tag this domain data, but ignore the results. - [etag(data) for data in domain_data] + entries = [self._domain_data(domain) for domain in domains] + # Tag the domain entries, but use the dictionaries. + [etag(data) for data in entries] resource = dict( start=0, total_size=len(domains), - entries=domain_data, + entries=entries, ) return http.ok([], etag(resource)) diff --git a/src/mailman/rest/lists.py b/src/mailman/rest/lists.py new file mode 100644 index 000000000..682353373 --- /dev/null +++ b/src/mailman/rest/lists.py @@ -0,0 +1,140 @@ +# 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 . + +"""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.interfaces.domain import BadDomainSpecificationError +from mailman.interfaces.listmanager import ( + IListManager, ListAlreadyExistsError) +from mailman.interfaces.member import MemberRole +from mailman.rest.helpers import 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): + """Shared base class for mailing list representations.""" + + def _list_data(self, mlist): + """Return the list data for a single mailing list.""" + 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 _format_list(self, mlist): + """Format the mailing list for a single mailing list.""" + return etag(self._list_data(mlist)) + + +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._format_list(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.""" + # XXX 2010-02-23 barry Sanity check the POST arguments by + # introspection of the target method, or via descriptors. + list_manager = getUtility(IListManager) + 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()) + mlist = list_manager.new(**kws) + 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)) + # 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 container(self, request): + """Return the /lists end-point.""" + mlists = list(getUtility(IListManager)) + if len(mlists) == 0: + resource = dict(start=None, total_size=0) + return http.ok([], etag(resource)) + entries = [self._list_data(mlist) for mlist in mlists] + # Tag the list entries, but use the dictionaries. + [etag(data) for data in entries] + resource = dict( + start=0, + total_size=len(mlists), + entries=entries, + ) + 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..b18097935 --- /dev/null +++ b/src/mailman/rest/members.py @@ -0,0 +1,138 @@ +# 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 . + +"""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, MemberRole +from mailman.interfaces.membership import ISubscriptionService +from mailman.rest.helpers import etag, path_to + + + +class _MemberBase(resource.Resource): + """Shared base class for member representations.""" + + def _member_data(self, member): + """Return the member data for a single member.""" + 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 _format_member(self, member): + """Format the data for a single member.""" + return etag(self._member_data(member)) + + +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._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 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): + """Return the /members end-point.""" + members = list(getUtility(ISubscriptionService)) + if len(members) == 0: + resource = dict(start=None, total_size=0) + return http.ok([], etag(resource)) + entries = [self._member_data(member) for member in members] + # Tag the domain entries, but use the dictionaries. + [etag(data) for data in entries] + resource = dict( + start=0, + total_size=len(members), + entries=entries, + ) + return http.ok([], etag(resource)) diff --git a/src/mailman/rest/root.py b/src/mailman/rest/root.py index e524f3632..6835586b8 100644 --- a/src/mailman/rest/root.py +++ b/src/mailman/rest/root.py @@ -31,8 +31,8 @@ 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.webservice import ( - AList, AllLists, AllMembers) +from mailman.rest.lists import AList, AllLists +from mailman.rest.members import AllMembers diff --git a/src/mailman/rest/webservice.py b/src/mailman/rest/webservice.py index 403948de8..c8aa470b9 100644 --- a/src/mailman/rest/webservice.py +++ b/src/mailman/rest/webservice.py @@ -52,213 +52,3 @@ log = logging.getLogger('mailman.http') -class _ListBase(resource.Resource): - """Shared base class for mailing list representations.""" - - def _format_list(self, mlist): - """Format the mailing list for a single domain.""" - list_data = dict( - fqdn_listname=mlist.fqdn_listname, - host_name=mlist.host_name, - list_name=mlist.list_name, - real_name=mlist.real_name, - resource_type_link='http://localhost:8001/3.0/#list', - self_link='http://localhost:8001/3.0/lists/{0}'.format( - mlist.fqdn_listname), - ) - etag = hashlib.sha1(repr(list_data)).hexdigest() - list_data['http_etag'] = '"{0}"'.format(etag) - 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, 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([], 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): - """The mailing lists.""" - - @resource.POST() - def create(self, request): - """Create a new mailing list.""" - # XXX 2010-02-23 barry Sanity check the POST arguments by - # introspection of the target method, or via descriptors. - list_manager = getUtility(IListManager) - 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()) - mlist = list_manager.new(**kws) - 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)) - # wsgiref wants headers to be bytes, not unicodes. - location = b'http://localhost:8001/3.0/lists/{0}'.format( - mlist.fqdn_listname) - # Include no extra headers or body. - return http.created(location, [], None) - - @resource.GET() - def container(self, request): - """Return the /lists end-point.""" - mlists = list(getUtility(IListManager)) - if len(mlists) == 0: - return http.ok( - [], json.dumps(dict(resource_type_link= - 'http://localhost:8001/3.0/#lists', - start=None, - total_size=0))) - entries = [] - response = dict( - resource_type_link='http://localhost:8001/3.0/#lists', - start=0, - total_size=len(mlists), - entries=entries, - ) - for mlist in mlists: - list_data = self._format_list(mlist) - entries.append(list_data) - 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 ValueError 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)) -- cgit v1.3.1 From 70dd8a96cc0e01b42737b46e30cf37b5c8fa0747 Mon Sep 17 00:00:00 2001 From: Barry Warsaw Date: Thu, 25 Feb 2010 20:40:44 -0500 Subject: Remove more files that are unnecessary. --- src/mailman/rest/configuration.py | 87 ----------------------- src/mailman/rest/urls.py | 142 -------------------------------------- src/mailman/rest/webservice.py | 54 --------------- 3 files changed, 283 deletions(-) delete mode 100644 src/mailman/rest/configuration.py delete mode 100644 src/mailman/rest/urls.py delete mode 100644 src/mailman/rest/webservice.py (limited to 'src') 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 . - -"""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/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 . - -"""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 c8aa470b9..000000000 --- a/src/mailman/rest/webservice.py +++ /dev/null @@ -1,54 +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 . - -"""Module stuff.""" - -from __future__ import absolute_import, unicode_literals - -__metaclass__ = type -__all__ = [ - ] - - -import json -import hashlib -import logging - -from restish import http, resource -from wsgiref.simple_server import make_server as wsgi_server - -from zope.component import getUtility -from zope.interface import implements - -from mailman.app.membership import delete_member -from mailman.config import config -from mailman.interfaces.address import InvalidEmailAddressError -from mailman.interfaces.domain import ( - BadDomainSpecificationError, IDomain, IDomainManager) -from mailman.interfaces.listmanager import ( - IListManager, ListAlreadyExistsError, NoSuchListError) -from mailman.interfaces.mailinglist import IMailingList -from mailman.interfaces.member import ( - AlreadySubscribedError, IMember, MemberRole) -from mailman.interfaces.membership import ISubscriptionService - - -COMMASPACE = ', ' -log = logging.getLogger('mailman.http') - - - -- cgit v1.3.1 From c231eb4a8c1bd593804a3a2f05f07966dcd73f18 Mon Sep 17 00:00:00 2001 From: Barry Warsaw Date: Thu, 25 Feb 2010 21:07:27 -0500 Subject: Refactoring for common collection code. --- src/mailman/rest/docs/domains.txt | 4 +-- src/mailman/rest/docs/lists.txt | 2 +- src/mailman/rest/docs/membership.txt | 4 +-- src/mailman/rest/domains.py | 33 ++++++++---------------- src/mailman/rest/helpers.py | 50 ++++++++++++++++++++++++++++++++++++ src/mailman/rest/lists.py | 33 ++++++++---------------- src/mailman/rest/members.py | 31 ++++++++-------------- 7 files changed, 87 insertions(+), 70 deletions(-) (limited to 'src') diff --git a/src/mailman/rest/docs/domains.txt b/src/mailman/rest/docs/domains.txt index 86c868530..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') - http_etag: "f62b4cab6d4bfa731d5ff091dde29d318a3234a8" - start: None + http_etag: "..." + start: 0 total_size: 0 Once a domain is added though, it is accessible through the API. diff --git a/src/mailman/rest/docs/lists.txt b/src/mailman/rest/docs/lists.txt index d04fed0b3..6abf29a1d 100644 --- a/src/mailman/rest/docs/lists.txt +++ b/src/mailman/rest/docs/lists.txt @@ -8,7 +8,7 @@ yet though. >>> dump_json('http://localhost:8001/3.0/lists') http_etag: "..." - start: None + start: 0 total_size: 0 Create a mailing list in a domain and it's accessible via the API. diff --git a/src/mailman/rest/docs/membership.txt b/src/mailman/rest/docs/membership.txt index e86f598f1..70d4384ce 100644 --- a/src/mailman/rest/docs/membership.txt +++ b/src/mailman/rest/docs/membership.txt @@ -10,7 +10,7 @@ There are no mailing lists and no members yet. >>> dump_json('http://localhost:8001/3.0/members') http_etag: "..." - start: None + start: 0 total_size: 0 We create a mailing list, which starts out with no members. @@ -20,7 +20,7 @@ We create a mailing list, which starts out with no members. >>> dump_json('http://localhost:8001/3.0/members') http_etag: "..." - start: None + start: 0 total_size: 0 diff --git a/src/mailman/rest/domains.py b/src/mailman/rest/domains.py index 32f010534..8b7cd2699 100644 --- a/src/mailman/rest/domains.py +++ b/src/mailman/rest/domains.py @@ -31,15 +31,15 @@ from zope.component import getUtility from mailman.interfaces.domain import ( BadDomainSpecificationError, IDomainManager) -from mailman.rest.helpers import etag, path_to +from mailman.rest.helpers import CollectionMixin, etag, path_to -class _DomainBase(resource.Resource): +class _DomainBase(resource.Resource, CollectionMixin): """Shared base class for domain representations.""" - def _domain_data(self, domain): - """Return the domain data for a single domain.""" + def _resource_as_dict(self, domain): + """See `CollectionMixin`.""" return dict( base_url=domain.base_url, contact_address=domain.contact_address, @@ -49,9 +49,9 @@ class _DomainBase(resource.Resource): url_host=domain.url_host, ) - def _format_domain(self, domain): - """Format the data for a single domain.""" - return etag(self._domain_data(domain)) + def _get_collection(self, request): + """See `CollectionMixin`.""" + return list(getUtility(IDomainManager)) class ADomain(_DomainBase): @@ -66,7 +66,7 @@ class ADomain(_DomainBase): domain = getUtility(IDomainManager).get(self._domain) if domain is None: return http.not_found() - return http.ok([], self._format_domain(domain)) + return http.ok([], self._resource_as_json(domain)) class AllDomains(_DomainBase): @@ -90,18 +90,7 @@ class AllDomains(_DomainBase): return http.created(location, [], None) @resource.GET() - def container(self, request): - """Return the /domains end-point.""" - domains = list(getUtility(IDomainManager)) - if len(domains) == 0: - resource = dict(start=None, total_size=0) - return http.ok([], etag(resource)) - entries = [self._domain_data(domain) for domain in domains] - # Tag the domain entries, but use the dictionaries. - [etag(data) for data in entries] - resource = dict( - start=0, - total_size=len(domains), - entries=entries, - ) + 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 index fac4ae4f1..87614d1f0 100644 --- a/src/mailman/rest/helpers.py +++ b/src/mailman/rest/helpers.py @@ -21,6 +21,7 @@ from __future__ import absolute_import, unicode_literals __metaclass__ = type __all__ = [ + 'ContainerMixin', 'etag', 'path_to', ] @@ -71,3 +72,52 @@ def etag(resource): 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, + ) diff --git a/src/mailman/rest/lists.py b/src/mailman/rest/lists.py index 682353373..b232cc5bc 100644 --- a/src/mailman/rest/lists.py +++ b/src/mailman/rest/lists.py @@ -33,7 +33,7 @@ from mailman.interfaces.domain import BadDomainSpecificationError from mailman.interfaces.listmanager import ( IListManager, ListAlreadyExistsError) from mailman.interfaces.member import MemberRole -from mailman.rest.helpers import etag, path_to +from mailman.rest.helpers import CollectionMixin, etag, path_to from mailman.rest.members import AMember @@ -62,11 +62,11 @@ member_matcher.score = () -class _ListBase(resource.Resource): +class _ListBase(resource.Resource, CollectionMixin): """Shared base class for mailing list representations.""" - def _list_data(self, mlist): - """Return the list data for a single mailing list.""" + def _resource_as_dict(self, mlist): + """See `CollectionMixin`.""" return dict( fqdn_listname=mlist.fqdn_listname, host_name=mlist.host_name, @@ -75,9 +75,9 @@ class _ListBase(resource.Resource): self_link=path_to('lists/{0}'.format(mlist.fqdn_listname)), ) - def _format_list(self, mlist): - """Format the mailing list for a single mailing list.""" - return etag(self._list_data(mlist)) + def _get_collection(self, request): + """See `CollectionMixin`.""" + return list(getUtility(IListManager)) class AList(_ListBase): @@ -91,7 +91,7 @@ class AList(_ListBase): """Return a single mailing list end-point.""" if self._mlist is None: return http.not_found() - return http.ok([], self._format_list(self._mlist)) + return http.ok([], self._resource_as_json(self._mlist)) @resource.child(member_matcher) def member(self, request, segments, role, address): @@ -123,18 +123,7 @@ class AllLists(_ListBase): return http.created(location, [], None) @resource.GET() - def container(self, request): - """Return the /lists end-point.""" - mlists = list(getUtility(IListManager)) - if len(mlists) == 0: - resource = dict(start=None, total_size=0) - return http.ok([], etag(resource)) - entries = [self._list_data(mlist) for mlist in mlists] - # Tag the list entries, but use the dictionaries. - [etag(data) for data in entries] - resource = dict( - start=0, - total_size=len(mlists), - entries=entries, - ) + 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 index b18097935..ace1ff3cb 100644 --- a/src/mailman/rest/members.py +++ b/src/mailman/rest/members.py @@ -34,24 +34,24 @@ from mailman.interfaces.address import InvalidEmailAddressError from mailman.interfaces.listmanager import NoSuchListError from mailman.interfaces.member import AlreadySubscribedError, MemberRole from mailman.interfaces.membership import ISubscriptionService -from mailman.rest.helpers import etag, path_to +from mailman.rest.helpers import CollectionMixin, etag, path_to -class _MemberBase(resource.Resource): +class _MemberBase(resource.Resource, CollectionMixin): """Shared base class for member representations.""" - def _member_data(self, member): - """Return the member data for a single member.""" + 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 _format_member(self, member): - """Format the data for a single member.""" - return etag(self._member_data(member)) + def _get_collection(self, request): + """See `CollectionMixin`.""" + return list(getUtility(ISubscriptionService)) class AMember(_MemberBase): @@ -77,7 +77,7 @@ class AMember(_MemberBase): @resource.GET() def member(self, request): """Return a single member end-point.""" - return http.ok([], self._format_member(self._member)) + return http.ok([], self._resource_as_json(self._member)) @resource.DELETE() def delete(self, request): @@ -122,17 +122,6 @@ class AllMembers(_MemberBase): @resource.GET() def container(self, request): - """Return the /members end-point.""" - members = list(getUtility(ISubscriptionService)) - if len(members) == 0: - resource = dict(start=None, total_size=0) - return http.ok([], etag(resource)) - entries = [self._member_data(member) for member in members] - # Tag the domain entries, but use the dictionaries. - [etag(data) for data in entries] - resource = dict( - start=0, - total_size=len(members), - entries=entries, - ) + """/members""" + resource = self._make_collection(request) return http.ok([], etag(resource)) -- cgit v1.3.1 From b235e189e6158d3daaaebd56ed0b18baf774f8d7 Mon Sep 17 00:00:00 2001 From: Barry Warsaw Date: Mon, 1 Mar 2010 23:12:36 -0500 Subject: Add POST validators. --- src/mailman/app/lifecycle.py | 16 ++++++- src/mailman/interfaces/listmanager.py | 19 -------- src/mailman/model/listmanager.py | 5 --- src/mailman/rest/docs/helpers.txt | 84 +++++++++++++++++++++++++++++++++++ src/mailman/rest/domains.py | 19 ++++---- src/mailman/rest/helpers.py | 41 +++++++++++++++++ src/mailman/rest/lists.py | 14 +++--- src/mailman/rest/members.py | 17 +++---- 8 files changed, 166 insertions(+), 49 deletions(-) (limited to 'src') 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/interfaces/listmanager.py b/src/mailman/interfaces/listmanager.py index f83791079..f24230852 100644 --- a/src/mailman/interfaces/listmanager.py +++ b/src/mailman/interfaces/listmanager.py @@ -73,25 +73,6 @@ class IListManager(Interface): :raise `ListAlreadyExistsError` if the named list already exists. """ - - def new(fqdn_listname): - """Add a new mailing list. - - The mailing may not exist yet, but the domain specified in - `fqdn_listname` must exist. This is a higher level interface than - create() and should generally be used instead of that method. - - :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. - """ - def get(fqdn_listname): """Return the mailing list with the given name, if it exists. diff --git a/src/mailman/model/listmanager.py b/src/mailman/model/listmanager.py index 3d0448e8e..e123c47f4 100644 --- a/src/mailman/model/listmanager.py +++ b/src/mailman/model/listmanager.py @@ -58,11 +58,6 @@ class ListManager: config.db.store.add(mlist) return mlist - def new(self, fqdn_listname): - """See `IListManager.""" - from mailman.app.lifecycle import create_list - return create_list(fqdn_listname) - def get(self, fqdn_listname): """See `IListManager`.""" listname, at, hostname = fqdn_listname.partition('@') diff --git a/src/mailman/rest/docs/helpers.txt b/src/mailman/rest/docs/helpers.txt index 9304bbb17..7b9aa9863 100644 --- a/src/mailman/rest/docs/helpers.txt +++ b/src/mailman/rest/docs/helpers.txt @@ -57,3 +57,87 @@ dictionary after tagging, since that's almost always what you want. 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/domains.py b/src/mailman/rest/domains.py index 8b7cd2699..8bc68a6c1 100644 --- a/src/mailman/rest/domains.py +++ b/src/mailman/rest/domains.py @@ -31,7 +31,7 @@ from zope.component import getUtility from mailman.interfaces.domain import ( BadDomainSpecificationError, IDomainManager) -from mailman.rest.helpers import CollectionMixin, etag, path_to +from mailman.rest.helpers import CollectionMixin, Validator, etag, path_to @@ -75,16 +75,19 @@ class AllDomains(_DomainBase): @resource.POST() def create(self, request): """Create a new domain.""" - # XXX 2010-02-23 barry Sanity check the POST arguments by - # introspection of the target method, or via descriptors. domain_manager = getUtility(IDomainManager) 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()) - domain = domain_manager.add(**kws) + 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([], 'Domain exists') + 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) diff --git a/src/mailman/rest/helpers.py b/src/mailman/rest/helpers.py index 87614d1f0..369eebffa 100644 --- a/src/mailman/rest/helpers.py +++ b/src/mailman/rest/helpers.py @@ -33,6 +33,8 @@ import hashlib from lazr.config import as_boolean from mailman.config import config +COMMASPACE = ', ' + def path_to(resource): @@ -121,3 +123,42 @@ class CollectionMixin: 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/lists.py b/src/mailman/rest/lists.py index b232cc5bc..947869cd9 100644 --- a/src/mailman/rest/lists.py +++ b/src/mailman/rest/lists.py @@ -29,11 +29,12 @@ __all__ = [ 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, etag, path_to +from mailman.rest.helpers import CollectionMixin, Validator, etag, path_to from mailman.rest.members import AMember @@ -104,19 +105,16 @@ class AllLists(_ListBase): @resource.POST() def create(self, request): """Create a new mailing list.""" - # XXX 2010-02-23 barry Sanity check the POST arguments by - # introspection of the target method, or via descriptors. - list_manager = getUtility(IListManager) 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()) - mlist = list_manager.new(**kws) + 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. diff --git a/src/mailman/rest/members.py b/src/mailman/rest/members.py index ace1ff3cb..a5a3dd02e 100644 --- a/src/mailman/rest/members.py +++ b/src/mailman/rest/members.py @@ -32,9 +32,10 @@ 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, MemberRole +from mailman.interfaces.member import ( + AlreadySubscribedError, DeliveryMode, MemberRole) from mailman.interfaces.membership import ISubscriptionService -from mailman.rest.helpers import CollectionMixin, etag, path_to +from mailman.rest.helpers import CollectionMixin, Validator, etag, path_to @@ -98,14 +99,14 @@ class AllMembers(_MemberBase): @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) + 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: -- cgit v1.3.1 From 1bee98cb58461121af5c4482beb247c6cd503a20 Mon Sep 17 00:00:00 2001 From: Barry Warsaw Date: Wed, 3 Mar 2010 22:20:40 -0500 Subject: * Fix two type specifications in argparse. * Update printing of version string to avoid argparse DeprecationWarnings. --- src/mailman/bin/mailman.py | 7 +++++-- src/mailman/commands/cli_lists.py | 4 ++-- 2 files changed, 7 insertions(+), 4 deletions(-) (limited to 'src') 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/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 -- cgit v1.3.1