Add per list member roster visibility option
[mailman.git] / src / mailman / rest / helpers.py
blobdb09d1d72108f6d8d513f6cf313a6c7daa111fc0
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 """Web service helpers."""
20 import json
21 import falcon
22 import hashlib
24 from contextlib import suppress
25 from datetime import datetime, timedelta
26 from email.header import Header
27 from email.message import Message
28 from enum import Enum
29 from lazr.config import as_boolean
30 from mailman.config import config
31 from pprint import pformat
32 from public import public
35 class ExtendedEncoder(json.JSONEncoder):
36 """An extended JSON encoder which knows about other data types."""
38 def default(self, obj):
39 if isinstance(obj, datetime):
40 return obj.isoformat()
41 elif isinstance(obj, timedelta):
42 # as_timedelta() does not recognize microseconds, so convert these
43 # to floating seconds, but only if there are any seconds.
44 if obj.seconds > 0 or obj.microseconds > 0:
45 seconds = obj.seconds + obj.microseconds / 1000000.0
46 return '{}d{}s'.format(obj.days, seconds)
47 return '{}d'.format(obj.days)
48 elif isinstance(obj, Enum):
49 # It's up to the decoding validator to associate this name with
50 # the right Enum class.
51 return obj.name
52 elif isinstance(obj, bytes):
53 return bytes_to_str(obj)
54 elif isinstance(obj, Message):
55 return obj.as_string()
56 elif isinstance(obj, Header):
57 return str(obj)
58 return super().default(obj)
61 def bytes_to_str(value):
62 # Convert a string to unicode when the encoding is not declared.
63 if not isinstance(value, bytes):
64 return value
65 for encoding in ('ascii', 'utf-8', 'raw_unicode_escape'):
66 with suppress(UnicodeDecodeError):
67 return value.decode(encoding)
70 @public
71 def etag(resource):
72 """Calculate the etag and return a JSON representation.
74 The input is a dictionary representing the resource. This
75 dictionary must not contain an `http_etag` key. This function
76 calculates the etag by using the sha1 hexdigest of the
77 pretty-printed (and thus key-sorted and predictable) representation
78 of the dictionary. It then inserts this value under the `http_etag`
79 key, and returns the JSON representation of the modified dictionary.
81 :param resource: The original resource representation.
82 :type resource: dictionary
83 :return: JSON representation of the modified dictionary.
84 :rtype string
85 """
86 assert 'http_etag' not in resource, 'Resource already etagged'
87 # Calculate the tag from a predictable (i.e. sorted) representation of the
88 # dictionary. The actual details aren't so important. pformat() is
89 # guaranteed to sort the keys, however it returns a str and the hash
90 # library requires a bytes. Use the safest possible encoding.
91 hashfood = pformat(resource).encode('raw-unicode-escape')
92 etag = hashlib.sha1(hashfood).hexdigest()
93 resource['http_etag'] = '"{}"'.format(etag)
94 return json.dumps(resource, cls=ExtendedEncoder,
95 sort_keys=as_boolean(config.devmode.enabled))
98 @public
99 class CollectionMixin:
100 """Mixin class for common collection-ish things."""
102 def _resource_as_dict(self, resource):
103 """Return the dictionary representation of a resource.
105 This must be implemented by subclasses.
107 :param resource: The resource object.
108 :type resource: object
109 :return: The representation of the resource.
110 :rtype: dict
112 raise NotImplementedError
114 def _resource_as_json(self, resource):
115 """Return the JSON formatted representation of the resource."""
116 resource = self._resource_as_dict(resource)
117 assert resource is not None, resource
118 return etag(resource)
120 def _get_collection(self, request):
121 """Return the collection as a sequence.
123 The returned value must support the collections.abc.Sequence
124 API. This method must be implemented by subclasses.
126 :param request: An http request.
127 :return: The collection
128 :rtype: collections.abc.Sequence
130 raise NotImplementedError
132 def _paginate(self, request, collection):
133 """Method to paginate through collection result lists.
135 Use this to return only a slice of a collection, specified in
136 the request itself. The request should use query parameters
137 `count` and `page` to specify the slice they want. The slice
138 will start at index ``(page - 1) * count`` and end (exclusive)
139 at ``(page * count)``.
141 # Allow falcon's HTTPBadRequest exceptions to percolate up. They'll
142 # get turned into HTTP 400 errors.
143 count = request.get_param_as_int('count', min=0)
144 page = request.get_param_as_int('page', min=1)
145 total_size = len(collection)
146 if count is None and page is None:
147 return 0, total_size, collection
148 list_start = (page - 1) * count
149 list_end = page * count
150 return list_start, total_size, collection[list_start:list_end]
152 def _make_collection(self, request):
153 """Provide the collection to the REST layer."""
154 start, total_size, collection = self._paginate(
155 request, self._get_collection(request))
156 result = dict(start=start, total_size=total_size)
157 if len(collection) != 0:
158 entries = [self._resource_as_dict(resource)
159 for resource in collection]
160 assert None not in entries, entries
161 # Tag the resources but use the dictionaries.
162 [etag(resource) for resource in entries]
163 # Create the collection resource
164 result['entries'] = entries
165 return result
168 @public
169 class GetterSetter:
170 """Get and set attributes on an object.
172 Most attributes are fairly simple - a getattr() or setattr() on the object
173 does the trick, with the appropriate encoding or decoding on the way in
174 and out. Encoding doesn't happen here though; the standard JSON library
175 handles most types, but see ExtendedEncoder for additional support.
177 Others are more complicated since they aren't kept in the model as direct
178 columns in the database. These will use subclasses of this base class.
179 Read-only attributes will have a decoder which always raises ValueError.
182 def __init__(self, decoder=None):
183 """Create a getter/setter for a specific attribute.
185 :param decoder: The callable for decoding a web request value string
186 into the specific data type needed by the object's attribute. Use
187 None to indicate a read-only attribute. The callable should raise
188 ValueError when the web request value cannot be converted.
189 :type decoder: callable
191 self.decoder = decoder
193 def get(self, obj, attribute):
194 """Return the named object attribute value.
196 :param obj: The object to access.
197 :type obj: object
198 :param attribute: The attribute name.
199 :type attribute: string
200 :return: The attribute value, ready for JSON encoding.
201 :rtype: object
203 return getattr(obj, attribute)
205 def put(self, obj, attribute, value):
206 """Set the named object attribute value.
208 :param obj: The object to change.
209 :type obj: object
210 :param attribute: The attribute name.
211 :type attribute: string
212 :param value: The new value for the attribute.
214 setattr(obj, attribute, value)
216 def __call__(self, value):
217 """Convert the value to its internal format.
219 :param value: The web request value to convert.
220 :type value: string
221 :return: The converted value.
222 :rtype: object
224 if self.decoder is None:
225 return value
226 return self.decoder(value)
229 # Falcon REST framework add-ons.
231 @public
232 def child(matcher=None):
233 def decorator(func):
234 if matcher is None:
235 func.__matcher__ = func.__name__
236 else:
237 func.__matcher__ = matcher
238 return func
239 return decorator
242 @public
243 class ChildError:
244 def __init__(self, status):
245 self._status = status
247 def _oops(self, request, response):
248 raise falcon.HTTPError(self._status, None)
250 on_get = _oops
251 on_post = _oops
252 on_put = _oops
253 on_patch = _oops
254 on_delete = _oops
257 @public
258 class BadRequest(ChildError):
259 def __init__(self):
260 super().__init__(falcon.HTTP_400)
263 @public
264 class NotFound(ChildError):
265 def __init__(self):
266 super().__init__(falcon.HTTP_404)
269 @public
270 def okay(response, body=None):
271 response.status = falcon.HTTP_200
272 if body is not None:
273 response.body = body
276 @public
277 def no_content(response):
278 response.status = falcon.HTTP_204
281 @public
282 def not_found(response, body=b'404 Not Found'):
283 response.status = falcon.HTTP_404
284 if body is not None:
285 response.body = body
288 @public
289 def accepted(response, body=None):
290 response.status = falcon.HTTP_202
291 if body is not None:
292 response.body = body
295 @public
296 def bad_request(response, body=b'400 Bad Request'):
297 response.status = falcon.HTTP_400
298 if body is not None:
299 response.body = body
302 @public
303 def created(response, location):
304 response.status = falcon.HTTP_201
305 response.location = location
308 @public
309 def conflict(response, body=b'409 Conflict'):
310 response.status = falcon.HTTP_409
311 if body is not None:
312 response.body = body
315 @public
316 def forbidden(response, body=b'403 Forbidden'):
317 response.status = falcon.HTTP_403
318 if body is not None:
319 response.body = body