Additional refactoring to use the QuerySequence wrapper, so that we can still
[mailman.git] / src / mailman / rest / helpers.py
blob6a1408988c3264fa25a664948a9850be126cc136
1 # Copyright (C) 2010-2016 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 __all__ = [
21 'BadRequest',
22 'ChildError',
23 'CollectionMixin',
24 'GetterSetter',
25 'NotFound',
26 'bad_request',
27 'child',
28 'conflict',
29 'created',
30 'etag',
31 'forbidden',
32 'no_content',
33 'not_found',
34 'okay',
35 'path_to',
39 import json
40 import falcon
41 import hashlib
43 from datetime import datetime, timedelta
44 from enum import Enum
45 from lazr.config import as_boolean
46 from mailman.config import config
47 from pprint import pformat
51 def path_to(resource, api_version):
52 """Return the url path to a resource.
54 :param resource: The canonical path to the resource, relative to the
55 system base URI.
56 :type resource: string
57 :param api_version: API version to report.
58 :type api_version: string
59 :return: The full path to the resource.
60 :rtype: bytes
61 """
62 return '{0}://{1}:{2}/{3}/{4}'.format(
63 ('https' if as_boolean(config.webservice.use_https) else 'http'),
64 config.webservice.hostname,
65 config.webservice.port,
66 api_version,
67 (resource[1:] if resource.startswith('/') else resource),
72 class ExtendedEncoder(json.JSONEncoder):
73 """An extended JSON encoder which knows about other data types."""
75 def default(self, obj):
76 if isinstance(obj, datetime):
77 return obj.isoformat()
78 elif isinstance(obj, timedelta):
79 # as_timedelta() does not recognize microseconds, so convert these
80 # to floating seconds, but only if there are any seconds.
81 if obj.seconds > 0 or obj.microseconds > 0:
82 seconds = obj.seconds + obj.microseconds / 1000000.0
83 return '{0}d{1}s'.format(obj.days, seconds)
84 return '{0}d'.format(obj.days)
85 elif isinstance(obj, Enum):
86 # It's up to the decoding validator to associate this name with
87 # the right Enum class.
88 return obj.name
89 return super().default(obj)
92 def etag(resource):
93 """Calculate the etag and return a JSON representation.
95 The input is a dictionary representing the resource. This
96 dictionary must not contain an `http_etag` key. This function
97 calculates the etag by using the sha1 hexdigest of the
98 pretty-printed (and thus key-sorted and predictable) representation
99 of the dictionary. It then inserts this value under the `http_etag`
100 key, and returns the JSON representation of the modified dictionary.
102 :param resource: The original resource representation.
103 :type resource: dictionary
104 :return: JSON representation of the modified dictionary.
105 :rtype string
107 assert 'http_etag' not in resource, 'Resource already etagged'
108 # Calculate the tag from a predictable (i.e. sorted) representation of the
109 # dictionary. The actual details aren't so important. pformat() is
110 # guaranteed to sort the keys, however it returns a str and the hash
111 # library requires a bytes. Use the safest possible encoding.
112 hashfood = pformat(resource).encode('raw-unicode-escape')
113 etag = hashlib.sha1(hashfood).hexdigest()
114 resource['http_etag'] = '"{}"'.format(etag)
115 return json.dumps(resource, cls=ExtendedEncoder,
116 sort_keys=as_boolean(config.devmode.enabled))
120 class CollectionMixin:
121 """Mixin class for common collection-ish things."""
123 def _resource_as_dict(self, resource):
124 """Return the dictionary representation of a resource.
126 This must be implemented by subclasses.
128 :param resource: The resource object.
129 :type resource: object
130 :return: The representation of the resource.
131 :rtype: dict
133 raise NotImplementedError
135 def _resource_as_json(self, resource):
136 """Return the JSON formatted representation of the resource."""
137 resource = self._resource_as_dict(resource)
138 assert resource is not None, resource
139 return etag(resource)
141 def _get_collection(self, request):
142 """Return the collection as a sequence.
144 The returned value must support the collections.abc.Sequence
145 API. This method must be implemented by subclasses.
147 :param request: An http request.
148 :return: The collection
149 :rtype: collections.abc.Sequence
151 raise NotImplementedError
153 def _paginate(self, request, collection):
154 """Method to paginate through collection result lists.
156 Use this to return only a slice of a collection, specified in
157 the request itself. The request should use query parameters
158 `count` and `page` to specify the slice they want. The slice
159 will start at index ``(page - 1) * count`` and end (exclusive)
160 at ``(page * count)``.
162 # Allow falcon's HTTPBadRequest exceptions to percolate up. They'll
163 # get turned into HTTP 400 errors.
164 count = request.get_param_as_int('count', min=0)
165 page = request.get_param_as_int('page', min=1)
166 total_size = len(collection)
167 if count is None and page is None:
168 return 0, total_size, collection
169 list_start = (page - 1) * count
170 list_end = page * count
171 return list_start, total_size, collection[list_start:list_end]
173 def _make_collection(self, request):
174 """Provide the collection to the REST layer."""
175 start, total_size, collection = self._paginate(
176 request, self._get_collection(request))
177 result = dict(start=start, total_size=total_size)
178 if len(collection) != 0:
179 entries = [self._resource_as_dict(resource)
180 for resource in collection]
181 assert None not in entries, entries
182 # Tag the resources but use the dictionaries.
183 [etag(resource) for resource in entries]
184 # Create the collection resource
185 result['entries'] = entries
186 return result
188 def path_to(self, resource):
189 return path_to(resource, self.api_version)
193 class GetterSetter:
194 """Get and set attributes on an object.
196 Most attributes are fairly simple - a getattr() or setattr() on the object
197 does the trick, with the appropriate encoding or decoding on the way in
198 and out. Encoding doesn't happen here though; the standard JSON library
199 handles most types, but see ExtendedEncoder for additional support.
201 Others are more complicated since they aren't kept in the model as direct
202 columns in the database. These will use subclasses of this base class.
203 Read-only attributes will have a decoder which always raises ValueError.
206 def __init__(self, decoder=None):
207 """Create a getter/setter for a specific attribute.
209 :param decoder: The callable for decoding a web request value string
210 into the specific data type needed by the object's attribute. Use
211 None to indicate a read-only attribute. The callable should raise
212 ValueError when the web request value cannot be converted.
213 :type decoder: callable
215 self.decoder = decoder
217 def get(self, obj, attribute):
218 """Return the named object attribute value.
220 :param obj: The object to access.
221 :type obj: object
222 :param attribute: The attribute name.
223 :type attribute: string
224 :return: The attribute value, ready for JSON encoding.
225 :rtype: object
227 return getattr(obj, attribute)
229 def put(self, obj, attribute, value):
230 """Set the named object attribute value.
232 :param obj: The object to change.
233 :type obj: object
234 :param attribute: The attribute name.
235 :type attribute: string
236 :param value: The new value for the attribute.
238 setattr(obj, attribute, value)
240 def __call__(self, value):
241 """Convert the value to its internal format.
243 :param value: The web request value to convert.
244 :type value: string
245 :return: The converted value.
246 :rtype: object
248 if self.decoder is None:
249 return value
250 return self.decoder(value)
254 # Falcon REST framework add-ons.
256 def child(matcher=None):
257 def decorator(func):
258 if matcher is None:
259 func.__matcher__ = func.__name__
260 else:
261 func.__matcher__ = matcher
262 return func
263 return decorator
266 class ChildError:
267 def __init__(self, status):
268 self._status = status
270 def _oops(self, request, response):
271 raise falcon.HTTPError(self._status, None)
273 on_get = _oops
274 on_post = _oops
275 on_put = _oops
276 on_patch = _oops
277 on_delete = _oops
280 class BadRequest(ChildError):
281 def __init__(self):
282 super(BadRequest, self).__init__(falcon.HTTP_400)
285 class NotFound(ChildError):
286 def __init__(self):
287 super(NotFound, self).__init__(falcon.HTTP_404)
290 def okay(response, body=None):
291 response.status = falcon.HTTP_200
292 if body is not None:
293 response.body = body
296 def no_content(response):
297 response.status = falcon.HTTP_204
300 def not_found(response, body=b'404 Not Found'):
301 response.status = falcon.HTTP_404
302 if body is not None:
303 response.body = body
306 def accepted(response, body=None):
307 response.status = falcon.HTTP_202
308 if body is not None:
309 response.body = body
312 def bad_request(response, body='400 Bad Request'):
313 response.status = falcon.HTTP_400
314 if body is not None:
315 response.body = body
318 def created(response, location):
319 response.status = falcon.HTTP_201
320 response.location = location
323 def conflict(response, body=b'409 Conflict'):
324 response.status = falcon.HTTP_409
325 if body is not None:
326 response.body = body
329 def forbidden(response, body=b'403 Forbidden'):
330 response.status = falcon.HTTP_403
331 if body is not None:
332 response.body = body