From 37e72b0a6aaefcb90bf7d9ef23fdf24c07979551 Mon Sep 17 00:00:00 2001 From: Barry Warsaw Date: Mon, 29 Dec 2014 17:44:58 -0500 Subject: Back port from the py3 branch: * You can access the system configuration via the resource path ``/3.0/system/configuration/
``. This returns a dictionary with the keys being the section's variables and the values being their value from ``mailman.cfg`` as verbatim strings. You can get a list of all section names via ``/3.0/system/configuration`` which returns a dictionary containing the ``http_etag`` and the section names as a sorted list under the ``sections`` key. The system configuration resource is read-only. --- src/mailman/config/config.py | 3 + src/mailman/docs/NEWS.rst | 7 + src/mailman/rest/configuration.py | 226 ------------------------- src/mailman/rest/docs/configuration.rst | 237 --------------------------- src/mailman/rest/docs/listconf.rst | 237 +++++++++++++++++++++++++++ src/mailman/rest/docs/systemconf.rst | 34 ++++ src/mailman/rest/listconf.py | 226 +++++++++++++++++++++++++ src/mailman/rest/lists.py | 2 +- src/mailman/rest/root.py | 33 +++- src/mailman/rest/tests/test_configuration.py | 94 ----------- src/mailman/rest/tests/test_listconf.py | 94 +++++++++++ src/mailman/rest/tests/test_root.py | 12 -- src/mailman/rest/tests/test_systemconf.py | 181 ++++++++++++++++++++ src/mailman/runners/docs/rest.rst | 2 +- 14 files changed, 814 insertions(+), 574 deletions(-) delete mode 100644 src/mailman/rest/configuration.py delete mode 100644 src/mailman/rest/docs/configuration.rst create mode 100644 src/mailman/rest/docs/listconf.rst create mode 100644 src/mailman/rest/docs/systemconf.rst create mode 100644 src/mailman/rest/listconf.py delete mode 100644 src/mailman/rest/tests/test_configuration.py create mode 100644 src/mailman/rest/tests/test_listconf.py create mode 100644 src/mailman/rest/tests/test_systemconf.py diff --git a/src/mailman/config/config.py b/src/mailman/config/config.py index 649d6c5e1..7181e23e9 100644 --- a/src/mailman/config/config.py +++ b/src/mailman/config/config.py @@ -97,6 +97,9 @@ class Configuration: """Delegate to the configuration object.""" return getattr(self._config, name) + def __iter__(self): + return iter(self._config) + def load(self, filename=None): """Load the configuration from the schema and config files.""" schema_file = config_file = None diff --git a/src/mailman/docs/NEWS.rst b/src/mailman/docs/NEWS.rst index f18713b02..40dd81d27 100644 --- a/src/mailman/docs/NEWS.rst +++ b/src/mailman/docs/NEWS.rst @@ -75,6 +75,13 @@ REST Given by Aurélien Bompard based on work by Nicolas Karageuzian. * The ``/3.0/system`` path is deprecated; use ``/3.0/system/versions`` to get the system version information. + * You can access the system configuration via the resource path + ``/3.0/system/configuration/
``. This returns a dictionary with + the keys being the section's variables and the values being their value + from ``mailman.cfg`` as verbatim strings. You can get a list of all + section names via ``/3.0/system/configuration`` which returns a dictionary + containing the ``http_etag`` and the section names as a sorted list under + the ``sections`` key. The system configuration resource is read-only. 3.0 beta 4 -- "Time and Motion" diff --git a/src/mailman/rest/configuration.py b/src/mailman/rest/configuration.py deleted file mode 100644 index b432268c7..000000000 --- a/src/mailman/rest/configuration.py +++ /dev/null @@ -1,226 +0,0 @@ -# Copyright (C) 2010-2014 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 . - -"""Mailing list configuration via REST API.""" - -from __future__ import absolute_import, print_function, unicode_literals - -__metaclass__ = type -__all__ = [ - 'ListConfiguration', - ] - - -from lazr.config import as_boolean, as_timedelta -from mailman.config import config -from mailman.core.errors import ( - ReadOnlyPATCHRequestError, UnknownPATCHRequestError) -from mailman.interfaces.action import Action -from mailman.interfaces.archiver import ArchivePolicy -from mailman.interfaces.autorespond import ResponseAction -from mailman.interfaces.mailinglist import IAcceptableAliasSet, ReplyToMunging -from mailman.rest.helpers import ( - GetterSetter, bad_request, etag, no_content, okay) -from mailman.rest.validator import PatchValidator, Validator, enum_validator - - - -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: {}'.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: {}'.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: {}'.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), - archive_policy=GetterSetter(enum_validator(ArchivePolicy)), - bounces_address=GetterSetter(None), - collapse_alternatives=GetterSetter(as_boolean), - convert_html_to_plaintext=GetterSetter(as_boolean), - created_at=GetterSetter(None), - default_member_action=GetterSetter(enum_validator(Action)), - default_nonmember_action=GetterSetter(enum_validator(Action)), - description=GetterSetter(unicode), - digest_last_sent_at=GetterSetter(None), - digest_size_threshold=GetterSetter(float), - filter_content=GetterSetter(as_boolean), - first_strip_reply_to=GetterSetter(as_boolean), - fqdn_listname=GetterSetter(None), - mail_host=GetterSetter(None), - allow_list_posts=GetterSetter(as_boolean), - include_rfc2369_headers=GetterSetter(as_boolean), - join_address=GetterSetter(None), - last_post_at=GetterSetter(None), - leave_address=GetterSetter(None), - list_name=GetterSetter(None), - next_digest_number=GetterSetter(None), - no_reply_address=GetterSetter(None), - owner_address=GetterSetter(None), - post_id=GetterSetter(None), - posting_address=GetterSetter(None), - posting_pipeline=GetterSetter(pipeline_validator), - display_name=GetterSetter(unicode), - reply_goes_to_list=GetterSetter(enum_validator(ReplyToMunging)), - reply_to_address=GetterSetter(unicode), - request_address=GetterSetter(None), - scheme=GetterSetter(None), - send_welcome_message=GetterSetter(as_boolean), - subject_prefix=GetterSetter(unicode), - volume=GetterSetter(None), - web_host=GetterSetter(None), - welcome_message_uri=GetterSetter(unicode), - ) - - -VALIDATORS = ATTRIBUTES.copy() -for attribute, gettersetter in VALIDATORS.items(): - if gettersetter.decoder is None: - del VALIDATORS[attribute] - - - -class ListConfiguration: - """A mailing list configuration resource.""" - - def __init__(self, mailing_list, attribute): - self._mlist = mailing_list - self._attribute = attribute - - def on_get(self, request, response): - """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: - bad_request( - response, b'Unknown attribute: {}'.format(self._attribute)) - return - else: - attribute = self._attribute - value = ATTRIBUTES[attribute].get(self._mlist, attribute) - resource[attribute] = value - okay(response, etag(resource)) - - def on_put(self, request, response): - """Set a mailing list configuration.""" - attribute = self._attribute - if attribute is None: - validator = Validator(**VALIDATORS) - try: - validator.update(self._mlist, request) - except ValueError as error: - bad_request(response, str(error)) - return - elif attribute not in ATTRIBUTES: - bad_request(response, b'Unknown attribute: {}'.format(attribute)) - return - elif ATTRIBUTES[attribute].decoder is None: - bad_request( - response, b'Read-only attribute: {}'.format(attribute)) - return - else: - validator = Validator(**{attribute: VALIDATORS[attribute]}) - try: - validator.update(self._mlist, request) - except ValueError as error: - bad_request(response, str(error)) - return - no_content(response) - - def on_patch(self, request, response): - """Patch the configuration (i.e. partial update).""" - try: - validator = PatchValidator(request, ATTRIBUTES) - except UnknownPATCHRequestError as error: - bad_request( - response, b'Unknown attribute: {}'.format(error.attribute)) - return - except ReadOnlyPATCHRequestError as error: - bad_request( - response, b'Read-only attribute: {}'.format(error.attribute)) - return - try: - validator.update(self._mlist, request) - except ValueError as error: - bad_request(response, str(error)) - else: - no_content(response) diff --git a/src/mailman/rest/docs/configuration.rst b/src/mailman/rest/docs/configuration.rst deleted file mode 100644 index 841ab3c27..000000000 --- a/src/mailman/rest/docs/configuration.rst +++ /dev/null @@ -1,237 +0,0 @@ -========================== -Mailing list configuration -========================== - -Mailing lists can be configured via the REST API. - - >>> mlist = create_list('ant@example.com') - >>> transaction.commit() - - -Reading a configuration -======================= - -All readable attributes for a list are available on a sub-resource. - - >>> dump_json('http://localhost:9001/3.0/lists/ant@example.com/config') - acceptable_aliases: [] - admin_immed_notify: True - admin_notify_mchanges: False - administrivia: True - advertised: True - allow_list_posts: True - anonymous_list: False - archive_policy: public - autorespond_owner: none - autorespond_postings: none - autorespond_requests: none - autoresponse_grace_period: 90d - autoresponse_owner_text: - autoresponse_postings_text: - autoresponse_request_text: - bounces_address: ant-bounces@example.com - collapse_alternatives: True - convert_html_to_plaintext: False - created_at: 20...T... - default_member_action: defer - default_nonmember_action: hold - description: - digest_last_sent_at: None - digest_size_threshold: 30.0 - display_name: Ant - filter_content: False - first_strip_reply_to: False - fqdn_listname: ant@example.com - http_etag: "..." - include_rfc2369_headers: True - join_address: ant-join@example.com - last_post_at: None - leave_address: ant-leave@example.com - list_name: ant - mail_host: example.com - next_digest_number: 1 - no_reply_address: noreply@example.com - owner_address: ant-owner@example.com - post_id: 1 - posting_address: ant@example.com - posting_pipeline: default-posting-pipeline - reply_goes_to_list: no_munging - reply_to_address: - request_address: ant-request@example.com - scheme: http - send_welcome_message: True - subject_prefix: [Ant] - volume: 1 - web_host: lists.example.com - welcome_message_uri: mailman:///welcome.txt - - -Changing the full configuration -=============================== - -Not all of the readable attributes can be set through the web interface. The -ones that can, can either be set via ``PUT`` or ``PATCH``. ``PUT`` changes -all the writable attributes in one request. - -When using ``PUT``, all writable attributes must be included. - - >>> dump_json('http://localhost:9001/3.0/lists/' - ... 'ant@example.com/config', - ... dict( - ... acceptable_aliases=['one@example.com', 'two@example.com'], - ... admin_immed_notify=False, - ... admin_notify_mchanges=True, - ... administrivia=False, - ... advertised=False, - ... anonymous_list=True, - ... archive_policy='never', - ... autorespond_owner='respond_and_discard', - ... autorespond_postings='respond_and_continue', - ... autorespond_requests='respond_and_discard', - ... autoresponse_grace_period='45d', - ... autoresponse_owner_text='the owner', - ... autoresponse_postings_text='the mailing list', - ... autoresponse_request_text='the robot', - ... display_name='Fnords', - ... description='This is my mailing list', - ... include_rfc2369_headers=False, - ... allow_list_posts=False, - ... digest_size_threshold=10.5, - ... posting_pipeline='virgin', - ... filter_content=True, - ... first_strip_reply_to=True, - ... convert_html_to_plaintext=True, - ... collapse_alternatives=False, - ... reply_goes_to_list='point_to_list', - ... reply_to_address='bee@example.com', - ... send_welcome_message=False, - ... subject_prefix='[ant]', - ... welcome_message_uri='mailman:///welcome.txt', - ... default_member_action='hold', - ... default_nonmember_action='discard', - ... ), - ... 'PUT') - content-length: 0 - date: ... - server: WSGIServer/... - status: 204 - -These values are changed permanently. - - >>> dump_json('http://localhost:9001/3.0/lists/' - ... 'ant@example.com/config') - acceptable_aliases: ['one@example.com', 'two@example.com'] - admin_immed_notify: False - admin_notify_mchanges: True - administrivia: False - advertised: False - allow_list_posts: False - anonymous_list: True - archive_policy: never - autorespond_owner: respond_and_discard - autorespond_postings: respond_and_continue - autorespond_requests: respond_and_discard - autoresponse_grace_period: 45d - autoresponse_owner_text: the owner - autoresponse_postings_text: the mailing list - autoresponse_request_text: the robot - ... - collapse_alternatives: False - convert_html_to_plaintext: True - ... - default_member_action: hold - default_nonmember_action: discard - description: This is my mailing list - ... - digest_size_threshold: 10.5 - display_name: Fnords - filter_content: True - first_strip_reply_to: True - ... - include_rfc2369_headers: False - ... - posting_pipeline: virgin - reply_goes_to_list: point_to_list - reply_to_address: bee@example.com - ... - send_welcome_message: False - subject_prefix: [ant] - ... - welcome_message_uri: mailman:///welcome.txt - - -Changing a partial configuration -================================ - -Using ``PATCH``, you can change just one attribute. - - >>> dump_json('http://localhost:9001/3.0/lists/' - ... 'ant@example.com/config', - ... dict(display_name='My List'), - ... 'PATCH') - content-length: 0 - date: ... - server: ... - status: 204 - -These values are changed permanently. - - >>> print(mlist.display_name) - My List - - -Sub-resources -============= - -Many of the mailing list configuration variables are actually available as -sub-resources on the mailing list. This is because they are collections, -sequences, and other complex configuration types. Their values can be -retrieved and set through the sub-resource. - - -Acceptable aliases ------------------- - -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:9001/3.0/lists/' - ... 'ant@example.com/config/acceptable_aliases') - acceptable_aliases: [] - http_etag: "..." - -We can add a few by ``PUT``-ing them on the sub-resource. The keys in the -dictionary are ignored. - - >>> dump_json('http://localhost:9001/3.0/lists/' - ... 'ant@example.com/config/acceptable_aliases', - ... dict(acceptable_aliases=['foo@example.com', - ... 'bar@example.net']), - ... 'PUT') - content-length: 0 - date: ... - server: WSGIServer/... - status: 204 - -Aliases are returned as a list on the ``aliases`` key. - - >>> response = call_http( - ... 'http://localhost:9001/3.0/lists/' - ... 'ant@example.com/config/acceptable_aliases') - >>> for alias in response['acceptable_aliases']: - ... print(alias) - bar@example.net - foo@example.com - -The mailing list has its aliases set. - - >>> from mailman.interfaces.mailinglist import IAcceptableAliasSet - >>> aliases = IAcceptableAliasSet(mlist) - >>> for alias in sorted(aliases.aliases): - ... print(alias) - bar@example.net - foo@example.com diff --git a/src/mailman/rest/docs/listconf.rst b/src/mailman/rest/docs/listconf.rst new file mode 100644 index 000000000..841ab3c27 --- /dev/null +++ b/src/mailman/rest/docs/listconf.rst @@ -0,0 +1,237 @@ +========================== +Mailing list configuration +========================== + +Mailing lists can be configured via the REST API. + + >>> mlist = create_list('ant@example.com') + >>> transaction.commit() + + +Reading a configuration +======================= + +All readable attributes for a list are available on a sub-resource. + + >>> dump_json('http://localhost:9001/3.0/lists/ant@example.com/config') + acceptable_aliases: [] + admin_immed_notify: True + admin_notify_mchanges: False + administrivia: True + advertised: True + allow_list_posts: True + anonymous_list: False + archive_policy: public + autorespond_owner: none + autorespond_postings: none + autorespond_requests: none + autoresponse_grace_period: 90d + autoresponse_owner_text: + autoresponse_postings_text: + autoresponse_request_text: + bounces_address: ant-bounces@example.com + collapse_alternatives: True + convert_html_to_plaintext: False + created_at: 20...T... + default_member_action: defer + default_nonmember_action: hold + description: + digest_last_sent_at: None + digest_size_threshold: 30.0 + display_name: Ant + filter_content: False + first_strip_reply_to: False + fqdn_listname: ant@example.com + http_etag: "..." + include_rfc2369_headers: True + join_address: ant-join@example.com + last_post_at: None + leave_address: ant-leave@example.com + list_name: ant + mail_host: example.com + next_digest_number: 1 + no_reply_address: noreply@example.com + owner_address: ant-owner@example.com + post_id: 1 + posting_address: ant@example.com + posting_pipeline: default-posting-pipeline + reply_goes_to_list: no_munging + reply_to_address: + request_address: ant-request@example.com + scheme: http + send_welcome_message: True + subject_prefix: [Ant] + volume: 1 + web_host: lists.example.com + welcome_message_uri: mailman:///welcome.txt + + +Changing the full configuration +=============================== + +Not all of the readable attributes can be set through the web interface. The +ones that can, can either be set via ``PUT`` or ``PATCH``. ``PUT`` changes +all the writable attributes in one request. + +When using ``PUT``, all writable attributes must be included. + + >>> dump_json('http://localhost:9001/3.0/lists/' + ... 'ant@example.com/config', + ... dict( + ... acceptable_aliases=['one@example.com', 'two@example.com'], + ... admin_immed_notify=False, + ... admin_notify_mchanges=True, + ... administrivia=False, + ... advertised=False, + ... anonymous_list=True, + ... archive_policy='never', + ... autorespond_owner='respond_and_discard', + ... autorespond_postings='respond_and_continue', + ... autorespond_requests='respond_and_discard', + ... autoresponse_grace_period='45d', + ... autoresponse_owner_text='the owner', + ... autoresponse_postings_text='the mailing list', + ... autoresponse_request_text='the robot', + ... display_name='Fnords', + ... description='This is my mailing list', + ... include_rfc2369_headers=False, + ... allow_list_posts=False, + ... digest_size_threshold=10.5, + ... posting_pipeline='virgin', + ... filter_content=True, + ... first_strip_reply_to=True, + ... convert_html_to_plaintext=True, + ... collapse_alternatives=False, + ... reply_goes_to_list='point_to_list', + ... reply_to_address='bee@example.com', + ... send_welcome_message=False, + ... subject_prefix='[ant]', + ... welcome_message_uri='mailman:///welcome.txt', + ... default_member_action='hold', + ... default_nonmember_action='discard', + ... ), + ... 'PUT') + content-length: 0 + date: ... + server: WSGIServer/... + status: 204 + +These values are changed permanently. + + >>> dump_json('http://localhost:9001/3.0/lists/' + ... 'ant@example.com/config') + acceptable_aliases: ['one@example.com', 'two@example.com'] + admin_immed_notify: False + admin_notify_mchanges: True + administrivia: False + advertised: False + allow_list_posts: False + anonymous_list: True + archive_policy: never + autorespond_owner: respond_and_discard + autorespond_postings: respond_and_continue + autorespond_requests: respond_and_discard + autoresponse_grace_period: 45d + autoresponse_owner_text: the owner + autoresponse_postings_text: the mailing list + autoresponse_request_text: the robot + ... + collapse_alternatives: False + convert_html_to_plaintext: True + ... + default_member_action: hold + default_nonmember_action: discard + description: This is my mailing list + ... + digest_size_threshold: 10.5 + display_name: Fnords + filter_content: True + first_strip_reply_to: True + ... + include_rfc2369_headers: False + ... + posting_pipeline: virgin + reply_goes_to_list: point_to_list + reply_to_address: bee@example.com + ... + send_welcome_message: False + subject_prefix: [ant] + ... + welcome_message_uri: mailman:///welcome.txt + + +Changing a partial configuration +================================ + +Using ``PATCH``, you can change just one attribute. + + >>> dump_json('http://localhost:9001/3.0/lists/' + ... 'ant@example.com/config', + ... dict(display_name='My List'), + ... 'PATCH') + content-length: 0 + date: ... + server: ... + status: 204 + +These values are changed permanently. + + >>> print(mlist.display_name) + My List + + +Sub-resources +============= + +Many of the mailing list configuration variables are actually available as +sub-resources on the mailing list. This is because they are collections, +sequences, and other complex configuration types. Their values can be +retrieved and set through the sub-resource. + + +Acceptable aliases +------------------ + +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:9001/3.0/lists/' + ... 'ant@example.com/config/acceptable_aliases') + acceptable_aliases: [] + http_etag: "..." + +We can add a few by ``PUT``-ing them on the sub-resource. The keys in the +dictionary are ignored. + + >>> dump_json('http://localhost:9001/3.0/lists/' + ... 'ant@example.com/config/acceptable_aliases', + ... dict(acceptable_aliases=['foo@example.com', + ... 'bar@example.net']), + ... 'PUT') + content-length: 0 + date: ... + server: WSGIServer/... + status: 204 + +Aliases are returned as a list on the ``aliases`` key. + + >>> response = call_http( + ... 'http://localhost:9001/3.0/lists/' + ... 'ant@example.com/config/acceptable_aliases') + >>> for alias in response['acceptable_aliases']: + ... print(alias) + bar@example.net + foo@example.com + +The mailing list has its aliases set. + + >>> from mailman.interfaces.mailinglist import IAcceptableAliasSet + >>> aliases = IAcceptableAliasSet(mlist) + >>> for alias in sorted(aliases.aliases): + ... print(alias) + bar@example.net + foo@example.com diff --git a/src/mailman/rest/docs/systemconf.rst b/src/mailman/rest/docs/systemconf.rst new file mode 100644 index 000000000..66953f4ba --- /dev/null +++ b/src/mailman/rest/docs/systemconf.rst @@ -0,0 +1,34 @@ +==================== +System configuration +==================== + +The entire system configuration is available through the REST API. You can +get a list of all defined sections. + + >>> dump_json('http://localhost:9001/3.0/system/configuration') + http_etag: ... + sections: ['antispam', 'archiver.mail_archive', 'archiver.master', ... + +You can also get all the values for a particular section. + + >>> dump_json('http://localhost:9001/3.0/system/configuration/mailman') + default_language: en + email_commands_max_lines: 10 + filtered_messages_are_preservable: no + http_etag: ... + layout: testing + noreply_address: noreply + pending_request_life: 3d + post_hook: + pre_hook: + sender_headers: from from_ reply-to sender + site_owner: noreply@example.com + +Dotted section names work too, for example, to get the French language +settings section. + + >>> dump_json('http://localhost:9001/3.0/system/configuration/language.fr') + charset: iso-8859-1 + description: French + enabled: yes + http_etag: ... diff --git a/src/mailman/rest/listconf.py b/src/mailman/rest/listconf.py new file mode 100644 index 000000000..b432268c7 --- /dev/null +++ b/src/mailman/rest/listconf.py @@ -0,0 +1,226 @@ +# Copyright (C) 2010-2014 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 . + +"""Mailing list configuration via REST API.""" + +from __future__ import absolute_import, print_function, unicode_literals + +__metaclass__ = type +__all__ = [ + 'ListConfiguration', + ] + + +from lazr.config import as_boolean, as_timedelta +from mailman.config import config +from mailman.core.errors import ( + ReadOnlyPATCHRequestError, UnknownPATCHRequestError) +from mailman.interfaces.action import Action +from mailman.interfaces.archiver import ArchivePolicy +from mailman.interfaces.autorespond import ResponseAction +from mailman.interfaces.mailinglist import IAcceptableAliasSet, ReplyToMunging +from mailman.rest.helpers import ( + GetterSetter, bad_request, etag, no_content, okay) +from mailman.rest.validator import PatchValidator, Validator, enum_validator + + + +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: {}'.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: {}'.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: {}'.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), + archive_policy=GetterSetter(enum_validator(ArchivePolicy)), + bounces_address=GetterSetter(None), + collapse_alternatives=GetterSetter(as_boolean), + convert_html_to_plaintext=GetterSetter(as_boolean), + created_at=GetterSetter(None), + default_member_action=GetterSetter(enum_validator(Action)), + default_nonmember_action=GetterSetter(enum_validator(Action)), + description=GetterSetter(unicode), + digest_last_sent_at=GetterSetter(None), + digest_size_threshold=GetterSetter(float), + filter_content=GetterSetter(as_boolean), + first_strip_reply_to=GetterSetter(as_boolean), + fqdn_listname=GetterSetter(None), + mail_host=GetterSetter(None), + allow_list_posts=GetterSetter(as_boolean), + include_rfc2369_headers=GetterSetter(as_boolean), + join_address=GetterSetter(None), + last_post_at=GetterSetter(None), + leave_address=GetterSetter(None), + list_name=GetterSetter(None), + next_digest_number=GetterSetter(None), + no_reply_address=GetterSetter(None), + owner_address=GetterSetter(None), + post_id=GetterSetter(None), + posting_address=GetterSetter(None), + posting_pipeline=GetterSetter(pipeline_validator), + display_name=GetterSetter(unicode), + reply_goes_to_list=GetterSetter(enum_validator(ReplyToMunging)), + reply_to_address=GetterSetter(unicode), + request_address=GetterSetter(None), + scheme=GetterSetter(None), + send_welcome_message=GetterSetter(as_boolean), + subject_prefix=GetterSetter(unicode), + volume=GetterSetter(None), + web_host=GetterSetter(None), + welcome_message_uri=GetterSetter(unicode), + ) + + +VALIDATORS = ATTRIBUTES.copy() +for attribute, gettersetter in VALIDATORS.items(): + if gettersetter.decoder is None: + del VALIDATORS[attribute] + + + +class ListConfiguration: + """A mailing list configuration resource.""" + + def __init__(self, mailing_list, attribute): + self._mlist = mailing_list + self._attribute = attribute + + def on_get(self, request, response): + """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: + bad_request( + response, b'Unknown attribute: {}'.format(self._attribute)) + return + else: + attribute = self._attribute + value = ATTRIBUTES[attribute].get(self._mlist, attribute) + resource[attribute] = value + okay(response, etag(resource)) + + def on_put(self, request, response): + """Set a mailing list configuration.""" + attribute = self._attribute + if attribute is None: + validator = Validator(**VALIDATORS) + try: + validator.update(self._mlist, request) + except ValueError as error: + bad_request(response, str(error)) + return + elif attribute not in ATTRIBUTES: + bad_request(response, b'Unknown attribute: {}'.format(attribute)) + return + elif ATTRIBUTES[attribute].decoder is None: + bad_request( + response, b'Read-only attribute: {}'.format(attribute)) + return + else: + validator = Validator(**{attribute: VALIDATORS[attribute]}) + try: + validator.update(self._mlist, request) + except ValueError as error: + bad_request(response, str(error)) + return + no_content(response) + + def on_patch(self, request, response): + """Patch the configuration (i.e. partial update).""" + try: + validator = PatchValidator(request, ATTRIBUTES) + except UnknownPATCHRequestError as error: + bad_request( + response, b'Unknown attribute: {}'.format(error.attribute)) + return + except ReadOnlyPATCHRequestError as error: + bad_request( + response, b'Read-only attribute: {}'.format(error.attribute)) + return + try: + validator.update(self._mlist, request) + except ValueError as error: + bad_request(response, str(error)) + else: + no_content(response) diff --git a/src/mailman/rest/lists.py b/src/mailman/rest/lists.py index 580b6e898..c96d5ded9 100644 --- a/src/mailman/rest/lists.py +++ b/src/mailman/rest/lists.py @@ -43,7 +43,7 @@ from mailman.interfaces.mailinglist import IListArchiverSet from mailman.interfaces.member import MemberRole from mailman.interfaces.styles import IStyleManager from mailman.interfaces.subscriptions import ISubscriptionService -from mailman.rest.configuration import ListConfiguration +from mailman.rest.listconf import ListConfiguration from mailman.rest.helpers import ( CollectionMixin, GetterSetter, NotFound, bad_request, child, created, etag, no_content, not_found, okay, paginate, path_to) diff --git a/src/mailman/rest/root.py b/src/mailman/rest/root.py index f29f2ba1d..a3d18c201 100644 --- a/src/mailman/rest/root.py +++ b/src/mailman/rest/root.py @@ -37,7 +37,7 @@ from mailman.interfaces.listmanager import IListManager from mailman.rest.addresses import AllAddresses, AnAddress from mailman.rest.domains import ADomain, AllDomains from mailman.rest.helpers import ( - BadRequest, NotFound, child, etag, okay, path_to) + BadRequest, NotFound, child, etag, not_found, okay, path_to) from mailman.rest.lists import AList, AllLists, Styles from mailman.rest.members import AMember, AllMembers, FindMembers from mailman.rest.preferences import ReadOnlyPreferences @@ -91,6 +91,27 @@ class Versions: okay(response, etag(resource)) +class SystemConfiguration: + def __init__(self, section=None): + self._section = section + + def on_get(self, request, response): + if self._section is None: + resource = dict( + sections=sorted(section.name for section in config)) + okay(response, etag(resource)) + return + missing = object() + section = getattr(config, self._section, missing) + if section is missing: + not_found(response) + return + # Sections don't have .keys(), .values(), or .items() but we can + # iterate over them. + resource = {key: section[key] for key in section} + okay(response, etag(resource)) + + class TopLevel: """Top level collections and entries.""" @@ -100,12 +121,18 @@ class TopLevel: if len(segments) == 0: # This provides backward compatibility; see /system/versions. return Versions() - elif len(segments) > 1: - return BadRequest(), [] elif segments[0] == 'preferences': + if len(segments) > 1: + return BadRequest(), [] return ReadOnlyPreferences(system_preferences, 'system'), [] elif segments[0] == 'versions': + if len(segments) > 1: + return BadRequest(), [] return Versions(), [] + elif segments[0] == 'configuration': + if len(segments) <= 2: + return SystemConfiguration(*segments[1:]), [] + return BadRequest(), [] else: return NotFound(), [] diff --git a/src/mailman/rest/tests/test_configuration.py b/src/mailman/rest/tests/test_configuration.py deleted file mode 100644 index 93171ec4b..000000000 --- a/src/mailman/rest/tests/test_configuration.py +++ /dev/null @@ -1,94 +0,0 @@ -# Copyright (C) 2014 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 . - -"""Test list configuration via the REST API.""" - -from __future__ import absolute_import, print_function, unicode_literals - -__metaclass__ = type -__all__ = [ - 'TestConfiguration', - ] - - -import unittest - -from mailman.app.lifecycle import create_list -from mailman.database.transaction import transaction -from mailman.interfaces.mailinglist import IAcceptableAliasSet -from mailman.testing.helpers import call_api -from mailman.testing.layers import RESTLayer - - - -class TestConfiguration(unittest.TestCase): - """Test list configuration via the REST API.""" - - layer = RESTLayer - - def setUp(self): - with transaction(): - self._mlist = create_list('test@example.com') - - def test_put_configuration(self): - aliases = [ - 'ant@example.com', - 'bee@example.com', - 'cat@example.com', - ] - # When using PUT, all writable attributes must be included. - resource, response = call_api( - 'http://localhost:9001/3.0/lists/test@example.com/config', - dict( - acceptable_aliases=aliases, - admin_immed_notify=False, - admin_notify_mchanges=True, - administrivia=False, - advertised=False, - anonymous_list=True, - archive_policy='never', - autorespond_owner='respond_and_discard', - autorespond_postings='respond_and_continue', - autorespond_requests='respond_and_discard', - autoresponse_grace_period='45d', - autoresponse_owner_text='the owner', - autoresponse_postings_text='the mailing list', - autoresponse_request_text='the robot', - display_name='Fnords', - description='This is my mailing list', - include_rfc2369_headers=False, - allow_list_posts=False, - digest_size_threshold=10.5, - posting_pipeline='virgin', - filter_content=True, - first_strip_reply_to=True, - convert_html_to_plaintext=True, - collapse_alternatives=False, - reply_goes_to_list='point_to_list', - reply_to_address='bee@example.com', - send_welcome_message=False, - subject_prefix='[ant]', - welcome_message_uri='mailman:///welcome.txt', - default_member_action='hold', - default_nonmember_action='discard', - ), - 'PUT') - self.assertEqual(response.status, 204) - self.assertEqual(self._mlist.display_name, 'Fnords') - # All three acceptable aliases were set. - self.assertEqual(set(IAcceptableAliasSet(self._mlist).aliases), - set(aliases)) diff --git a/src/mailman/rest/tests/test_listconf.py b/src/mailman/rest/tests/test_listconf.py new file mode 100644 index 000000000..93171ec4b --- /dev/null +++ b/src/mailman/rest/tests/test_listconf.py @@ -0,0 +1,94 @@ +# Copyright (C) 2014 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 . + +"""Test list configuration via the REST API.""" + +from __future__ import absolute_import, print_function, unicode_literals + +__metaclass__ = type +__all__ = [ + 'TestConfiguration', + ] + + +import unittest + +from mailman.app.lifecycle import create_list +from mailman.database.transaction import transaction +from mailman.interfaces.mailinglist import IAcceptableAliasSet +from mailman.testing.helpers import call_api +from mailman.testing.layers import RESTLayer + + + +class TestConfiguration(unittest.TestCase): + """Test list configuration via the REST API.""" + + layer = RESTLayer + + def setUp(self): + with transaction(): + self._mlist = create_list('test@example.com') + + def test_put_configuration(self): + aliases = [ + 'ant@example.com', + 'bee@example.com', + 'cat@example.com', + ] + # When using PUT, all writable attributes must be included. + resource, response = call_api( + 'http://localhost:9001/3.0/lists/test@example.com/config', + dict( + acceptable_aliases=aliases, + admin_immed_notify=False, + admin_notify_mchanges=True, + administrivia=False, + advertised=False, + anonymous_list=True, + archive_policy='never', + autorespond_owner='respond_and_discard', + autorespond_postings='respond_and_continue', + autorespond_requests='respond_and_discard', + autoresponse_grace_period='45d', + autoresponse_owner_text='the owner', + autoresponse_postings_text='the mailing list', + autoresponse_request_text='the robot', + display_name='Fnords', + description='This is my mailing list', + include_rfc2369_headers=False, + allow_list_posts=False, + digest_size_threshold=10.5, + posting_pipeline='virgin', + filter_content=True, + first_strip_reply_to=True, + convert_html_to_plaintext=True, + collapse_alternatives=False, + reply_goes_to_list='point_to_list', + reply_to_address='bee@example.com', + send_welcome_message=False, + subject_prefix='[ant]', + welcome_message_uri='mailman:///welcome.txt', + default_member_action='hold', + default_nonmember_action='discard', + ), + 'PUT') + self.assertEqual(response.status, 204) + self.assertEqual(self._mlist.display_name, 'Fnords') + # All three acceptable aliases were set. + self.assertEqual(set(IAcceptableAliasSet(self._mlist).aliases), + set(aliases)) diff --git a/src/mailman/rest/tests/test_root.py b/src/mailman/rest/tests/test_root.py index a91a179e0..510120087 100644 --- a/src/mailman/rest/tests/test_root.py +++ b/src/mailman/rest/tests/test_root.py @@ -22,7 +22,6 @@ from __future__ import absolute_import, print_function, unicode_literals __metaclass__ = type __all__ = [ 'TestRoot', - 'TestSystemConfiguration', ] @@ -67,12 +66,6 @@ class TestRoot(unittest.TestCase): call_api('http://localhost:9001/3.0/does-not-exist') self.assertEqual(cm.exception.code, 404) - def test_system_url_too_long(self): - # /system/foo/bar is not allowed. - with self.assertRaises(HTTPError) as cm: - call_api('http://localhost:9001/3.0/system/foo/bar') - self.assertEqual(cm.exception.code, 400) - def test_system_url_not_preferences(self): # /system/foo where `foo` is not `preferences`. with self.assertRaises(HTTPError) as cm: @@ -132,8 +125,3 @@ class TestRoot(unittest.TestCase): self.assertEqual(content['title'], '401 Unauthorized') self.assertEqual(content['description'], 'User is not authorized for the REST API') - - - -class TestSystemConfiguration(unittest.TestCase): - layer = RESTLayer diff --git a/src/mailman/rest/tests/test_systemconf.py b/src/mailman/rest/tests/test_systemconf.py new file mode 100644 index 000000000..2eb4fa251 --- /dev/null +++ b/src/mailman/rest/tests/test_systemconf.py @@ -0,0 +1,181 @@ +# Copyright (C) 2014 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 . + +"""Test system configuration read-only access.""" + +__all__ = [ + 'TestSystemConfiguration', + ] + + +import unittest + +from mailman.testing.helpers import call_api +from mailman.testing.layers import RESTLayer +from six.moves.urllib_error import HTTPError + + + +class TestSystemConfiguration(unittest.TestCase): + layer = RESTLayer + maxDiff = None + + def test_basic_system_configuration(self): + # Read some basic system configuration value, just to prove that the + # infrastructure works. + url = 'http://localhost:9001/3.0/system/configuration/mailman' + json, response = call_api(url) + # There must be an `http_etag` key, but we don't care about its value. + self.assertIn('http_etag', json) + del json['http_etag'] + self.assertEqual(json, dict( + site_owner='noreply@example.com', + noreply_address='noreply', + default_language='en', + sender_headers='from from_ reply-to sender', + email_commands_max_lines='10', + pending_request_life='3d', + pre_hook='', + post_hook='', + layout='testing', + filtered_messages_are_preservable='no', + )) + + def test_dotted_section(self): + # A dotted section works too. + url = 'http://localhost:9001/3.0/system/configuration/language.fr' + json, response = call_api(url) + # There must be an `http_etag` key, but we don't care about its value. + self.assertIn('http_etag', json) + del json['http_etag'] + self.assertEqual(json, dict( + description='French', + charset='iso-8859-1', + enabled='yes', + )) + + def test_multiline(self): + # Some values contain multiple lines. It is up to the client to split + # on whitespace. + url = 'http://localhost:9001/3.0/system/configuration/nntp' + json, response = call_api(url) + value = json['remove_headers'] + self.assertEqual(sorted(value.split()), [ + 'date-received', + 'nntp-posting-date', + 'nntp-posting-host', + 'posted', + 'posting-version', + 'received', + 'relay-version', + 'x-complaints-to', + 'x-trace', + 'xref', + ]) + + + def test_all_sections(self): + # Getting the top level configuration object returns a list of all + # existing sections. + url = 'http://localhost:9001/3.0/system/configuration' + json, response = call_api(url) + self.assertIn('http_etag', json) + self.assertEqual(sorted(json['sections']), [ + 'antispam', + 'archiver.mail_archive', + 'archiver.master', + 'archiver.mhonarc', + 'archiver.prototype', + 'bounces', + 'database', + 'devmode', + 'digests', + 'language.en', + 'language.fr', + 'language.ja', + 'logging.archiver', + 'logging.bounce', + 'logging.config', + 'logging.database', + 'logging.debug', + 'logging.error', + 'logging.fromusenet', + 'logging.http', + 'logging.locks', + 'logging.mischief', + 'logging.root', + 'logging.runner', + 'logging.smtp', + 'logging.subscribe', + 'logging.vette', + 'mailman', + 'mta', + 'nntp', + 'passwords', + 'paths.dev', + 'paths.fhs', + 'paths.local', + 'paths.testing', + 'runner.archive', + 'runner.bad', + 'runner.bounces', + 'runner.command', + 'runner.digest', + 'runner.in', + 'runner.lmtp', + 'runner.nntp', + 'runner.out', + 'runner.pipeline', + 'runner.rest', + 'runner.retry', + 'runner.shunt', + 'runner.virgin', + 'shell', + 'styles', + 'webservice', + ]) + + def test_no_such_section(self): + # A bogus section returns a 404. + url = 'http://localhost:9001/3.0/system/configuration/nosuchsection' + with self.assertRaises(HTTPError) as cm: + call_api(url) + self.assertEqual(cm.exception.code, 404) + + def test_too_many_path_components(self): + # More than two path components is an error, even if they name a valid + # configuration variable. + url = 'http://localhost:9001/3.0/system/configuration/mailman/layout' + with self.assertRaises(HTTPError) as cm: + call_api(url) + self.assertEqual(cm.exception.code, 400) + + def test_read_only(self): + # The entire configuration is read-only. + url = 'http://localhost:9001/3.0/system/configuration' + with self.assertRaises(HTTPError) as cm: + call_api(url, {'foo': 'bar'}) + # 405 is Method Not Allowed. + self.assertEqual(cm.exception.code, 405) + + def test_section_read_only(self): + # Sections are also read-only. + url = 'http://localhost:9001/3.0/system/configuration/mailman' + with self.assertRaises(HTTPError) as cm: + call_api(url, {'foo': 'bar'}) + # 405 is Method Not Allowed. + self.assertEqual(cm.exception.code, 405) diff --git a/src/mailman/runners/docs/rest.rst b/src/mailman/runners/docs/rest.rst index 9e8851eca..71a059ae1 100644 --- a/src/mailman/runners/docs/rest.rst +++ b/src/mailman/runners/docs/rest.rst @@ -14,7 +14,7 @@ The RESTful server can be used to access basic version information. http_etag: "..." mailman_version: GNU Mailman 3.0... (...) python_version: ... - self_link: http://localhost:9001/3.0/system + self_link: http://localhost:9001/3.0/system/versions Clean up -- cgit v1.3.1