Add per list member roster visibility option
[mailman.git] / src / mailman / rest / validator.py
blobfcdc90ec21f98828ab2b9e08c22faf7ceaf50130
1 # Copyright (C) 2010-2019 by the Free Software Foundation, Inc.
3 # This file is part of GNU Mailman.
5 # GNU Mailman is free software: you can redistribute it and/or modify it under
6 # the terms of the GNU General Public License as published by the Free
7 # Software Foundation, either version 3 of the License, or (at your option)
8 # any later version.
10 # GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
11 # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
12 # FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
13 # more details.
15 # You should have received a copy of the GNU General Public License along with
16 # GNU Mailman. If not, see <http://www.gnu.org/licenses/>.
18 """REST web form validation."""
20 import re
22 from mailman.interfaces.address import IEmailValidator
23 from mailman.interfaces.errors import MailmanError
24 from mailman.interfaces.languages import ILanguageManager
25 from public import public
26 from zope.component import getUtility
29 COMMASPACE = ', '
32 @public
33 class RESTError(MailmanError):
34 """Base class for REST API errors."""
37 @public
38 class UnknownPATCHRequestError(RESTError):
39 """A PATCH request contained an unknown attribute."""
41 def __init__(self, attribute):
42 self.attribute = attribute
45 @public
46 class ReadOnlyPATCHRequestError(RESTError):
47 """A PATCH request contained a read-only attribute."""
49 def __init__(self, attribute):
50 self.attribute = attribute
53 @public
54 class enum_validator:
55 """Convert an enum value name into an enum value."""
57 def __init__(self, enum_class, *, allow_blank=False):
58 self._enum_class = enum_class
59 self._allow_blank = allow_blank
61 def __call__(self, enum_value):
62 # This will raise a KeyError if the enum value is unknown. The
63 # Validator API requires turning this into a ValueError.
64 if not enum_value and self._allow_blank:
65 return None
66 try:
67 return self._enum_class[enum_value]
68 except KeyError as exception:
69 # Retain the error message.
70 raise ValueError(exception.args[0])
73 @public
74 def subscriber_validator(api):
75 """Convert an email-or-(int|hex) to an email-or-UUID."""
76 def _inner(subscriber):
77 try:
78 return api.to_uuid(subscriber)
79 except ValueError:
80 # It must be an email address.
81 if getUtility(IEmailValidator).is_valid(subscriber):
82 return subscriber
83 raise ValueError
84 return _inner
87 @public
88 def language_validator(code):
89 """Convert a language code to a Language object."""
90 return getUtility(ILanguageManager)[code]
93 @public
94 def list_of_strings_validator(values):
95 """Turn a list of things, or a single thing, into a list of unicodes."""
96 # There is no good way to pass around an empty list through HTTP API, so,
97 # we consider an empty string as an empty list, which can easily be passed
98 # around. This is a contract between Core and Postorius. This also fixes a
99 # bug where an empty string ('') would be interpreted as a valid value ['']
100 # to create a singleton list, instead of empty list, which in later stages
101 # would create other problems.
102 if values is '': # noqa: F632
103 return []
104 if not isinstance(values, (list, tuple)):
105 values = [values]
106 for value in values:
107 if not isinstance(value, str):
108 raise ValueError('Expected str, got {!r}'.format(value))
109 return values
112 @public
113 def list_of_emails_validator(values):
114 """Turn a list of things, or a single thing, into a list of emails."""
115 if not isinstance(values, (list, tuple)):
116 if getUtility(IEmailValidator).is_valid(values):
117 return [values]
118 raise ValueError('Bad email address format: {}'.format(values))
119 for value in values:
120 if not getUtility(IEmailValidator).is_valid(value):
121 raise ValueError('Expected email address, got {!r}'.format(value))
122 return values
125 @public
126 def integer_ge_zero_validator(value):
127 """Validate that the value is a non-negative integer."""
128 value = int(value)
129 if value < 0:
130 raise ValueError('Expected a non-negative integer: {}'.format(value))
131 return value
134 @public
135 def regexp_validator(value): # pragma: missed
136 """Validate that the value is a valid regexp."""
137 # This code is covered as proven by the fact that the tests
138 # test_add_bad_regexp and test_patch_bad_regexp in
139 # mailman/rest/tests/test_header_matches.py fail with AssertionError:
140 # HTTPError not raised if the code is bypassed, but coverage says it's
141 # not covered so work around it for now.
142 try:
143 re.compile(value)
144 except re.error:
145 raise ValueError('Expected a valid regexp, got {}'.format(value))
146 return value
149 @public
150 class Validator:
151 """A validator of parameter input."""
153 def __init__(self, **kws):
154 if '_optional' in kws:
155 self._optional = set(kws.pop('_optional'))
156 else:
157 self._optional = set()
158 self._converters = kws.copy()
160 def __call__(self, request):
161 values = {}
162 extras = set()
163 cannot_convert = set()
164 form_data = {}
165 # All keys which show up only once in the form data get a scalar value
166 # in the pre-converted dictionary. All keys which show up more than
167 # once get a list value.
168 missing = object()
169 items = request.params.items()
170 for key, new_value in items:
171 old_value = form_data.get(key, missing)
172 if old_value is missing:
173 form_data[key] = new_value
174 elif isinstance(old_value, list):
175 old_value.append(new_value)
176 else:
177 form_data[key] = [old_value, new_value]
178 # Now do all the conversions.
179 for key, value in form_data.items():
180 try:
181 values[key] = self._converters[key](value)
182 except KeyError:
183 extras.add(key)
184 except (TypeError, ValueError):
185 cannot_convert.add(key)
186 # Make sure there are no unexpected values.
187 if len(extras) != 0:
188 extras = COMMASPACE.join(sorted(extras))
189 raise ValueError('Unexpected parameters: {}'.format(extras))
190 # Make sure everything could be converted.
191 if len(cannot_convert) != 0:
192 bad = COMMASPACE.join(sorted(cannot_convert))
193 raise ValueError('Cannot convert parameters: {}'.format(bad))
194 # Make sure nothing's missing.
195 value_keys = set(values)
196 required_keys = set(self._converters) - self._optional
197 if value_keys & required_keys != required_keys:
198 missing = COMMASPACE.join(sorted(required_keys - value_keys))
199 raise ValueError('Missing parameters: {}'.format(missing))
200 return values
202 def update(self, obj, request):
203 """Update the object with the values in the request.
205 This first validates and converts the attributes in the request, then
206 updates the given object with the newly converted values.
208 :param obj: The object to update.
209 :type obj: object
210 :param request: The HTTP request.
211 :raises ValueError: if conversion failed for some attribute, including
212 if the API version mismatches.
214 for key, value in self.__call__(request).items():
215 self._converters[key].put(obj, key, value)
218 @public
219 class PatchValidator(Validator):
220 """Create a special validator for PATCH requests.
222 PATCH is different than PUT because with the latter, you're changing the
223 entire resource, so all expected attributes must exist. With the former,
224 you're only changing a subset of the attributes, so you only validate the
225 ones that exist in the request.
227 def __init__(self, request, converters):
228 """Create a validator for the PATCH request.
230 :param request: The request object, which must have a .PATCH
231 attribute.
232 :param converters: A mapping of attribute names to the converter for
233 that attribute's type. Generally, this will be a GetterSetter
234 instance, but it might be something more specific for custom data
235 types (e.g. non-basic types like unicodes).
236 :raises UnknownPATCHRequestError: if the request contains an unknown
237 attribute, i.e. one that is not in the `attributes` mapping.
238 :raises ReadOnlyPATCHRequest: if the requests contains an attribute
239 that is defined as read-only.
241 validationators = {}
242 for attribute in request.params:
243 if attribute not in converters:
244 raise UnknownPATCHRequestError(attribute)
245 if converters[attribute].decoder is None:
246 raise ReadOnlyPATCHRequestError(attribute)
247 validationators[attribute] = converters[attribute]
248 super().__init__(**validationators)