diff options
| -rw-r--r-- | src/mailman/rest/adapters.py | 2 | ||||
| -rw-r--r-- | src/mailman/rest/configuration.py | 260 | ||||
| -rw-r--r-- | src/mailman/rest/docs/basic.txt | 2 | ||||
| -rw-r--r-- | src/mailman/rest/docs/configuration.txt | 53 | ||||
| -rw-r--r-- | src/mailman/rest/docs/domains.txt | 2 | ||||
| -rw-r--r-- | src/mailman/rest/docs/helpers.txt | 54 | ||||
| -rw-r--r-- | src/mailman/rest/docs/lists.txt | 8 | ||||
| -rw-r--r-- | src/mailman/rest/docs/membership.txt | 12 | ||||
| -rw-r--r-- | src/mailman/rest/helpers.py | 36 | ||||
| -rw-r--r-- | src/mailman/rest/lists.py | 229 | ||||
| -rw-r--r-- | src/mailman/rest/members.py | 5 | ||||
| -rw-r--r-- | src/mailman/tests/test_documentation.py | 4 |
12 files changed, 401 insertions, 266 deletions
diff --git a/src/mailman/rest/adapters.py b/src/mailman/rest/adapters.py index 1edac3a34..a98798fcb 100644 --- a/src/mailman/rest/adapters.py +++ b/src/mailman/rest/adapters.py @@ -76,7 +76,7 @@ class SubscriptionService: # Convert from string to enum. mode = (DeliveryMode.regular if delivery_mode is None - else DeliveryMode(delivery_mode)) + else delivery_mode) if real_name is None: real_name, at, domain = address.partition('@') if len(at) == 0: diff --git a/src/mailman/rest/configuration.py b/src/mailman/rest/configuration.py new file mode 100644 index 000000000..d2653fea4 --- /dev/null +++ b/src/mailman/rest/configuration.py @@ -0,0 +1,260 @@ +# Copyright (C) 2010 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""Mailing list configuration via REST API.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'ListConfiguration', + ] + + +from lazr.config import as_boolean, as_timedelta +from restish import http, resource + +from mailman.config import config +from mailman.interfaces.autorespond import ResponseAction +from mailman.interfaces.mailinglist import IAcceptableAliasSet +from mailman.rest.helpers import Validator, enum_validator, etag + + + +class GetterSetter: + """Get and set attributes on mailing lists. + + Most attributes are fairly simple - a getattr() or setattr() on the + mailing list does the trick, with the appropriate encoding or decoding on + the way in and out. Encoding doesn't happen here though; the standard + JSON library handles most types, but see ExtendedEncoder in + mailman.rest.helpers for additional support. + + Others are more complicated since they aren't kept in the model as direct + columns in the database. These will use subclasses of this base class. + Read-only attributes will have a decoder which always raises ValueError. + """ + + def __init__(self, decoder=None): + """Create a getter/setter for a specific list attribute. + + :param decoder: The callable for decoding a web request value string + into the specific data type needed by the `IMailingList` + attribute. Use None to indicate a read-only attribute. The + callable should raise ValueError when the web request value cannot + be converted. + :type decoder: callable + """ + self.decoder = decoder + + def get(self, mlist, attribute): + """Return the named mailing list attribute value. + + :param mlist: The mailing list. + :type mlist: `IMailingList` + :param attribute: The attribute name. + :type attribute: string + :return: The attribute value, ready for JSON encoding. + :rtype: object + """ + return getattr(mlist, attribute) + + def put(self, mlist, attribute, value): + """Set the named mailing list attribute value. + + :param mlist: The mailing list. + :type mlist: `IMailingList` + :param attribute: The attribute name. + :type attribute: string + :param value: The new value for the attribute. + :type request_value: object + """ + setattr(mlist, attribute, value) + + def __call__(self, value): + """Convert the value to its internal format. + + :param value: The web request value to convert. + :type value: string + :return: The converted value. + :rtype: object + """ + if self.decoder is None: + return value + return self.decoder(value) + + +class AcceptableAliases(GetterSetter): + """Resource for the acceptable aliases of a mailing list.""" + + def get(self, mlist, attribute): + """Return the mailing list's acceptable aliases.""" + assert attribute == 'acceptable_aliases', ( + 'Unexpected attribute: {0}'.format(attribute)) + aliases = IAcceptableAliasSet(mlist) + return sorted(aliases.aliases) + + def put(self, mlist, attribute, value): + """Change the acceptable aliases. + + Because this is a PUT operation, all previous aliases are cleared + first. Thus, this is an overwrite. The keys in the request are + ignored. + """ + assert attribute == 'acceptable_aliases', ( + 'Unexpected attribute: {0}'.format(attribute)) + alias_set = IAcceptableAliasSet(mlist) + alias_set.clear() + for alias in value: + alias_set.add(unicode(alias)) + + + +# Additional validators for converting from web request strings to internal +# data types. See below for details. + +def pipeline_validator(pipeline_name): + """Convert the pipeline name to a string, but only if it's known.""" + if pipeline_name in config.pipelines: + return unicode(pipeline_name) + raise ValueError('Unknown pipeline: {0}'.format(pipeline_name)) + + +def list_of_unicode(values): + """Turn a list of things into a list of unicodes.""" + return [unicode(value) for value in values] + + + +# This is the list of IMailingList attributes that are exposed through the +# REST API. The values of the keys are the GetterSetter instance holding the +# decoder used to convert the web request string to an internally valid value. +# The instance also contains the get() and put() methods used to retrieve and +# set the attribute values. Its .decoder attribute will be None for read-only +# attributes. +# +# The decoder must either return the internal value or raise a ValueError if +# the conversion failed (e.g. trying to turn 'Nope' into a boolean). +# +# Many internal value types can be automatically JSON encoded, but see +# mailman.rest.helpers.ExtendedEncoder for specializations of certain types +# (e.g. datetimes, timedeltas, enums). + +ATTRIBUTES = dict( + acceptable_aliases=AcceptableAliases(list_of_unicode), + admin_immed_notify=GetterSetter(as_boolean), + admin_notify_mchanges=GetterSetter(as_boolean), + administrivia=GetterSetter(as_boolean), + advertised=GetterSetter(as_boolean), + anonymous_list=GetterSetter(as_boolean), + autorespond_owner=GetterSetter(enum_validator(ResponseAction)), + autorespond_postings=GetterSetter(enum_validator(ResponseAction)), + autorespond_requests=GetterSetter(enum_validator(ResponseAction)), + autoresponse_grace_period=GetterSetter(as_timedelta), + autoresponse_owner_text=GetterSetter(unicode), + autoresponse_postings_text=GetterSetter(unicode), + autoresponse_request_text=GetterSetter(unicode), + bounces_address=GetterSetter(None), + collapse_alternatives=GetterSetter(as_boolean), + convert_html_to_plaintext=GetterSetter(as_boolean), + created_at=GetterSetter(None), + description=GetterSetter(unicode), + digest_last_sent_at=GetterSetter(None), + digest_size_threshold=GetterSetter(float), + filter_content=GetterSetter(as_boolean), + fqdn_listname=GetterSetter(None), + host_name=GetterSetter(None), + include_list_post_header=GetterSetter(as_boolean), + include_rfc2369_headers=GetterSetter(as_boolean), + join_address=GetterSetter(None), + last_post_at=GetterSetter(None), + leave_address=GetterSetter(None), + list_id=GetterSetter(None), + list_name=GetterSetter(None), + next_digest_number=GetterSetter(None), + no_reply_address=GetterSetter(None), + owner_address=GetterSetter(None), + pipeline=GetterSetter(pipeline_validator), + post_id=GetterSetter(None), + posting_address=GetterSetter(None), + real_name=GetterSetter(unicode), + request_address=GetterSetter(None), + scheme=GetterSetter(None), + volume=GetterSetter(None), + web_host=GetterSetter(None), + ) + + +VALIDATORS = ATTRIBUTES.copy() +for attribute, gettersetter in VALIDATORS.items(): + if gettersetter.decoder is None: + del VALIDATORS[attribute] + + + +class ListConfiguration(resource.Resource): + """A mailing list configuration resource.""" + + def __init__(self, mailing_list, attribute): + self._mlist = mailing_list + self._attribute = attribute + + @resource.GET() + def get_configuration(self, request): + """Get a mailing list configuration.""" + resource = {} + if self._attribute is None: + # Return all readable attributes. + for attribute in ATTRIBUTES: + value = ATTRIBUTES[attribute].get(self._mlist, attribute) + resource[attribute] = value + elif self._attribute not in ATTRIBUTES: + return http.bad_request( + [], b'Unknown attribute: {0}'.format(self._attribute)) + else: + attribute = self._attribute + value = ATTRIBUTES[attribute].get(self._mlist, attribute) + resource[attribute] = value + return http.ok([], etag(resource)) + + @resource.PUT() + def put_configuration(self, request): + """Set a mailing list configuration.""" + attribute = self._attribute + if attribute is None: + # Set all writable attributes. + try: + converted = Validator(**VALIDATORS)(request) + except ValueError as error: + return http.bad_request([], str(error)) + for key, value in converted.items(): + ATTRIBUTES[key].put(self._mlist, key, value) + elif attribute not in ATTRIBUTES: + return http.bad_request( + [], b'Unknown attribute: {0}'.format(attribute)) + elif ATTRIBUTES[attribute].decoder is None: + return http.bad_request( + [], b'Read-only attribute: {0}'.format(attribute)) + else: + validator = Validator(**{attribute: VALIDATORS[attribute]}) + try: + values = validator(request) + except ValueError as error: + return http.bad_request([], str(error)) + ATTRIBUTES[attribute].put( + self._mlist, attribute, values[attribute]) + return http.ok([], '') diff --git a/src/mailman/rest/docs/basic.txt b/src/mailman/rest/docs/basic.txt index 643d6d906..94d80e5a2 100644 --- a/src/mailman/rest/docs/basic.txt +++ b/src/mailman/rest/docs/basic.txt @@ -28,4 +28,4 @@ appropriate HTTP 404 Not Found error. >>> dump_json('http://localhost:8001/3.0/does-not-exist') Traceback (most recent call last): ... - HTTPError: HTTP Error 404: Not Found + HTTPError: HTTP Error 404: 404 Not Found diff --git a/src/mailman/rest/docs/configuration.txt b/src/mailman/rest/docs/configuration.txt index 9cb30f045..1ad374d32 100644 --- a/src/mailman/rest/docs/configuration.txt +++ b/src/mailman/rest/docs/configuration.txt @@ -15,6 +15,7 @@ All readable attributes for a list are available on a sub-resource. >>> dump_json('http://localhost:8001/3.0/lists/' ... 'test-one@example.com/config') + acceptable_aliases: [] admin_immed_notify: True admin_notify_mchanges: False administrivia: True @@ -68,6 +69,7 @@ writable attributes in one request. >>> dump_json('http://localhost:8001/3.0/lists/' ... 'test-one@example.com/config', ... dict( + ... acceptable_aliases=['one@example.com', 'two@example.com'], ... admin_immed_notify=False, ... admin_notify_mchanges=True, ... administrivia=False, @@ -100,6 +102,7 @@ These values are changed permanently. >>> dump_json('http://localhost:8001/3.0/lists/' ... 'test-one@example.com/config') + acceptable_aliases: [u'one@example.com', u'two@example.com'] admin_immed_notify: False admin_notify_mchanges: True administrivia: False @@ -130,12 +133,13 @@ These values are changed permanently. ... If you use PUT to change a list's configuration, all writable attributes must -be included. It is an error to leave one out (e.g. `pipeline`)... +be included. It is an error to leave one or more out... >>> dump_json('http://localhost:8001/3.0/lists/' ... 'test-one@example.com/config', ... dict( - ... #admin_immed_notify=False, + ... #acceptable_aliases=['one', 'two'], + ... admin_immed_notify=False, ... admin_notify_mchanges=True, ... administrivia=False, ... advertised=False, @@ -160,7 +164,7 @@ be included. It is an error to leave one out (e.g. `pipeline`)... ... 'PUT') Traceback (most recent call last): ... - HTTPError: HTTP Error 400: Bad Request + HTTPError: HTTP Error 400: Missing parameters: acceptable_aliases ...or to add an unknown one. @@ -168,6 +172,7 @@ be included. It is an error to leave one out (e.g. `pipeline`)... ... 'test-one@example.com/config', ... dict( ... a_mailing_list_attribute=False, + ... acceptable_aliases=['one', 'two'], ... admin_immed_notify=False, ... admin_notify_mchanges=True, ... administrivia=False, @@ -193,7 +198,7 @@ be included. It is an error to leave one out (e.g. `pipeline`)... ... 'PUT') Traceback (most recent call last): ... - HTTPError: HTTP Error 400: Bad Request + HTTPError: HTTP Error 400: Unexpected parameters: a_mailing_list_attribute It is also an error to spell an attribute value incorrectly... @@ -201,6 +206,7 @@ It is also an error to spell an attribute value incorrectly... ... 'test-one@example.com/config', ... dict( ... admin_immed_notify='Nope', + ... acceptable_aliases=['one', 'two'], ... admin_notify_mchanges=True, ... administrivia=False, ... advertised=False, @@ -225,13 +231,14 @@ It is also an error to spell an attribute value incorrectly... ... 'PUT') Traceback (most recent call last): ... - HTTPError: HTTP Error 400: Bad Request + HTTPError: HTTP Error 400: Cannot convert parameters: admin_immed_notify ...or to name a pipeline that doesn't exist... >>> dump_json('http://localhost:8001/3.0/lists/' ... 'test-one@example.com/config', ... dict( + ... acceptable_aliases=['one', 'two'], ... admin_immed_notify=False, ... admin_notify_mchanges=True, ... advertised=False, @@ -256,13 +263,14 @@ It is also an error to spell an attribute value incorrectly... ... 'PUT') Traceback (most recent call last): ... - HTTPError: HTTP Error 400: Bad Request + HTTPError: HTTP Error 400: Cannot convert parameters: pipeline ...or to name an invalid auto-response enumeration value. >>> dump_json('http://localhost:8001/3.0/lists/' ... 'test-one@example.com/config', ... dict( + ... acceptable_aliases=['one', 'two'], ... admin_immed_notify=False, ... admin_notify_mchanges=True, ... advertised=False, @@ -279,7 +287,7 @@ It is also an error to spell an attribute value incorrectly... ... include_rfc2369_headers=False, ... include_list_post_header=False, ... digest_size_threshold=10.5, - ... pipeline='dummy', + ... pipeline='virgin', ... filter_content=True, ... convert_html_to_plaintext=True, ... collapse_alternatives=False, @@ -287,7 +295,7 @@ It is also an error to spell an attribute value incorrectly... ... 'PUT') Traceback (most recent call last): ... - HTTPError: HTTP Error 400: Bad Request + HTTPError: HTTP Error 400: Cannot convert parameters: autorespond_owner Changing a partial configuration @@ -295,18 +303,14 @@ Changing a partial configuration Using PATCH, you can change just one attribute. - >>> dump_json('http://localhost:8001/3.0/lists/' - ... 'test-one@example.com/config', - ... dict(real_name='My List'), - ... 'PATCH') - content-length: 0 - date: ... - server: WSGIServer/... - status: 200 +XXX WebOb does not currently support PATCH, so neither does restish. -These values are changed permanently. +# >>> dump_json('http://localhost:8001/3.0/lists/' +# ... 'test-one@example.com/config', +# ... dict(real_name='My List'), +# ... 'PATCH') -XXX WebOb does not currently support PATCH, so neither does restish. +These values are changed permanently. Sub-resources @@ -325,18 +329,21 @@ These are recipient aliases that can be used in the To and CC headers instead of the posting address. They are often used in forwarded emails. By default, a mailing list has no acceptable aliases. + >>> from mailman.interfaces.mailinglist import IAcceptableAliasSet + >>> IAcceptableAliasSet(mlist).clear() + >>> transaction.commit() >>> dump_json('http://localhost:8001/3.0/lists/' ... 'test-one@example.com/config/acceptable_aliases') - aliases: [] - http_etag: "c883ba7e4f62819da3c087f086feb5fe524c10b4" + acceptable_aliases: [] + http_etag: "..." We can add a few by PUTting them on the sub-resource. The keys in the dictionary are ignored. >>> dump_json('http://localhost:8001/3.0/lists/' ... 'test-one@example.com/config/acceptable_aliases', - ... dict(one='foo@example.com', - ... two='bar@example.net'), + ... dict(acceptable_aliases=['foo@example.com', + ... 'bar@example.net']), ... 'PUT') content-length: 0 date: ... @@ -348,7 +355,7 @@ Aliases are returned as a list on the 'aliases' key. >>> response = call_http( ... 'http://localhost:8001/3.0/lists/' ... 'test-one@example.com/config/acceptable_aliases') - >>> for alias in response['aliases']: + >>> for alias in response['acceptable_aliases']: ... print alias bar@example.net foo@example.com diff --git a/src/mailman/rest/docs/domains.txt b/src/mailman/rest/docs/domains.txt index b8e0170b0..a3a6f1227 100644 --- a/src/mailman/rest/docs/domains.txt +++ b/src/mailman/rest/docs/domains.txt @@ -110,7 +110,7 @@ But we get a 404 for a non-existent domain. >>> dump_json('http://localhost:8001/3.0/domains/does-not-exist') Traceback (most recent call last): ... - HTTPError: HTTP Error 404: Not Found + HTTPError: HTTP Error 404: 404 Not Found Creating new domains diff --git a/src/mailman/rest/docs/helpers.txt b/src/mailman/rest/docs/helpers.txt index 7b9aa9863..23cd2f162 100644 --- a/src/mailman/rest/docs/helpers.txt +++ b/src/mailman/rest/docs/helpers.txt @@ -141,3 +141,57 @@ But if the optional values are present, they must of course also be valid. Traceback (most recent call last): ... ValueError: Cannot convert parameters: five, four + + +Arrays +------ + +Some POST forms include more than one value for a particular key. This is how +lists and arrays are modeled. The Validator does the right thing with such +form data. Specifically, when a key shows up multiple times in the form data, +a list is given to the validator. + + # Of course we can't use a normal dictionary, but webob has a useful data + # type we can use. + >>> from webob.multidict import MultiDict + >>> form_data = MultiDict(one='1', many='3') + >>> form_data.add('many', '4') + >>> form_data.add('many', '5') + +This is a validation function that ensures the value is a list. + + >>> def must_be_list(value): + ... if not isinstance(value, list): + ... raise ValueError('not a list') + ... return [int(item) for item in value] + +This is a validation function that ensure the value is *not* a list. + + >>> def must_be_scalar(value): + ... if isinstance(value, list): + ... raise ValueError('is a list') + ... return int(value) + +And a validator to pull it all together. + + >>> validator = Validator(one=must_be_scalar, many=must_be_list) + >>> FakeRequest.POST = form_data + >>> values = validator(FakeRequest) + >>> print values['one'] + 1 + >>> print values['many'] + [3, 4, 5] + +The list values are guaranteed to be in the same order they show up in the +form data. + + >>> from webob.multidict import MultiDict + >>> form_data = MultiDict(one='1', many='3') + >>> form_data.add('many', '5') + >>> form_data.add('many', '4') + >>> FakeRequest.POST = form_data + >>> values = validator(FakeRequest) + >>> print values['one'] + 1 + >>> print values['many'] + [3, 5, 4] diff --git a/src/mailman/rest/docs/lists.txt b/src/mailman/rest/docs/lists.txt index 504d16feb..3cb7e1481 100644 --- a/src/mailman/rest/docs/lists.txt +++ b/src/mailman/rest/docs/lists.txt @@ -75,7 +75,7 @@ not exist. ... }) Traceback (most recent call last): ... - HTTPError: HTTP Error 400: Bad Request + HTTPError: HTTP Error 400: Domain does not exist example.org Nor can you create a mailing list that already exists. @@ -84,7 +84,7 @@ Nor can you create a mailing list that already exists. ... }) Traceback (most recent call last): ... - HTTPError: HTTP Error 400: Bad Request + HTTPError: HTTP Error 400: Mailing list exists Deleting lists via the API @@ -116,10 +116,10 @@ deleted. ... method='DELETE') Traceback (most recent call last): ... - HTTPError: HTTP Error 404: Not Found + HTTPError: HTTP Error 404: 404 Not Found >>> dump_json('http://localhost:8001/3.0/lists/test-ten@example.com', ... method='DELETE') Traceback (most recent call last): ... - HTTPError: HTTP Error 404: Not Found + HTTPError: HTTP Error 404: 404 Not Found diff --git a/src/mailman/rest/docs/membership.txt b/src/mailman/rest/docs/membership.txt index 59af8f2f7..0b5eb063a 100644 --- a/src/mailman/rest/docs/membership.txt +++ b/src/mailman/rest/docs/membership.txt @@ -257,7 +257,7 @@ For some reason Elly tries to join a mailing list that does not exist. ... }) Traceback (most recent call last): ... - HTTPError: HTTP Error 400: Bad Request + HTTPError: HTTP Error 400: No such list Then, she tries to leave a mailing list that does not exist. @@ -266,7 +266,7 @@ Then, she tries to leave a mailing list that does not exist. ... method='DELETE') Traceback (most recent call last): ... - HTTPError: HTTP Error 404: Not Found + HTTPError: HTTP Error 404: 404 Not Found She then tries to leave a mailing list with a bogus address. @@ -275,7 +275,7 @@ She then tries to leave a mailing list with a bogus address. ... method='DELETE') Traceback (most recent call last): ... - HTTPError: HTTP Error 404: Not Found + HTTPError: HTTP Error 404: 404 Not Found For some reason, Elly tries to leave the mailing list again, but she's already been unsubscribed. @@ -285,7 +285,7 @@ been unsubscribed. ... method='DELETE') Traceback (most recent call last): ... - HTTPError: HTTP Error 404: Not Found + HTTPError: HTTP Error 404: 404 Not Found Anna tries to join a mailing list she's already a member of. @@ -295,7 +295,7 @@ Anna tries to join a mailing list she's already a member of. ... }) Traceback (most recent call last): ... - HTTPError: HTTP Error 409: Conflict + HTTPError: HTTP Error 409: Member already subscribed Gwen tries to join the alpha mailing list using an invalid delivery mode. @@ -307,7 +307,7 @@ Gwen tries to join the alpha mailing list using an invalid delivery mode. ... }) Traceback (most recent call last): ... - HTTPError: HTTP Error 400: Bad Request + HTTPError: HTTP Error 400: Cannot convert parameters: delivery_mode Even using an address with "funny" characters Hugh can join the mailing list. diff --git a/src/mailman/rest/helpers.py b/src/mailman/rest/helpers.py index c6a020f61..8c4f0bae9 100644 --- a/src/mailman/rest/helpers.py +++ b/src/mailman/rest/helpers.py @@ -22,6 +22,7 @@ from __future__ import absolute_import, unicode_literals __metaclass__ = type __all__ = [ 'ContainerMixin', + 'enum_validator', 'etag', 'no_content', 'path_to', @@ -36,7 +37,6 @@ from datetime import datetime, timedelta from flufl.enum import Enum from lazr.config import as_boolean from restish.http import Response -from restish.resource import MethodDecorator from mailman.config import config @@ -154,6 +154,18 @@ class CollectionMixin: +class enum_validator: + """Convert an enum value name into an enum value.""" + + def __init__(self, enum_class): + self._enum_class = enum_class + + def __call__(self, enum_value): + # This will raise a ValueError if the enum value is unknown. Let that + # percolate up. + return self._enum_class[enum_value] + + class Validator: """A validator of parameter input.""" @@ -168,7 +180,21 @@ class Validator: values = {} extras = set() cannot_convert = set() - for key, value in request.POST.items(): + form_data = {} + # All keys which show up only once in the form data get a scalar value + # in the pre-converted dictionary. All keys which show up more than + # once get a list value. + missing = object() + for key, new_value in request.POST.items(): + old_value = form_data.get(key, missing) + if old_value is missing: + form_data[key] = new_value + elif isinstance(old_value, list): + old_value.append(new_value) + else: + form_data[key] = [old_value, new_value] + # Now do all the conversions. + for key, value in form_data.items(): try: values[key] = self._converters[key](value) except KeyError: @@ -206,9 +232,3 @@ def restish_matcher(function): def no_content(): """204 No Content.""" return Response('204 No Content', [], None) - - -# restish doesn't support HTTP PATCH (it's not standard). -class PATCH(MethodDecorator): - """ http PATCH method """ - method = 'PATCH' diff --git a/src/mailman/rest/lists.py b/src/mailman/rest/lists.py index 31c5dcfb0..af5b3ced5 100644 --- a/src/mailman/rest/lists.py +++ b/src/mailman/rest/lists.py @@ -27,25 +27,17 @@ __all__ = [ ] -import os -import sys - -from lazr.config import as_boolean, as_timedelta -from pkg_resources import resource_listdir from restish import http, resource from zope.component import getUtility from mailman.app.lifecycle import create_list, remove_list -from mailman.config import config -from mailman.interfaces.autorespond import ResponseAction from mailman.interfaces.domain import BadDomainSpecificationError from mailman.interfaces.listmanager import ( IListManager, ListAlreadyExistsError) -from mailman.interfaces.mailinglist import IAcceptableAliasSet from mailman.interfaces.member import MemberRole +from mailman.rest.configuration import ListConfiguration from mailman.rest.helpers import ( - CollectionMixin, PATCH, Validator, etag, no_content, path_to, - restish_matcher) + CollectionMixin, Validator, etag, no_content, path_to, restish_matcher) from mailman.rest.members import AMember, MembersOfList @@ -95,26 +87,18 @@ def config_matcher(request, segments): """A matcher for a mailing list's configuration resource. e.g. /config + e.g. /config/description """ - if len(segments) == 1 and segments[0] == 'config': + if len(segments) < 1 or segments[0] != 'config': + return None + if len(segments) == 1: return (), {}, () - # It's something else. + if len(segments) == 2: + return (), dict(attribute=segments[1]), () + # More segments are not allowed. return None -@restish_matcher -def subresource_config_matcher(request, segments): - """A matcher for configuration sub-resources. - - e.g. /config/acceptable_aliases - """ - if len(segments) != 2 or segments[0] != 'config': - return None - # Don't check here whether it's a known subresource or not. Let that be - # done in subresource_config() method below. - return (), dict(attribute=segments[1]), () - - class _ListBase(resource.Resource, CollectionMixin): """Shared base class for mailing list representations.""" @@ -169,22 +153,9 @@ class AList(_ListBase): return MembersOfList(self._mlist, role) @resource.child(config_matcher) - def config(self, request, segments): + def config(self, request, segments, attribute=None): """Return a mailing list configuration object.""" - return ListConfiguration(self._mlist) - - @resource.child(subresource_config_matcher) - def subresource_config(self, request, segments, attribute): - """Return the subresource configuration object. - - This will return a Bad Request if it isn't a known subresource. - """ - missing = object() - subresource_class = SUBRESOURCES.get(attribute, missing) - if subresource_class is missing: - return http.bad_request( - [], 'Unknown attribute {0}'.format(attribute)) - return subresource_class(self._mlist, attribute) + return ListConfiguration(self._mlist, attribute) @@ -214,181 +185,3 @@ class AllLists(_ListBase): """/lists""" resource = self._make_collection(request) return http.ok([], etag(resource)) - - - -# The set of readable IMailingList attributes. -READABLE = ( - # Identity. - 'created_at', - 'list_name', - 'host_name', - 'fqdn_listname', - 'real_name', - 'description', - 'list_id', - 'include_list_post_header', - 'include_rfc2369_headers', - 'advertised', - 'anonymous_list', - # Contact addresses. - 'posting_address', - 'no_reply_address', - 'owner_address', - 'request_address', - 'bounces_address', - 'join_address', - 'leave_address', - # Posting history. - 'last_post_at', - 'post_id', - # Digests. - 'digest_last_sent_at', - 'volume', - 'next_digest_number', - 'digest_size_threshold', - # Web access. - 'scheme', - 'web_host', - # Notifications. - 'admin_immed_notify', - 'admin_notify_mchanges', - # Automatic responses. - 'autoresponse_grace_period', - 'autorespond_owner', - 'autoresponse_owner_text', - 'autorespond_postings', - 'autoresponse_postings_text', - 'autorespond_requests', - 'autoresponse_request_text', - # Processing. - 'pipeline', - 'administrivia', - 'filter_content', - 'convert_html_to_plaintext', - 'collapse_alternatives', - ) - - -def pipeline_validator(pipeline_name): - """Convert the pipeline name to a string, but only if it's known.""" - if pipeline_name in config.pipelines: - return unicode(pipeline_name) - raise ValueError('Unknown pipeline: {0}'.format(pipeline_name)) - - -class enum_validator: - """Convert an enum value name into an enum value.""" - - def __init__(self, enum_class): - self._enum_class = enum_class - - def __call__(self, enum_value): - # This will raise a ValueError if the enum value is unknown. Let that - # percolate up. - return self._enum_class[enum_value] - - -VALIDATORS = { - # Identity. - 'real_name': unicode, - 'description': unicode, - 'include_list_post_header': as_boolean, - 'include_rfc2369_headers': as_boolean, - 'advertised': as_boolean, - 'anonymous_list': as_boolean, - # Digests. - 'digest_size_threshold': float, - # Notifications. - 'admin_immed_notify': as_boolean, - 'admin_notify_mchanges': as_boolean, - # Automatic responses. - 'autoresponse_grace_period': as_timedelta, - 'autorespond_owner': enum_validator(ResponseAction), - 'autoresponse_owner_text': unicode, - 'autorespond_postings': enum_validator(ResponseAction), - 'autoresponse_postings_text': unicode, - 'autorespond_requests': enum_validator(ResponseAction), - 'autoresponse_request_text': unicode, - # Processing. - 'pipeline': pipeline_validator, - 'administrivia': as_boolean, - 'filter_content': as_boolean, - 'convert_html_to_plaintext': as_boolean, - 'collapse_alternatives': as_boolean, - } - - -class ListConfiguration(resource.Resource): - """A mailing list configuration resource.""" - - def __init__(self, mailing_list): - self._mlist = mailing_list - - @resource.GET() - def get_configuration(self, request): - """Return a mailing list's readable configuration.""" - resource = {} - for attribute in READABLE: - resource[attribute] = getattr(self._mlist, attribute) - return http.ok([], etag(resource)) - - @resource.PUT() - def put_configuration(self, request): - """Set all of a mailing list's configuration.""" - # Use PATCH to change just one or a few of the attributes. - validator = Validator(**VALIDATORS) - try: - for key, value in validator(request).items(): - setattr(self._mlist, key, value) - except ValueError as error: - return http.bad_request([], str(error)) - return http.ok([], '') - - @PATCH() - def patch_configuration(self, request): - """Set a subset of the mailing list's configuration.""" - validator = Validator(_optional=VALIDATORS.keys(), **VALIDATORS) - try: - for key, value in validator(request).items(): - setattr(self._mlist, key, value) - except ValueError as error: - return http.bad_request([], str(error)) - return http.ok([], '') - - - -class AcceptableAliases(resource.Resource): - """Resource for the acceptable aliases of a mailing list.""" - - def __init__(self, mailing_list, attribute): - assert attribute == 'acceptable_aliases', ( - 'unexpected attribute: {0}'.format(attribute)) - self._mlist = mailing_list - - @resource.GET() - def aliases(self, request): - """Return the mailing list's acceptable aliases.""" - aliases = IAcceptableAliasSet(self._mlist) - resource = dict(aliases=sorted(aliases.aliases)) - return http.ok([], etag(resource)) - - @resource.PUT() - def put_configuration(self, request): - """Change the acceptable aliases. - - Because this is a PUT operation, all previous aliases are cleared - first. Thus, this is an overwrite. The keys in the request are - ignored. - """ - aliases = IAcceptableAliasSet(self._mlist) - aliases.clear() - for alias in request.POST.values(): - aliases.add(unicode(alias)) - return http.ok([], '') - - - -SUBRESOURCES = dict( - acceptable_aliases=AcceptableAliases, - ) diff --git a/src/mailman/rest/members.py b/src/mailman/rest/members.py index abd0eff67..67dfb6533 100644 --- a/src/mailman/rest/members.py +++ b/src/mailman/rest/members.py @@ -38,7 +38,8 @@ from mailman.interfaces.listmanager import NoSuchListError from mailman.interfaces.member import ( AlreadySubscribedError, DeliveryMode, MemberRole) from mailman.interfaces.membership import ISubscriptionService -from mailman.rest.helpers import CollectionMixin, Validator, etag, path_to +from mailman.rest.helpers import ( + CollectionMixin, Validator, enum_validator, etag, path_to) @@ -97,7 +98,7 @@ class AllMembers(_MemberBase): validator = Validator(fqdn_listname=unicode, address=unicode, real_name=unicode, - delivery_mode=unicode, + delivery_mode=enum_validator(DeliveryMode), _optional=('real_name', 'delivery_mode')) member = service.join(**validator(request)) except AlreadySubscribedError: diff --git a/src/mailman/tests/test_documentation.py b/src/mailman/tests/test_documentation.py index a914127e7..c0f8dca20 100644 --- a/src/mailman/tests/test_documentation.py +++ b/src/mailman/tests/test_documentation.py @@ -123,7 +123,7 @@ def call_http(url, data=None, method=None): """ headers = {} if data is not None: - data = urlencode(data) + data = urlencode(data, doseq=True) headers['Content-Type'] = 'application/x-www-form-urlencoded' if method is None: if data is None: @@ -135,7 +135,7 @@ def call_http(url, data=None, method=None): # 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) + raise HTTPError(url, response.status, content, response, None) if len(content) == 0: for header in sorted(response): print '{0}: {1}'.format(header, response[header]) |
