diff options
| author | Barry Warsaw | 2012-09-22 13:35:24 -0400 |
|---|---|---|
| committer | Barry Warsaw | 2012-09-22 13:35:24 -0400 |
| commit | 7e97811156ad3fbf9daafc253a2c473b05e07542 (patch) | |
| tree | 82009bad2d877462d42cf1757d3dd96ad436d643 /src | |
| parent | a8e5f267b418cd4bb577ae229fd7e22d5720e93f (diff) | |
| download | mailman-7e97811156ad3fbf9daafc253a2c473b05e07542.tar.gz mailman-7e97811156ad3fbf9daafc253a2c473b05e07542.tar.zst mailman-7e97811156ad3fbf9daafc253a2c473b05e07542.zip | |
Diffstat (limited to 'src')
| -rw-r--r-- | src/mailman/core/errors.py | 22 | ||||
| -rw-r--r-- | src/mailman/docs/NEWS.rst | 3 | ||||
| -rw-r--r-- | src/mailman/rest/configuration.py | 108 | ||||
| -rw-r--r-- | src/mailman/rest/docs/domains.rst | 4 | ||||
| -rw-r--r-- | src/mailman/rest/docs/users.rst | 101 | ||||
| -rw-r--r-- | src/mailman/rest/helpers.py | 61 | ||||
| -rw-r--r-- | src/mailman/rest/preferences.py | 21 | ||||
| -rw-r--r-- | src/mailman/rest/users.py | 60 | ||||
| -rw-r--r-- | src/mailman/rest/validator.py | 50 |
9 files changed, 320 insertions, 110 deletions
diff --git a/src/mailman/core/errors.py b/src/mailman/core/errors.py index 529ac86fe..95a318ca6 100644 --- a/src/mailman/core/errors.py +++ b/src/mailman/core/errors.py @@ -43,7 +43,10 @@ __all__ = [ 'MemberError', 'MustDigestError', 'PasswordError', + 'RESTError', + 'ReadOnlyPATCHRequestError', 'RejectMessage', + 'UnknownPATCHRequestError', ] @@ -126,3 +129,22 @@ class BadPasswordSchemeError(PasswordError): def __str__(self): return 'A bad password scheme was given: %s' % self.scheme_name + + + +class RESTError(MailmanError): + """Base class for REST API errors.""" + + +class UnknownPATCHRequestError(RESTError): + """A PATCH request contained an unknown attribute.""" + + def __init__(self, attribute): + self.attribute = attribute + + +class ReadOnlyPATCHRequestError(RESTError): + """A PATCH request contained a read-only attribute.""" + + def __init__(self, attribute): + self.attribute = attribute diff --git a/src/mailman/docs/NEWS.rst b/src/mailman/docs/NEWS.rst index 984e0d9fb..50eeb0fda 100644 --- a/src/mailman/docs/NEWS.rst +++ b/src/mailman/docs/NEWS.rst @@ -20,6 +20,9 @@ REST the URL with the list-id. To reference a mailing list, the list-id url is preferred, but for backward compatibility, the posting address is still accepted. + * You can now PUT and PATCH on user resources to change the user's display + name or password. For passwords, you pass in the clear text password and + Mailman will hash it before storing. 3.0 beta 2 -- "Freeze" diff --git a/src/mailman/rest/configuration.py b/src/mailman/rest/configuration.py index 83d4c74f6..8db23136a 100644 --- a/src/mailman/rest/configuration.py +++ b/src/mailman/rest/configuration.py @@ -29,78 +29,17 @@ from lazr.config import as_boolean, as_timedelta from restish import http, resource 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 PATCH, etag, no_content -from mailman.rest.validator import Validator, enum_validator +from mailman.rest.helpers import GetterSetter, PATCH, etag, no_content +from mailman.rest.validator import PatchValidator, Validator, enum_validator -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.""" @@ -239,19 +178,6 @@ class ListConfiguration(resource.Resource): resource[attribute] = value return http.ok([], etag(resource)) - # XXX 2010-09-01 barry: Refactor {put,patch}_configuration() for common - # code paths. - - def _set_writable_attributes(self, validator, request): - """Common code for setting all attributes given in the request. - - Returns an HTTP 400 when a request tries to write to a read-only - attribute. - """ - converted = validator(request) - for key, value in converted.items(): - ATTRIBUTES[key].put(self._mlist, key, value) - @resource.PUT() def put_configuration(self, request): """Set a mailing list configuration.""" @@ -259,7 +185,7 @@ class ListConfiguration(resource.Resource): if attribute is None: validator = Validator(**VALIDATORS) try: - self._set_writable_attributes(validator, request) + validator.update(self._mlist, request) except ValueError as error: return http.bad_request([], str(error)) elif attribute not in ATTRIBUTES: @@ -271,7 +197,7 @@ class ListConfiguration(resource.Resource): else: validator = Validator(**{attribute: VALIDATORS[attribute]}) try: - self._set_writable_attributes(validator, request) + validator.update(self._mlist, request) except ValueError as error: return http.bad_request([], str(error)) return no_content() @@ -279,20 +205,16 @@ class ListConfiguration(resource.Resource): @PATCH() def patch_configuration(self, request): """Patch the configuration (i.e. partial update).""" - # Validate only the partial subset of attributes given in the request. - validationators = {} - for attribute in request.PATCH: - if 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: - validationators[attribute] = VALIDATORS[attribute] - validator = Validator(**validationators) try: - self._set_writable_attributes(validator, request) + validator = PatchValidator(request, ATTRIBUTES) + except UnknownPATCHRequestError as error: + return http.bad_request( + [], b'Unknown attribute: {0}'.format(error.attribute)) + except ReadOnlyPATCHRequestError as error: + return http.bad_request( + [], b'Read-only attribute: {0}'.format(error.attribute)) + try: + validator.update(self._mlist, request) except ValueError as error: return http.bad_request([], str(error)) return no_content() diff --git a/src/mailman/rest/docs/domains.rst b/src/mailman/rest/docs/domains.rst index c890af7fa..92c73ffbf 100644 --- a/src/mailman/rest/docs/domains.rst +++ b/src/mailman/rest/docs/domains.rst @@ -131,7 +131,7 @@ example.com domain does not contain any mailing lists. ... }) content-length: 0 date: ... - location: http://localhost:9001/3.0/lists/test-domains@example.com + location: http://localhost:9001/3.0/lists/test-domains.example.com ... >>> dump_json('http://localhost:9001/3.0/domains/example.com/lists') @@ -141,7 +141,7 @@ example.com domain does not contain any mailing lists. http_etag: "..." ... member_count: 0 - self_link: http://localhost:9001/3.0/lists/test-domains@example.com + self_link: http://localhost:9001/3.0/lists/test-domains.example.com volume: 1 http_etag: "..." start: 0 diff --git a/src/mailman/rest/docs/users.rst b/src/mailman/rest/docs/users.rst index cdede10ee..888bc43fd 100644 --- a/src/mailman/rest/docs/users.rst +++ b/src/mailman/rest/docs/users.rst @@ -40,7 +40,7 @@ The user ids match. >>> json['entries'][0]['user_id'] == anne.user_id.int True -A user might not have a real name, in which case, the attribute will not be +A user might not have a display name, in which case, the attribute will not be returned in the REST API. >>> dave = user_manager.create_user('dave@example.com') @@ -66,7 +66,8 @@ Creating users via the API ========================== New users can be created through the REST API. To do so requires the initial -email address for the user, a password, and optionally the user's full name. +email address for the user, a password, and optionally the user's display +name. :: >>> transaction.abort() @@ -134,6 +135,75 @@ therefore cannot be retrieved. It can be reset though. user_id: 4 +Updating users +============== + +Users have a password and a display name. The display name can be changed +through the REST API. + + >>> dump_json('http://localhost:9001/3.0/users/4', { + ... 'display_name': 'Chrissy Person', + ... }, method='PATCH') + content-length: 0 + date: ... + server: ... + status: 204 + +Cris's display name has been updated. + + >>> dump_json('http://localhost:9001/3.0/users/4') + created_on: 2005-08-01T07:49:23 + display_name: Chrissy Person + http_etag: "..." + password: {plaintext}... + self_link: http://localhost:9001/3.0/users/4 + user_id: 4 + +You can also change the user's password by passing in the new clear text +password. Mailman will hash this before it is stored internally. + + >>> dump_json('http://localhost:9001/3.0/users/4', { + ... 'cleartext_password': 'clockwork angels', + ... }, method='PATCH') + content-length: 0 + date: ... + server: ... + status: 204 + +Even though you see *{plaintext}clockwork angels* below, it has still been +hashed before storage. The default hashing algorithm for the test suite is a +plain text hash, but you can see that it works by the addition of the +algorithm prefix. + + >>> dump_json('http://localhost:9001/3.0/users/4') + created_on: 2005-08-01T07:49:23 + display_name: Chrissy Person + http_etag: "..." + password: {plaintext}clockwork angels + self_link: http://localhost:9001/3.0/users/4 + user_id: 4 + +You can change both the display name and the password by PUTing the full +resource. + + >>> dump_json('http://localhost:9001/3.0/users/4', { + ... 'display_name': 'Christopherson Person', + ... 'cleartext_password': 'the garden', + ... }, method='PUT') + content-length: 0 + date: ... + server: ... + status: 204 + + >>> dump_json('http://localhost:9001/3.0/users/4') + created_on: 2005-08-01T07:49:23 + display_name: Christopherson Person + http_etag: "..." + password: {plaintext}the garden + self_link: http://localhost:9001/3.0/users/4 + user_id: 4 + + Deleting users via the API ========================== @@ -145,11 +215,21 @@ Users can also be deleted via the API. date: ... server: ... status: 204 + +Cris's resource cannot be retrieved either by email address... + >>> dump_json('http://localhost:9001/3.0/users/cris@example.com') Traceback (most recent call last): ... HTTPError: HTTP Error 404: 404 Not Found +...or user id. + + >>> dump_json('http://localhost:9001/3.0/users/4') + Traceback (most recent call last): + ... + HTTPError: HTTP Error 404: 404 Not Found + Missing users ============= @@ -171,6 +251,23 @@ user id... ... HTTPError: HTTP Error 404: 404 Not Found +You also can't update a missing user. + + >>> dump_json('http://localhost:9001/3.0/users/zed@example.org', { + ... 'display_name': 'Is Dead', + ... }, method='PATCH') + Traceback (most recent call last): + ... + HTTPError: HTTP Error 404: 404 Not Found + + >>> dump_json('http://localhost:9001/3.0/users/zed@example.org', { + ... 'display_name': 'Is Dead', + ... 'cleartext_password': 'vroom', + ... }, method='PUT') + Traceback (most recent call last): + ... + HTTPError: HTTP Error 404: 404 Not Found + User addresses ============== diff --git a/src/mailman/rest/helpers.py b/src/mailman/rest/helpers.py index 2824a894e..72040c848 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__ = [ + 'GetterSetter', 'PATCH', 'etag', 'no_content', @@ -205,3 +206,63 @@ class PATCH(MethodDecorator): def __call__(self, func): really_wrapped_func = PATCHWrapper(func) return super(PATCH, self).__call__(really_wrapped_func) + + +class GetterSetter: + """Get and set attributes on an object. + + Most attributes are fairly simple - a getattr() or setattr() on the object + 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 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 attribute. + + :param decoder: The callable for decoding a web request value string + into the specific data type needed by the object's 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, obj, attribute): + """Return the named object attribute value. + + :param obj: The object to access. + :type obj: object + :param attribute: The attribute name. + :type attribute: string + :return: The attribute value, ready for JSON encoding. + :rtype: object + """ + return getattr(obj, attribute) + + def put(self, obj, attribute, value): + """Set the named object attribute value. + + :param obj: The object to change. + :type obj: object + :param attribute: The attribute name. + :type attribute: string + :param value: The new value for the attribute. + """ + setattr(obj, 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) diff --git a/src/mailman/rest/preferences.py b/src/mailman/rest/preferences.py index be598458e..1961c8b84 100644 --- a/src/mailman/rest/preferences.py +++ b/src/mailman/rest/preferences.py @@ -30,7 +30,8 @@ from lazr.config import as_boolean from restish import http, resource from mailman.interfaces.member import DeliveryMode, DeliveryStatus -from mailman.rest.helpers import PATCH, etag, no_content, path_to +from mailman.rest.helpers import ( + GetterSetter, PATCH, etag, no_content, path_to) from mailman.rest.validator import ( Validator, enum_validator, language_validator) @@ -72,7 +73,7 @@ class ReadOnlyPreferences(resource.Resource): resource['self_link'] = path_to( '{0}/preferences'.format(self._base_url)) return http.ok([], etag(resource)) - + class Preferences(ReadOnlyPreferences): @@ -82,22 +83,20 @@ class Preferences(ReadOnlyPreferences): if self._parent is None: return http.not_found() kws = dict( - acknowledge_posts=as_boolean, - delivery_mode=enum_validator(DeliveryMode), - delivery_status=enum_validator(DeliveryStatus), - preferred_language=language_validator, - receive_list_copy=as_boolean, - receive_own_postings=as_boolean, + acknowledge_posts=GetterSetter(as_boolean), + delivery_mode=GetterSetter(enum_validator(DeliveryMode)), + delivery_status=GetterSetter(enum_validator(DeliveryStatus)), + preferred_language=GetterSetter(language_validator), + receive_list_copy=GetterSetter(as_boolean), + receive_own_postings=GetterSetter(as_boolean), ) if is_optional: # For a PUT, all attributes are optional. kws['_optional'] = kws.keys() try: - values = Validator(**kws)(request) + Validator(**kws).update(self._parent, request) except ValueError as error: return http.bad_request([], str(error)) - for key, value in values.items(): - setattr(self._parent, key, value) return no_content() @PATCH() diff --git a/src/mailman/rest/users.py b/src/mailman/rest/users.py index bce541ae5..94817da49 100644 --- a/src/mailman/rest/users.py +++ b/src/mailman/rest/users.py @@ -32,12 +32,34 @@ from uuid import UUID from zope.component import getUtility from mailman.config import config +from mailman.core.errors import ( + ReadOnlyPATCHRequestError, UnknownPATCHRequestError) from mailman.interfaces.address import ExistingAddressError from mailman.interfaces.usermanager import IUserManager from mailman.rest.addresses import UserAddresses -from mailman.rest.helpers import CollectionMixin, etag, no_content, path_to +from mailman.rest.helpers import ( + CollectionMixin, GetterSetter, PATCH, etag, no_content, path_to) from mailman.rest.preferences import Preferences -from mailman.rest.validator import Validator +from mailman.rest.validator import PatchValidator, Validator + + +# Attributes of a user which can be changed via the REST API. +class PasswordEncrypterGetterSetter(GetterSetter): + def __init__(self): + super(PasswordEncrypterGetterSetter, self).__init__( + config.password_context.encrypt) + def get(self, obj, attribute): + assert attribute == 'cleartext_password' + super(PasswordEncrypterGetterSetter, self).get(obj, 'password') + def put(self, obj, attribute, value): + assert attribute == 'cleartext_password' + super(PasswordEncrypterGetterSetter, self).put(obj, 'password', value) + + +ATTRIBUTES = dict( + display_name=GetterSetter(unicode), + cleartext_password=PasswordEncrypterGetterSetter(), + ) @@ -165,3 +187,37 @@ class AUser(_UserBase): self._user.preferences, 'users/{0}'.format(self._user.user_id.int)) return child, [] + + @PATCH() + def patch_update(self, request): + """Patch the user's configuration (i.e. partial update).""" + if self._user is None: + return http.not_found() + try: + validator = PatchValidator(request, ATTRIBUTES) + except UnknownPATCHRequestError as error: + return http.bad_request( + [], b'Unknown attribute: {0}'.format(error.attribute)) + except ReadOnlyPATCHRequestError as error: + return http.bad_request( + [], b'Read-only attribute: {0}'.format(error.attribute)) + validator.update(self._user, request) + return no_content() + + @resource.PUT() + def put_update(self, request): + """Put the user's configuration (i.e. full update).""" + if self._user is None: + return http.not_found() + validator = Validator(**ATTRIBUTES) + try: + validator.update(self._user, request) + except UnknownPATCHRequestError as error: + return http.bad_request( + [], b'Unknown attribute: {0}'.format(error.attribute)) + except ReadOnlyPATCHRequestError as error: + return http.bad_request( + [], b'Read-only attribute: {0}'.format(error.attribute)) + except ValueError as error: + return http.bad_request([], str(error)) + return no_content() diff --git a/src/mailman/rest/validator.py b/src/mailman/rest/validator.py index 7484aa260..f6f0cd7e6 100644 --- a/src/mailman/rest/validator.py +++ b/src/mailman/rest/validator.py @@ -21,6 +21,7 @@ from __future__ import absolute_import, unicode_literals __metaclass__ = type __all__ = [ + 'PatchValidator', 'Validator', 'enum_validator', 'language_validator', @@ -31,6 +32,8 @@ __all__ = [ from uuid import UUID from zope.component import getUtility +from mailman.core.errors import ( + ReadOnlyPATCHRequestError, UnknownPATCHRequestError) from mailman.interfaces.languages import ILanguageManager @@ -119,3 +122,50 @@ class Validator: missing = COMMASPACE.join(sorted(required_keys - value_keys)) raise ValueError('Missing parameters: {0}'.format(missing)) return values + + def update(self, obj, request): + """Update the object with the values in the request. + + This first validates and converts the attributes in the request, then + updates the given object with the newly converted values. + + :param obj: The object to update. + :type obj: object + :param request: The HTTP request. + :raises ValueError: if conversion failed for some attribute. + """ + for key, value in self.__call__(request).items(): + self._converters[key].put(obj, key, value) + + + +class PatchValidator(Validator): + """Create a special validator for PATCH requests. + + PATCH is different than PUT because with the latter, you're changing the + entire resource, so all expected attributes must exist. With the former, + you're only changing a subset of the attributes, so you only validate the + ones that exist in the request. + """ + def __init__(self, request, converters): + """Create a validator for the PATCH request. + + :param request: The request object, which must have a .PATCH + attribute. + :param converters: A mapping of attribute names to the converter for + that attribute's type. Generally, this will be a GetterSetter + instance, but it might be something more specific for custom data + types (e.g. non-basic types like unicodes). + :raises UnknownPATCHRequestError: if the request contains an unknown + attribute, i.e. one that is not in the `attributes` mapping. + :raises ReadOnlyPATCHRequest: if the requests contains an attribute + that is defined as read-only. + """ + validationators = {} + for attribute in request.PATCH: + if attribute not in converters: + raise UnknownPATCHRequestError(attribute) + if converters[attribute].decoder is None: + raise ReadOnlyPATCHRequestError(attribute) + validationators[attribute] = converters[attribute] + super(PatchValidator, self).__init__(**validationators) |
