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 """Web service helpers."""
24 from contextlib
import suppress
25 from datetime
import datetime
, timedelta
26 from email
.header
import Header
27 from email
.message
import Message
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.
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
):
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
):
65 for encoding
in ('ascii', 'utf-8', 'raw_unicode_escape'):
66 with
suppress(UnicodeDecodeError):
67 return value
.decode(encoding
)
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.
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
))
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.
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
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.
198 :param attribute: The attribute name.
199 :type attribute: string
200 :return: The attribute value, ready for JSON encoding.
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.
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.
221 :return: The converted value.
224 if self
.decoder
is None:
226 return self
.decoder(value
)
229 # Falcon REST framework add-ons.
232 def child(matcher
=None):
235 func
.__matcher
__ = func
.__name
__
237 func
.__matcher
__ = matcher
244 def __init__(self
, status
):
245 self
._status
= status
247 def _oops(self
, request
, response
):
248 raise falcon
.HTTPError(self
._status
, None)
258 class BadRequest(ChildError
):
260 super().__init
__(falcon
.HTTP_400
)
264 class NotFound(ChildError
):
266 super().__init
__(falcon
.HTTP_404
)
270 def okay(response
, body
=None):
271 response
.status
= falcon
.HTTP_200
277 def no_content(response
):
278 response
.status
= falcon
.HTTP_204
282 def not_found(response
, body
=b
'404 Not Found'):
283 response
.status
= falcon
.HTTP_404
289 def accepted(response
, body
=None):
290 response
.status
= falcon
.HTTP_202
296 def bad_request(response
, body
=b
'400 Bad Request'):
297 response
.status
= falcon
.HTTP_400
303 def created(response
, location
):
304 response
.status
= falcon
.HTTP_201
305 response
.location
= location
309 def conflict(response
, body
=b
'409 Conflict'):
310 response
.status
= falcon
.HTTP_409
316 def forbidden(response
, body
=b
'403 Forbidden'):
317 response
.status
= falcon
.HTTP_403