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)
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."""
43 from datetime
import datetime
, timedelta
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
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.
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
,
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.
89 return super().default(obj
)
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.
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.
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
188 def path_to(self
, resource
):
189 return path_to(resource
, self
.api_version
)
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.
222 :param attribute: The attribute name.
223 :type attribute: string
224 :return: The attribute value, ready for JSON encoding.
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.
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.
245 :return: The converted value.
248 if self
.decoder
is None:
250 return self
.decoder(value
)
254 # Falcon REST framework add-ons.
256 def child(matcher
=None):
259 func
.__matcher
__ = func
.__name
__
261 func
.__matcher
__ = matcher
267 def __init__(self
, status
):
268 self
._status
= status
270 def _oops(self
, request
, response
):
271 raise falcon
.HTTPError(self
._status
, None)
280 class BadRequest(ChildError
):
282 super(BadRequest
, self
).__init
__(falcon
.HTTP_400
)
285 class NotFound(ChildError
):
287 super(NotFound
, self
).__init
__(falcon
.HTTP_404
)
290 def okay(response
, body
=None):
291 response
.status
= falcon
.HTTP_200
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
306 def accepted(response
, body
=None):
307 response
.status
= falcon
.HTTP_202
312 def bad_request(response
, body
='400 Bad Request'):
313 response
.status
= falcon
.HTTP_400
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
329 def forbidden(response
, body
=b
'403 Forbidden'):
330 response
.status
= falcon
.HTTP_403