Edgewall Software

Web Layer Refactoring: Controller API

Status Quo

The current web layer in Trac is quite low-level. It builds directly on the component architecture, and requires a “controller” to implement the IRequestHandler interface and expose two methods: match_request(req) and process_request(req). The former method is called by the request dispatcher to determine which controller should handle a particular request. The latter is then called on the controller that was chosen.

The following example defines a (heavily simplified) controller based on that API:

class ExampleModule(Component):
    implements(IRequestHandler)

    # IRequestHandler implementation

    def match_request(self, req):
        match = re.match(r'/example/(\w+)/?', req.path_info)
        if match:
            req.args['name'] = match.group(1)
            return True
        else:
            return False

    def process_request(self, req):
        action = req.args.get('action', 'view')
        if req.method == 'POST':
            if action == 'edit':
                self._do_save(self, req)
            elif action == 'delete':
                self._do_delete(self, req)
        else:
            if action == 'edit':
                self._render_form(self, req)
            elif action == 'delete':
                self._render_confirm(self, req)
            else:
                self._render_view(self, req)

        add_stylesheet(req, 'example.css')
        return ('example.cs', None)

    # Internal methods

    def _do_save(self, req):
        name = req.args.get('name')
        # process the form submission

    def _do_delete(self, req):
        name = req.args.get('name')
        # process the form submission

    def _render_confirm(self, req):
        name = req.args.get('name')
        req.hdf['title'] = 'Confirm deletion:'

    def _render_editor(self, req):
        name = req.args.get('name')
        req.hdf['title'] = 'Edit me:'

    def _render_view(self, req):
        name = req.args.get('name')
        req.hdf['title'] = 'An example: %s' % name

As shown in this example, pretty much every implementation of process_request() dispatches to one of several internal methods depending on the request method and the action parameter. Furthermore, the different match_request() methods always check the request path info against a regular expression, and maybe set parameters depend on some parts of that path (for example, “/ticket/123” results in the parameter “id” being set to “123”).

Either in the main process_request() function, or in the individual action handlers, a common task is to extract the required parameters from the request for further processing. The method then returns the template (and optionally the content type) to use for rendering the response.

Proposal: Controller Class

This proposal extracts these common patterns from the individual controllers, and introduces a base class called Controller that allows for more convenient handling of requests. That class extends Component and implements the IRequestHandler interface, so that a concrete controller can still directly extend other extension points, such as INavigationContributor.

When a controller is derived from the Controller base class, it should not need to implement match_request() or process_request(). Instead:

  • it may define a class attribute called url_pattern that provides the regular expression that is tested against the request path info, and
  • it can define a number of different functions for handling requests that are marked with the @Controller.action decorator

Consider the following example:

class ExampleController(Controller):
    stylesheets = ['example.css']

    @Controller.action(template='example_view.html')
    def _process_view(self, req, name, format=None):
        req.hdf['title'] = 'An example'

    @Controller.action('delete', template='example_confirm.html')
    def _process_delete(self, req, name):
        if req.method == 'POST':
            # process the form submission, redirect if all is well
        req.hdf['title'] = 'Confirm deletion:'

    @Controller.action('edit', template='example_form.html')
    def _process_edit(self, req, name, description=None):
        if req.method == 'POST':
            # process the form submission, redirect if all is well
        req.hdf['title'] = 'Edit me:'

This sets up three different methods for handling requests, each decorated with the @Controller.action decorator. That decorator takes the name of the action as the first parameter. When the base class processes a request, it extracts the value of the action parameter (which defaults to “view” if not provided), and looks for a method that declares to handle that action. The decorator also accepts the template file name and a list of stylesheet names, so that those don't need to be setup in each method body.

In addition, request parameters that the controller method declares as keyword arguments are automatically extracted from the request and passed as parameters (although always as string, so that sometimes the parameter value will still need to be casted).

One difference to the current code in Trac is that both GET and POST requests are handled by the same method. This will make it easier to implement user-friendly validation where a POST request with validity errors results in the form being redisplayed.

Conventions and Convenience Features

The example controller above does not provide an explicit url_pattern class attribute. In that case, the controller base class uses a generic pattern based on the name of the class, passing additional path segments as a positional argument. So the request dispatcher would respond to a request like:

GET /example/foo?format=rss HTTP/1.1

by invoking:

ExampleController(env)._process_view(req, 'foo', format='rss')

Also, the name of the template does not need to be explicitly specified if it follows the following convention:

[classname](_[actionname])?(_[format])?.cs

For example, for the ExampleController._process_edit action above, the default template name would be example_edit.cs.

For convenience, the Controller base class provides direct access to various chrome methods such as add_link() or add_stylesheet(). These can simply be invoked as instance methods and thus don't need to be explicitly imported.

ControllerMixin

While the Controller base class assumes that the controller is a top-level IRequestHandler, the action dispatching functionality is also available to other kinds of components, such as WebAdmin panels. To use this feature, a class should extend the ControllerMixin base class instead of Controller. In that case, no automatic matching against requests is made, but requests can be conveniently dispatched to actions by invoking the process_request() method.

Implementation Notes

The proposed API leaves the low-level IRequestHandler mechanism in place, but adds a convenience layer between the request dispatcher and the concrete controller components. This means that the change can be implemented incrementally, by converting each controller individually.

The code can be checked out from the sandbox/controller branch of the SubversionRepository.

Note that the branch currently requires Python 2.4 due to the use of decorators, but will be backported to 2.3 before/if it gets merged.


See also: TracDev/Proposals, TracDev

Last modified 9 years ago Last modified on Dec 4, 2008, 9:15:25 PM
Note: See TracWiki for help on using the wiki.