Update WSGI functions to take necessary arguments
[mailman.git] / src / mailman / rest / wsgiapp.py
blob70d756405c87a25196edbbd2e08ec5abb07f9acb
1 # Copyright (C) 2010-2017 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 """Basic WSGI Application object for REST server."""
20 import re
21 import logging
23 from base64 import b64decode
24 from falcon import API, HTTPUnauthorized
25 from falcon.routing import create_http_method_map
26 from mailman.config import config
27 from mailman.database.transaction import transactional
28 from mailman.rest.root import Root
29 from public import public
30 from wsgiref.simple_server import (
31 WSGIRequestHandler, WSGIServer, make_server as wsgi_server)
34 log = logging.getLogger('mailman.http')
36 MISSING = object()
37 SLASH = '/'
38 EMPTYSTRING = ''
39 REALM = 'mailman3-rest'
42 class AdminWSGIServer(WSGIServer):
43 """Server class that integrates error handling with our log files."""
45 def handle_error(self, request, client_address):
46 # Interpose base class method so that the exception gets printed to
47 # our log file rather than stderr.
48 log.exception('REST server exception during request from %s',
49 client_address)
52 class StderrLogger:
53 def __init__(self):
54 self._buffer = []
56 def write(self, message):
57 self._buffer.append(message)
59 def flush(self):
60 self._buffer.insert(0, 'REST request handler error:\n')
61 log.error(EMPTYSTRING.join(self._buffer))
62 self._buffer = []
65 class AdminWebServiceWSGIRequestHandler(WSGIRequestHandler):
66 """Handler class which just logs output to the right place."""
68 default_request_version = 'HTTP/1.1'
70 def log_message(self, format, *args):
71 """See `BaseHTTPRequestHandler`."""
72 log.info('%s - - %s', self.address_string(), format % args)
74 def get_stderr(self):
75 # Return a fake stderr object that will actually write its output to
76 # the log file.
77 return StderrLogger()
80 class Middleware:
81 """Falcon middleware object for Mailman's REST API.
83 This does two things. It sets the API version on the resource
84 object, and it verifies that the proper authentication has been
85 performed.
86 """
87 def process_resource(self, request, response, resource, params):
88 # Check the authorization credentials.
89 authorized = False
90 if request.auth is not None and request.auth.startswith('Basic '):
91 # b64decode() returns bytes, but we require a str.
92 credentials = b64decode(request.auth[6:]).decode('utf-8')
93 username, password = credentials.split(':', 1)
94 if (username == config.webservice.admin_user and
95 password == config.webservice.admin_pass):
96 authorized = True
97 if not authorized:
98 # Not authorized.
99 raise HTTPUnauthorized(
100 '401 Unauthorized',
101 'REST API authorization failed',
102 challenges=['Basic realm=Mailman3'])
105 class ObjectRouter:
106 def __init__(self, root):
107 self._root = root
109 def add_route(self, uri_template, method_map, resource):
110 # We don't need this method for object-based routing.
111 raise NotImplementedError
113 def find(self, uri):
114 segments = uri.split(SLASH)
115 # Since the path is always rooted at /, skip the first segment, which
116 # will always be the empty string.
117 segments.pop(0)
118 this_segment = segments.pop(0)
119 resource = self._root
120 context = {}
121 while True:
122 # Plumb the API through to all child resources.
123 api = getattr(resource, 'api', None)
124 # See if any of the resource's child links match the next segment.
125 for name in dir(resource):
126 if name.startswith('__') and name.endswith('__'):
127 continue
128 attribute = getattr(resource, name, MISSING)
129 assert attribute is not MISSING, name
130 matcher = getattr(attribute, '__matcher__', MISSING)
131 if matcher is MISSING:
132 continue
133 result = None
134 if isinstance(matcher, str):
135 # Is the matcher string a regular expression or plain
136 # string? If it starts with a caret, it's a regexp.
137 if matcher.startswith('^'):
138 cre = re.compile(matcher)
139 # Search against the entire remaining path.
140 tmp_segments = segments[:]
141 tmp_segments.insert(0, this_segment)
142 remaining_path = SLASH.join(tmp_segments)
143 mo = cre.match(remaining_path)
144 if mo:
145 result = attribute(
146 context, segments, **mo.groupdict())
147 elif matcher == this_segment:
148 result = attribute(context, segments)
149 else:
150 # The matcher is a callable. It returns None if it
151 # doesn't match, and if it does, it returns a 3-tuple
152 # containing the positional arguments, the keyword
153 # arguments, and the remaining segments. The attribute is
154 # then called with these arguments. Note that the matcher
155 # wants to see the full remaining path components, which
156 # includes the current hop.
157 tmp_segments = segments[:]
158 tmp_segments.insert(0, this_segment)
159 matcher_result = matcher(tmp_segments)
160 if matcher_result is not None:
161 positional, keyword, segments = matcher_result
162 result = attribute(
163 context, segments, *positional, **keyword)
164 # The attribute could return a 2-tuple giving the resource and
165 # remaining path segments, or it could just return the result.
166 # Of course, if the result is None, then the matcher did not
167 # match.
168 if result is None:
169 continue
170 elif isinstance(result, tuple):
171 resource, segments = result
172 else:
173 resource = result
174 # See if the context set an API and set it on the next
175 # resource in the chain, falling back to the parent resource's
176 # API if there is one.
177 resource.api = context.pop('api', api)
178 # The method could have truncated the remaining segments,
179 # meaning, it's consumed all the path segments, or this is the
180 # last path segment. In that case the resource we're left at
181 # is the responder.
182 if len(segments) == 0:
183 # We're at the end of the path, so the root must be the
184 # responder.
185 method_map = create_http_method_map(resource)
186 return resource, method_map, context
187 this_segment = segments.pop(0)
188 break
189 else:
190 # None of the attributes matched this path component, so the
191 # response is a 404.
192 return None, None, None
195 class RootedAPI(API):
196 def __init__(self, root, *args, **kws):
197 super().__init__(
198 *args,
199 middleware=Middleware(),
200 router=ObjectRouter(root),
201 **kws)
202 # Let Falcon parse the form data into the request object's
203 # .params attribute.
204 self.req_options.auto_parse_form_urlencoded = True
205 # Don't ignore empty query parameters, e.g. preserve empty string
206 # values, which some resources will interpret as a DELETE.
207 self.req_options.keep_blank_qs_values = True
209 # Override the base class implementation to wrap a transactional
210 # handler around the call, so that the current transaction is
211 # committed if no errors occur, and aborted otherwise.
212 @transactional
213 def __call__(self, environ, start_response):
214 return super().__call__(environ, start_response)
217 @public
218 def make_application():
219 """Return a callable WSGI application object."""
220 return RootedAPI(Root())
223 @public
224 def make_server():
225 """Create the Mailman REST server.
227 Use this if you just want to run Mailman's wsgiref-based REST server.
229 host = config.webservice.hostname
230 port = int(config.webservice.port)
231 server = wsgi_server(
232 host, port, make_application(),
233 server_class=AdminWSGIServer,
234 handler_class=AdminWebServiceWSGIRequestHandler)
235 return server