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)
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
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."""
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
33 class RESTError(MailmanError
):
34 """Base class for REST API errors."""
38 class UnknownPATCHRequestError(RESTError
):
39 """A PATCH request contained an unknown attribute."""
41 def __init__(self
, attribute
):
42 self
.attribute
= attribute
46 class ReadOnlyPATCHRequestError(RESTError
):
47 """A PATCH request contained a read-only attribute."""
49 def __init__(self
, attribute
):
50 self
.attribute
= attribute
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
:
67 return self
._enum
_class
[enum_value
]
68 except KeyError as exception
:
69 # Retain the error message.
70 raise ValueError(exception
.args
[0])
74 def subscriber_validator(api
):
75 """Convert an email-or-(int|hex) to an email-or-UUID."""
76 def _inner(subscriber
):
78 return api
.to_uuid(subscriber
)
80 # It must be an email address.
81 if getUtility(IEmailValidator
).is_valid(subscriber
):
88 def language_validator(code
):
89 """Convert a language code to a Language object."""
90 return getUtility(ILanguageManager
)[code
]
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
104 if not isinstance(values
, (list, tuple)):
107 if not isinstance(value
, str):
108 raise ValueError('Expected str, got {!r}'.format(value
))
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
):
118 raise ValueError('Bad email address format: {}'.format(values
))
120 if not getUtility(IEmailValidator
).is_valid(value
):
121 raise ValueError('Expected email address, got {!r}'.format(value
))
126 def integer_ge_zero_validator(value
):
127 """Validate that the value is a non-negative integer."""
130 raise ValueError('Expected a non-negative integer: {}'.format(value
))
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.
145 raise ValueError('Expected a valid regexp, got {}'.format(value
))
151 """A validator of parameter input."""
153 def __init__(self
, **kws
):
154 if '_optional' in kws
:
155 self
._optional
= set(kws
.pop('_optional'))
157 self
._optional
= set()
158 self
._converters
= kws
.copy()
160 def __call__(self
, request
):
163 cannot_convert
= set()
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.
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
)
177 form_data
[key
] = [old_value
, new_value
]
178 # Now do all the conversions.
179 for key
, value
in form_data
.items():
181 values
[key
] = self
._converters
[key
](value
)
184 except (TypeError, ValueError):
185 cannot_convert
.add(key
)
186 # Make sure there are no unexpected values.
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
))
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.
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
)
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
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.
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
)