1 # Copyright 2010 United States Government as represented by the
2 # Administrator of the National Aeronautics and Space Administration.
3 # Copyright 2010 OpenStack Foundation
4 # Copyright 2014 IBM Corp.
7 # Licensed under the Apache License, Version 2.0 (the "License"); you may
8 # not use this file except in compliance with the License. You may obtain
9 # a copy of the License at
11 # http://www.apache.org/licenses/LICENSE-2.0
13 # Unless required by applicable law or agreed to in writing, software
14 # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
15 # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
16 # License for the specific language governing permissions and limitations
20 Utility methods for working with WSGI servers
22 from __future__ import print_function
32 from eventlet.green import socket
33 from eventlet.green import ssl
34 import eventlet.greenio
36 from oslo_serialization import jsonutils
37 from oslo_concurrency import processutils
38 from oslo_config import cfg
39 from oslo_log import log as logging
40 from oslo_log import loggers
42 import routes.middleware
46 from webob import multidict
48 from escalator.common import exception
49 from escalator.common import utils
50 from escalator import i18n
59 cfg.StrOpt('bind_host', default='0.0.0.0',
60 help=_('Address to bind the server. Useful when '
61 'selecting a particular network interface.')),
62 cfg.IntOpt('bind_port',
63 help=_('The port on which the server will listen.')),
67 cfg.IntOpt('backlog', default=4096,
68 help=_('The backlog value that will be used when creating the '
69 'TCP listener socket.')),
70 cfg.IntOpt('tcp_keepidle', default=600,
71 help=_('The value for the socket option TCP_KEEPIDLE. This is '
72 'the time in seconds that the connection must be idle '
73 'before TCP starts sending keepalive probes.')),
74 cfg.StrOpt('ca_file', help=_('CA certificate file to use to verify '
75 'connecting clients.')),
76 cfg.StrOpt('cert_file', help=_('Certificate file to use when starting API '
78 cfg.StrOpt('key_file', help=_('Private key file to use when starting API '
83 cfg.IntOpt('workers', default=processutils.get_worker_count(),
84 help=_('The number of child process workers that will be '
85 'created to service requests. The default will be '
86 'equal to the number of CPUs available.')),
87 cfg.IntOpt('max_header_line', default=16384,
88 help=_('Maximum line size of message headers to be accepted. '
89 'max_header_line may need to be increased when using '
90 'large tokens (typically those generated by the '
91 'Keystone v3 API with big service catalogs')),
92 cfg.BoolOpt('http_keepalive', default=True,
93 help=_('If False, server will return the header '
94 '"Connection: close", '
95 'If True, server will return "Connection: Keep-Alive" '
96 'in its responses. In order to close the client socket '
97 'connection explicitly after the response is sent and '
98 'read successfully by the client, you simply have to '
99 'set this option to False when you create a wsgi '
104 cfg.BoolOpt("enabled", default=False,
105 help=_('If False fully disable profiling feature.')),
106 cfg.BoolOpt("trace_sqlalchemy", default=False,
107 help=_("If False doesn't trace SQL requests."))
111 LOG = logging.getLogger(__name__)
114 CONF.register_opts(bind_opts)
115 CONF.register_opts(socket_opts)
116 CONF.register_opts(eventlet_opts)
117 CONF.register_opts(profiler_opts, group="profiler")
119 ASYNC_EVENTLET_THREAD_POOL_LIST = []
122 def get_bind_addr(default_port=None):
123 """Return the host and port to bind to."""
124 return (CONF.bind_host, CONF.bind_port or default_port)
127 def ssl_wrap_socket(sock):
129 Wrap an existing socket in SSL
131 :param sock: non-SSL socket to wrap
133 :returns: An SSL wrapped socket
135 utils.validate_key_cert(CONF.key_file, CONF.cert_file)
139 'certfile': CONF.cert_file,
140 'keyfile': CONF.key_file,
141 'cert_reqs': ssl.CERT_NONE,
145 ssl_kwargs['ca_certs'] = CONF.ca_file
146 ssl_kwargs['cert_reqs'] = ssl.CERT_REQUIRED
148 return ssl.wrap_socket(sock, **ssl_kwargs)
151 def get_socket(default_port):
153 Bind socket to bind ip:port in conf
155 note: Mostly comes from Swift with a few small changes...
157 :param default_port: port to bind to if none is specified in conf
159 :returns : a socket object as returned from socket.listen or
160 ssl.wrap_socket if conf specifies cert_file
162 bind_addr = get_bind_addr(default_port)
164 # TODO(jaypipes): eventlet's greened socket module does not actually
165 # support IPv6 in getaddrinfo(). We need to get around this in the
166 # future or monitor upstream for a fix
168 addr[0] for addr in socket.getaddrinfo(bind_addr[0],
172 if addr[0] in (socket.AF_INET, socket.AF_INET6)
175 use_ssl = CONF.key_file or CONF.cert_file
176 if use_ssl and (not CONF.key_file or not CONF.cert_file):
177 raise RuntimeError(_("When running server in SSL mode, you must "
178 "specify both a cert_file and key_file "
179 "option value in your configuration file"))
181 sock = utils.get_test_suite_socket()
182 retry_until = time.time() + 30
184 while not sock and time.time() < retry_until:
186 sock = eventlet.listen(bind_addr,
187 backlog=CONF.backlog,
188 family=address_family)
189 except socket.error as err:
190 if err.args[0] != errno.EADDRINUSE:
194 raise RuntimeError(_("Could not bind to %(host)s:%(port)s after"
195 " trying for 30 seconds") %
196 {'host': bind_addr[0],
197 'port': bind_addr[1]})
202 def set_eventlet_hub():
204 eventlet.hubs.use_hub('poll')
207 eventlet.hubs.use_hub('selects')
209 msg = _("eventlet 'poll' nor 'selects' hubs are available "
211 raise exception.WorkerCreationFailure(
215 def get_asynchronous_eventlet_pool(size=1000):
216 """Return eventlet pool to caller.
218 Also store pools created in global list, to wait on
219 it after getting signal for graceful shutdown.
221 :param size: eventlet pool size
222 :returns: eventlet pool
224 global ASYNC_EVENTLET_THREAD_POOL_LIST
226 pool = eventlet.GreenPool(size=size)
227 # Add pool to global ASYNC_EVENTLET_THREAD_POOL_LIST
228 ASYNC_EVENTLET_THREAD_POOL_LIST.append(pool)
233 class Server(object):
234 """Server class to manage multiple WSGI sockets and applications.
236 This class requires initialize_escalator_store set to True if
237 escalator store needs to be initialized.
240 def __init__(self, threads=1000, initialize_escalator_store=False):
241 os.umask(0o27) # ensure files are created with the correct privileges
242 self._logger = logging.getLogger("eventlet.wsgi.server")
243 self._wsgi_logger = loggers.WritableLogger(self._logger)
244 self.threads = threads
245 self.children = set()
246 self.stale_children = set()
248 # NOTE(abhishek): Allows us to only re-initialize escalator_store when
249 # the API's configuration reloads.
250 self.initialize_escalator_store = initialize_escalator_store
251 self.pgid = os.getpid()
253 # NOTE(flaper87): Make sure this process
254 # runs in its own process group.
255 os.setpgid(self.pgid, self.pgid)
257 # NOTE(flaper87): When running escalator-control,
258 # (escalator's functional tests, for example)
259 # setpgid fails with EPERM as escalator-control
260 # creates a fresh session, of which the newly
261 # launched service becomes the leader (session
262 # leaders may not change process groups)
264 # Running escalator-(api) is safe and
265 # shouldn't raise any error here.
268 def hup(self, *args):
270 Reloads configuration files with zero down time
272 signal.signal(signal.SIGHUP, signal.SIG_IGN)
273 raise exception.SIGHUPInterrupt
275 def kill_children(self, *args):
276 """Kills the entire process group."""
277 signal.signal(signal.SIGTERM, signal.SIG_IGN)
278 signal.signal(signal.SIGINT, signal.SIG_IGN)
280 os.killpg(self.pgid, signal.SIGTERM)
282 def start(self, application, default_port):
284 Run a WSGI server with the given application.
286 :param application: The application to be run in the WSGI server
287 :param default_port: Port to bind to if none is specified in conf
289 self.application = application
290 self.default_port = default_port
294 def start_wsgi(self):
296 if CONF.workers == 0:
297 # Useful for profiling, test, debug etc.
298 self.pool = self.create_pool()
299 self.pool.spawn_n(self._single_run, self.application, self.sock)
302 LOG.info(_LI("Starting %d workers") % CONF.workers)
303 signal.signal(signal.SIGTERM, self.kill_children)
304 signal.signal(signal.SIGINT, self.kill_children)
305 signal.signal(signal.SIGHUP, self.hup)
306 while len(self.children) < CONF.workers:
309 def create_pool(self):
310 return eventlet.GreenPool(size=self.threads)
312 def _remove_children(self, pid):
313 if pid in self.children:
314 self.children.remove(pid)
315 LOG.info(_LI('Removed dead child %s') % pid)
316 elif pid in self.stale_children:
317 self.stale_children.remove(pid)
318 LOG.info(_LI('Removed stale child %s') % pid)
320 LOG.warn(_LW('Unrecognised child %s') % pid)
322 def _verify_and_respawn_children(self, pid, status):
323 if len(self.stale_children) == 0:
324 LOG.debug('No stale children')
325 if os.WIFEXITED(status) and os.WEXITSTATUS(status) != 0:
326 LOG.error(_LE('Not respawning child %d, cannot '
327 'recover from termination') % pid)
328 if not self.children and not self.stale_children:
330 _LI('All workers have terminated. Exiting'))
333 if len(self.children) < CONF.workers:
336 def wait_on_children(self):
339 pid, status = os.wait()
340 if os.WIFEXITED(status) or os.WIFSIGNALED(status):
341 self._remove_children(pid)
342 self._verify_and_respawn_children(pid, status)
343 except OSError as err:
344 if err.errno not in (errno.EINTR, errno.ECHILD):
346 except KeyboardInterrupt:
347 LOG.info(_LI('Caught keyboard interrupt. Exiting.'))
349 except exception.SIGHUPInterrupt:
352 eventlet.greenio.shutdown_safe(self.sock)
356 def configure(self, old_conf=None, has_changed=None):
358 Apply configuration settings
360 :param old_conf: Cached old configuration settings (if any)
361 :param has changed: callable to determine if a parameter has changed
363 eventlet.wsgi.MAX_HEADER_LINE = CONF.max_header_line
364 self.configure_socket(old_conf, has_changed)
365 if self.initialize_escalator_store:
366 initialize_escalator_store()
370 Reload and re-apply configuration settings
372 Existing child processes are sent a SIGHUP signal
373 and will exit after completing existing requests.
374 New child processes, which will have the updated
375 configuration, are spawned. This allows preventing
376 interruption to the service.
378 def _has_changed(old, new, param):
380 new = getattr(new, param)
383 old_conf = utils.stash_conf_values()
384 has_changed = functools.partial(_has_changed, old_conf, CONF)
385 CONF.reload_config_files()
386 os.killpg(self.pgid, signal.SIGHUP)
387 self.stale_children = self.children
388 self.children = set()
390 # Ensure any logging config changes are picked up
391 logging.setup(CONF, 'escalator')
393 self.configure(old_conf, has_changed)
397 """Wait until all servers have completed running."""
400 self.wait_on_children()
403 except KeyboardInterrupt:
407 def child_hup(*args):
408 """Shuts down child processes, existing requests are handled."""
409 signal.signal(signal.SIGHUP, signal.SIG_IGN)
410 eventlet.wsgi.is_accepting = False
415 signal.signal(signal.SIGHUP, child_hup)
416 signal.signal(signal.SIGTERM, signal.SIG_DFL)
417 # ignore the interrupt signal to avoid a race whereby
418 # a child worker receives the signal before the parent
419 # and is respawned unnecessarily as a result
420 signal.signal(signal.SIGINT, signal.SIG_IGN)
421 # The child has no need to stash the unwrapped
422 # socket, and the reference prevents a clean
426 LOG.info(_LI('Child %d exiting normally') % os.getpid())
427 # self.pool.waitall() is now called in wsgi's server so
428 # it's safe to exit here
431 LOG.info(_LI('Started child %s') % pid)
432 self.children.add(pid)
434 def run_server(self):
435 """Run a WSGI server."""
436 if cfg.CONF.pydev_worker_debug_host:
437 utils.setup_remote_pydev_debug(cfg.CONF.pydev_worker_debug_host,
438 cfg.CONF.pydev_worker_debug_port)
440 eventlet.wsgi.HttpProtocol.default_request_version = "HTTP/1.0"
441 self.pool = self.create_pool()
443 eventlet.wsgi.server(self.sock,
445 log=self._wsgi_logger,
446 custom_pool=self.pool,
448 keepalive=CONF.http_keepalive)
449 except socket.error as err:
450 if err[0] != errno.EINVAL:
453 # waiting on async pools
454 if ASYNC_EVENTLET_THREAD_POOL_LIST:
455 for pool in ASYNC_EVENTLET_THREAD_POOL_LIST:
458 def _single_run(self, application, sock):
459 """Start a WSGI server in a new green thread."""
460 LOG.info(_LI("Starting single process server"))
461 eventlet.wsgi.server(sock, application, custom_pool=self.pool,
462 log=self._wsgi_logger,
464 keepalive=CONF.http_keepalive)
466 def configure_socket(self, old_conf=None, has_changed=None):
468 Ensure a socket exists and is appropriately configured.
470 This function is called on start up, and can also be
471 called in the event of a configuration reload.
473 When called for the first time a new socket is created.
474 If reloading and either bind_host or bind port have been
475 changed the existing socket must be closed and a new
476 socket opened (laws of physics).
478 In all other cases (bind_host/bind_port have not changed)
479 the existing socket is reused.
481 :param old_conf: Cached old configuration settings (if any)
482 :param has changed: callable to determine if a parameter has changed
484 # Do we need a fresh socket?
485 new_sock = (old_conf is None or (
486 has_changed('bind_host') or
487 has_changed('bind_port')))
488 # Will we be using https?
489 use_ssl = not (not CONF.cert_file or not CONF.key_file)
490 # Were we using https before?
491 old_use_ssl = (old_conf is not None and not (
492 not old_conf.get('key_file') or
493 not old_conf.get('cert_file')))
494 # Do we now need to perform an SSL wrap on the socket?
495 wrap_sock = use_ssl is True and (old_use_ssl is False or new_sock)
496 # Do we now need to perform an SSL unwrap on the socket?
497 unwrap_sock = use_ssl is False and old_use_ssl is True
501 if old_conf is not None:
503 _sock = get_socket(self.default_port)
504 _sock.setsockopt(socket.SOL_SOCKET,
505 socket.SO_REUSEADDR, 1)
506 # sockets can hang around forever without keepalive
507 _sock.setsockopt(socket.SOL_SOCKET,
508 socket.SO_KEEPALIVE, 1)
512 self.sock = ssl_wrap_socket(self._sock)
515 self.sock = self._sock
517 if new_sock and not use_ssl:
518 self.sock = self._sock
520 # Pick up newly deployed certs
521 if old_conf is not None and use_ssl is True and old_use_ssl is True:
522 if has_changed('cert_file') or has_changed('key_file'):
523 utils.validate_key_cert(CONF.key_file, CONF.cert_file)
524 if has_changed('cert_file'):
525 self.sock.certfile = CONF.cert_file
526 if has_changed('key_file'):
527 self.sock.keyfile = CONF.key_file
529 if new_sock or (old_conf is not None and has_changed('tcp_keepidle')):
530 # This option isn't available in the OS X version of eventlet
531 if hasattr(socket, 'TCP_KEEPIDLE'):
532 self.sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE,
535 if old_conf is not None and has_changed('backlog'):
536 self.sock.listen(CONF.backlog)
539 class Middleware(object):
541 Base WSGI middleware wrapper. These classes require an application to be
542 initialized that will be called next. By default the middleware will
543 simply call its wrapped app, or you can override __call__ to customize its
547 def __init__(self, application):
548 self.application = application
551 def factory(cls, global_conf, **local_conf):
556 def process_request(self, req):
558 Called on each request.
560 If this returns None, the next application down the stack will be
561 executed. If it returns a response then that response will be returned
562 and execution will stop here.
567 def process_response(self, response):
568 """Do whatever you'd like to the response."""
572 def __call__(self, req):
573 response = self.process_request(req)
576 response = req.get_response(self.application)
577 response.request = req
579 return self.process_response(response)
580 except webob.exc.HTTPException as e:
584 class Debug(Middleware):
586 Helper class that can be inserted into any WSGI application chain
587 to get information about the request and response.
591 def __call__(self, req):
592 print(("*" * 40) + " REQUEST ENVIRON")
593 for key, value in req.environ.items():
594 print(key, "=", value)
596 resp = req.get_response(self.application)
598 print(("*" * 40) + " RESPONSE HEADERS")
599 for (key, value) in six.iteritems(resp.headers):
600 print(key, "=", value)
603 resp.app_iter = self.print_generator(resp.app_iter)
608 def print_generator(app_iter):
610 Iterator that prints the contents of a wrapper string iterator
613 print(("*" * 40) + " BODY")
614 for part in app_iter:
615 sys.stdout.write(part)
621 class APIMapper(routes.Mapper):
623 Handle route matching when url is '' because routes.Mapper returns
624 an error in this case.
627 def routematch(self, url=None, environ=None):
629 result = self._match("", environ)
630 return result[0], result[1]
631 return routes.Mapper.routematch(self, url, environ)
634 class RejectMethodController(object):
636 def reject(self, req, allowed_methods, *args, **kwargs):
637 LOG.debug("The method %s is not allowed for this resource" %
638 req.environ['REQUEST_METHOD'])
639 raise webob.exc.HTTPMethodNotAllowed(
640 headers=[('Allow', allowed_methods)])
643 class Router(object):
645 WSGI middleware that maps incoming requests to WSGI apps.
648 def __init__(self, mapper):
650 Create a router for the given routes.Mapper.
652 Each route in `mapper` must specify a 'controller', which is a
653 WSGI app to call. You'll probably want to specify an 'action' as
654 well and have your controller be a wsgi.Controller, who will route
655 the request to the action method.
658 mapper = routes.Mapper()
659 sc = ServerController()
661 # Explicit mapping of one route to a controller+action
662 mapper.connect(None, "/svrlist", controller=sc, action="list")
664 # Actions are all implicitly defined
665 mapper.resource("server", "servers", controller=sc)
667 # Pointing to an arbitrary WSGI app. You can specify the
668 # {path_info:.*} parameter so the target app can be handed just that
669 # section of the URL.
670 mapper.connect(None, "/v1.0/{path_info:.*}", controller=BlogApp())
672 mapper.redirect("", "/")
674 self._router = routes.middleware.RoutesMiddleware(self._dispatch,
678 def factory(cls, global_conf, **local_conf):
679 return cls(APIMapper())
682 def __call__(self, req):
684 Route the incoming request to a controller based on self.map.
685 If no match, return either a 404(Not Found) or 501(Not Implemented).
693 Called by self._router after matching the incoming request to a route
694 and putting the information into req.environ. Either returns 404,
695 501, or the routed WSGI app's response.
697 match = req.environ['wsgiorg.routing_args'][1]
699 implemented_http_methods = ['GET', 'HEAD', 'POST', 'PUT',
701 if req.environ['REQUEST_METHOD'] not in implemented_http_methods:
702 return webob.exc.HTTPNotImplemented()
704 return webob.exc.HTTPNotFound()
705 app = match['controller']
709 class Request(webob.Request):
710 """Add some OpenStack API-specific logic to the base webob.Request."""
712 def best_match_content_type(self):
713 """Determine the requested response content-type."""
714 supported = ('application/json',)
715 bm = self.accept.best_match(supported)
716 return bm or 'application/json'
718 def get_content_type(self, allowed_content_types):
719 """Determine content type of the request body."""
720 if "Content-Type" not in self.headers:
721 raise exception.InvalidContentType(content_type=None)
723 content_type = self.content_type
725 if content_type not in allowed_content_types:
726 raise exception.InvalidContentType(content_type=content_type)
730 def best_match_language(self):
731 """Determines best available locale from the Accept-Language header.
733 :returns: the best language match or None if the 'Accept-Language'
734 header was not available in the request.
736 if not self.accept_language:
738 langs = i18n.get_available_languages('escalator')
739 return self.accept_language.best_match(langs)
741 def get_content_range(self):
742 """Return the `Range` in a request."""
743 range_str = self.headers.get('Content-Range')
744 if range_str is not None:
745 range_ = webob.byterange.ContentRange.parse(range_str)
747 msg = _('Malformed Content-Range header: %s') % range_str
748 raise webob.exc.HTTPBadRequest(explanation=msg)
752 class JSONRequestDeserializer(object):
753 valid_transfer_encoding = frozenset(['chunked', 'compress', 'deflate',
756 def has_body(self, request):
758 Returns whether a Webob.Request object will possess an entity body.
760 :param request: Webob.Request object
762 request_encoding = request.headers.get('transfer-encoding', '').lower()
763 is_valid_encoding = request_encoding in self.valid_transfer_encoding
764 if is_valid_encoding and request.is_body_readable:
766 elif request.content_length > 0:
773 """Sanitizer method that will be passed to jsonutils.loads."""
776 def from_json(self, datastring):
778 return jsonutils.loads(datastring, object_hook=self._sanitizer)
780 msg = _('Malformed JSON in request body.')
781 raise webob.exc.HTTPBadRequest(explanation=msg)
783 def default(self, request):
784 if self.has_body(request):
785 return {'body': self.from_json(request.body)}
790 class JSONResponseSerializer(object):
792 def _sanitizer(self, obj):
793 """Sanitizer method that will be passed to jsonutils.dumps."""
794 if hasattr(obj, "to_dict"):
796 if isinstance(obj, multidict.MultiDict):
798 return jsonutils.to_primitive(obj)
800 def to_json(self, data):
801 return jsonutils.dumps(data, default=self._sanitizer)
803 def default(self, response, result):
804 response.content_type = 'application/json'
805 response.body = self.to_json(result)
808 def translate_exception(req, e):
809 """Translates all translatable elements of the given exception."""
811 # The RequestClass attribute in the webob.dec.wsgify decorator
812 # does not guarantee that the request object will be a particular
813 # type; this check is therefore necessary.
814 if not hasattr(req, "best_match_language"):
817 locale = req.best_match_language()
819 if isinstance(e, webob.exc.HTTPError):
820 e.explanation = i18n.translate(e.explanation, locale)
821 e.detail = i18n.translate(e.detail, locale)
822 if getattr(e, 'body_template', None):
823 e.body_template = i18n.translate(e.body_template, locale)
827 class Resource(object):
829 WSGI app that handles (de)serialization and controller dispatch.
831 Reads routing information supplied by RoutesMiddleware and calls
832 the requested action method upon its deserializer, controller,
833 and serializer. Those three objects may implement any of the basic
834 controller action methods (create, update, show, index, delete)
835 along with any that may be specified in the api router. A 'default'
836 method may also be implemented to be used in place of any
837 non-implemented actions. Deserializer methods must accept a request
838 argument and return a dictionary. Controller methods must accept a
839 request argument. Additionally, they must also accept keyword
840 arguments that represent the keys returned by the Deserializer. They
841 may raise a webob.exc exception or return a dict, which will be
842 serialized by requested content type.
845 def __init__(self, controller, deserializer=None, serializer=None):
847 :param controller: object that implement methods created by routes lib
848 :param deserializer: object that supports webob request deserialization
849 through controller-like actions
850 :param serializer: object that supports webob response serialization
851 through controller-like actions
853 self.controller = controller
854 self.serializer = serializer or JSONResponseSerializer()
855 self.deserializer = deserializer or JSONRequestDeserializer()
857 @webob.dec.wsgify(RequestClass=Request)
858 def __call__(self, request):
859 """WSGI method that controls (de)serialization and method dispatch."""
860 action_args = self.get_action_args(request.environ)
861 action = action_args.pop('action', None)
864 deserialized_request = self.dispatch(self.deserializer,
866 action_args.update(deserialized_request)
867 action_result = self.dispatch(self.controller, action,
868 request, **action_args)
869 except webob.exc.WSGIHTTPException as e:
870 exc_info = sys.exc_info()
871 raise translate_exception(request, e), None, exc_info[2]
874 response = webob.Response(request=request)
875 self.dispatch(self.serializer, action, response, action_result)
877 except webob.exc.WSGIHTTPException as e:
878 return translate_exception(request, e)
879 except webob.exc.HTTPException as e:
881 # return unserializable result (typically a webob exc)
885 def dispatch(self, obj, action, *args, **kwargs):
886 """Find action-specific method on self and call it."""
888 method = getattr(obj, action)
889 except AttributeError:
890 method = getattr(obj, 'default')
892 return method(*args, **kwargs)
894 def get_action_args(self, request_environment):
895 """Parse dictionary created by routes library."""
897 args = request_environment['wsgiorg.routing_args'][1].copy()
902 del args['controller']