Wiki Example

author:Ian Bicking <ianb@colorstudy.com>

Introduction

This is an example of how to write a WSGI application using WebOb. WebOb isn’t itself intended to write applications — it is not a web framework on its own — but it is possible to write applications using just WebOb.

The file serving example is a better example of advanced HTTP usage. The comment middleware example is a better example of using middleware. This example provides some completeness by showing an application-focused end point.

This example implements a very simple wiki.

Code

The finished code for this is available in docs/wiki-example-code/example.py — you can run that file as a script to try it out.

Creating an Application

A common pattern for creating small WSGI applications is to have a class which is instantiated with the configuration. For our application we’ll be storing the pages under a directory.

  1. class WikiApp(object):
  2. def __init__(self, storage_dir):
  3. self.storage_dir = os.path.abspath(os.path.normpath(storage_dir))

WSGI applications are callables like wsgi_app(environ, start_response). Instances of WikiApp are WSGI applications, so we’ll implement a __call__ method:

  1. class WikiApp(object):
  2. ...
  3. def __call__(self, environ, start_response):
  4. # what we'll fill in

To make the script runnable we’ll create a simple command-line interface:

  1. if __name__ == '__main__':
  2. import optparse
  3. parser = optparse.OptionParser(
  4. usage='%prog --port=PORT'
  5. )
  6. parser.add_option(
  7. '-p', '--port',
  8. default='8080',
  9. dest='port',
  10. type='int',
  11. help='Port to serve on (default 8080)')
  12. parser.add_option(
  13. '--wiki-data',
  14. default='./wiki',
  15. dest='wiki_data',
  16. help='Place to put wiki data into (default ./wiki/)')
  17. options, args = parser.parse_args()
  18. print 'Writing wiki pages to %s' % options.wiki_data
  19. app = WikiApp(options.wiki_data)
  20. from wsgiref.simple_server import make_server
  21. httpd = make_server('localhost', options.port, app)
  22. print 'Serving on http://localhost:%s' % options.port
  23. try:
  24. httpd.serve_forever()
  25. except KeyboardInterrupt:
  26. print '^C'

There’s not much to talk about in this code block. The application is instantiated and served with the built-in module wsgiref.simple_server.

The WSGI Application

Of course all the interesting stuff is in that __call__ method. WebOb lets you ignore some of the details of WSGI, like what start_response really is. environ is a CGI-like dictionary, but webob.Request gives an object interface to it. webob.Response represents a response, and is itself a WSGI application. Here’s kind of the hello world of WSGI applications using these objects:

  1. from webob import Request, Response
  2. class WikiApp(object):
  3. ...
  4. def __call__(self, environ, start_response):
  5. req = Request(environ)
  6. resp = Response(
  7. 'Hello %s!' % req.params.get('name', 'World'))
  8. return resp(environ, start_response)

req.params.get('name', 'World') gets any query string parameter (like ?name=Bob), or if it’s a POST form request it will look for a form parameter name. We instantiate the response with the body of the response. You could also give keyword arguments like content_type='text/plain' (text/html is the default content type and 200 OK is the default status).

For the wiki application we’ll support a couple different kinds of screens, and we’ll make our __call__ method dispatch to different methods depending on the request. We’ll support an action parameter like ?action=edit, and also dispatch on the method (GET, POST, etc, in req.method). We’ll pass in the request and expect a response object back.

Also, WebOb has a series of exceptions in webob.exc, like webob.exc.HTTPNotFound, webob.exc.HTTPTemporaryRedirect, etc. We’ll also let the method raise one of these exceptions and turn it into a response.

One last thing we’ll do in our __call__ method is create our Page object, which represents a wiki page.

All this together makes:

  1. from webob import Request, Response
  2. from webob import exc
  3. class WikiApp(object):
  4. ...
  5. def __call__(self, environ, start_response):
  6. req = Request(environ)
  7. action = req.params.get('action', 'view')
  8. # Here's where we get the Page domain object:
  9. page = self.get_page(req.path_info)
  10. try:
  11. try:
  12. # The method name is action_{action_param}_{request_method}:
  13. meth = getattr(self, 'action_%s_%s' % (action, req.method))
  14. except AttributeError:
  15. # If the method wasn't found there must be
  16. # something wrong with the request:
  17. raise exc.HTTPBadRequest('No such action %r' % action)
  18. resp = meth(req, page)
  19. except exc.HTTPException, e:
  20. # The exception object itself is a WSGI application/response:
  21. resp = e
  22. return resp(environ, start_response)

The Domain Object

The Page domain object isn’t really related to the web, but it is important to implementing this. Each Page is just a file on the filesystem. Our get_page method figures out the filename given the path (the path is in req.path_info, which is all the path after the base path). The Page class handles getting and setting the title and content.

Here’s the method to figure out the filename:

  1. import os
  2. class WikiApp(object):
  3. ...
  4. def get_page(self, path):
  5. path = path.lstrip('/')
  6. if not path:
  7. # The path was '/', the home page
  8. path = 'index'
  9. path = os.path.join(self.storage_dir)
  10. path = os.path.normpath(path)
  11. if path.endswith('/'):
  12. path += 'index'
  13. if not path.startswith(self.storage_dir):
  14. raise exc.HTTPBadRequest("Bad path")
  15. path += '.html'
  16. return Page(path)

Mostly this is just the kind of careful path construction you have to do when mapping a URL to a filename. While the server may normalize the path (so that a path like /../../ can’t be requested), you can never really be sure. By using os.path.normpath we eliminate these, and then we make absolutely sure that the resulting path is under our self.storage_dir with if not path.startswith(self.storage_dir): raise exc.HTTPBadRequest("Bad path").

Here’s the actual domain object:

  1. class Page(object):
  2. def __init__(self, filename):
  3. self.filename = filename
  4. @property
  5. def exists(self):
  6. return os.path.exists(self.filename)
  7. @property
  8. def title(self):
  9. if not self.exists:
  10. # we need to guess the title
  11. basename = os.path.splitext(os.path.basename(self.filename))[0]
  12. basename = re.sub(r'[_-]', ' ', basename)
  13. return basename.capitalize()
  14. content = self.full_content
  15. match = re.search(r'<title>(.*?)</title>', content, re.I|re.S)
  16. return match.group(1)
  17. @property
  18. def full_content(self):
  19. f = open(self.filename, 'rb')
  20. try:
  21. return f.read()
  22. finally:
  23. f.close()
  24. @property
  25. def content(self):
  26. if not self.exists:
  27. return ''
  28. content = self.full_content
  29. match = re.search(r'<body[^>]*>(.*?)</body>', content, re.I|re.S)
  30. return match.group(1)
  31. @property
  32. def mtime(self):
  33. if not self.exists:
  34. return None
  35. else:
  36. return int(os.stat(self.filename).st_mtime)
  37. def set(self, title, content):
  38. dir = os.path.dirname(self.filename)
  39. if not os.path.exists(dir):
  40. os.makedirs(dir)
  41. new_content = """<html><head><title>%s</title></head><body>%s</body></html>""" % (
  42. title, content)
  43. f = open(self.filename, 'wb')
  44. f.write(new_content)
  45. f.close()

Basically it provides a .title attribute, a .content attribute, the .mtime (last modified time), and the page can exist or not (giving appropriate guesses for title and content when the page does not exist). It encodes these on the filesystem as a simple HTML page that is parsed by some regular expressions.

None of this really applies much to the web or WebOb, so I’ll leave it to you to figure out the details of this.

URLs, PATH_INFO, and SCRIPT_NAME

This is an aside for the tutorial, but an important concept. In WSGI, and accordingly with WebOb, the URL is split up into several pieces. Some of these are obvious and some not.

An example:

  1. http://example.com:8080/wiki/article/12?version=10

There are several components here:

  • req.scheme: http
  • req.host: example.com:8080
  • req.server_name: example.com
  • req.server_port: 8080
  • req.script_name: /wiki
  • req.path_info: /article/12
  • req.query_string: version=10

One non-obvious part is req.script_name and req.path_info. These correspond to the CGI environmental variables SCRIPT_NAME and PATH_INFO. req.script_name points to the application. You might have several applications in your site at different paths: one at /wiki, one at /blog, one at /. Each application doesn’t necessarily know about the others, but it has to construct its URLs properly — so any internal links to the wiki application should start with /wiki.

Just as there are pieces to the URL, there are several properties in WebOb to construct URLs based on these:

  • req.host_url: http://example.com:8080
  • req.application_url: http://example.com:8080/wiki
  • req.path_url: http://example.com:8080/wiki/article/12
  • req.path: /wiki/article/12
  • req.path_qs: /wiki/article/12?version=10
  • req.url: http://example.com:8080/wiki/article/12?version10

You can also create URLs with req.relative_url('some/other/page'). In this example that would resolve to http://example.com:8080/wiki/article/some/other/page. You can also create a relative URL to the application URL (SCRIPT_NAME) like req.relative_url('some/other/page', True) which would be http://example.com:8080/wiki/some/other/page.

Back to the Application

We have a dispatching function with __call__ and we have a domain object with Page, but we aren’t actually doing anything.

The dispatching goes to action_ACTION_METHOD, where ACTION defaults to view. So a simple page view will be action_view_GET. Let’s implement that:

  1. class WikiApp(object):
  2. ...
  3. def action_view_GET(self, req, page):
  4. if not page.exists:
  5. return exc.HTTPTemporaryRedirect(
  6. location=req.url + '?action=edit')
  7. text = self.view_template.substitute(
  8. page=page, req=req)
  9. resp = Response(text)
  10. resp.last_modified = page.mtime
  11. resp.conditional_response = True
  12. return resp

The first thing we do is redirect the user to the edit screen if the page doesn’t exist. exc.HTTPTemporaryRedirect is a response that gives a 307 Temporary Redirect response with the given location.

Otherwise we fill in a template. The template language we’re going to use in this example is Tempita, a very simple template language with a similar interface to string.Template.

The template actually looks like this:

  1. from tempita import HTMLTemplate
  2. VIEW_TEMPLATE = HTMLTemplate("""\
  3. <html>
  4. <head>
  5. <title>{{page.title}}</title>
  6. </head>
  7. <body>
  8. <h1>{{page.title}}</h1>
  9. <div>{{page.content|html}}</div>
  10. <hr>
  11. <a href="{{req.url}}?action=edit">Edit</a>
  12. </body>
  13. </html>
  14. """)
  15. class WikiApp(object):
  16. view_template = VIEW_TEMPLATE
  17. ...

As you can see it’s a simple template using the title and the body, and a link to the edit screen. We copy the template object into a class method (view_template = VIEW_TEMPLATE) so that potentially a subclass could override these templates.

tempita.HTMLTemplate is a template that does automatic HTML escaping. Our wiki will just be written in plain HTML, so we disable escaping of the content with {{page.content|html}}.

So let’s look at the action_view_GET method again:

  1. def action_view_GET(self, req, page):
  2. if not page.exists:
  3. return exc.HTTPTemporaryRedirect(
  4. location=req.url + '?action=edit')
  5. text = self.view_template.substitute(
  6. page=page, req=req)
  7. resp = Response(text)
  8. resp.last_modified = page.mtime
  9. resp.conditional_response = True
  10. return resp

The template should be pretty obvious now. We create a response with Response(text), which already has a default Content-Type of text/html.

To allow conditional responses we set resp.last_modified. You can set this attribute to a date, None (effectively removing the header), a time tuple (like produced by time.localtime()), or as in this case to an integer timestamp. If you get the value back it will always be a datetime object (or None). With this header we can process requests with If-Modified-Since headers, and return 304 Not Modified if appropriate. It won’t actually do that unless you set resp.conditional_response to True.

Note

If you subclass webob.Response you can set the class attribute default_conditional_response = True and this setting will be on by default. You can also set other defaults, like the default_charset ("utf8"), or default_content_type ("text/html").

The Edit Screen

The edit screen will be implemented in the method action_edit_GET. There’s a template and a very simple method:

  1. EDIT_TEMPLATE = HTMLTemplate("""\
  2. <html>
  3. <head>
  4. <title>Edit: {{page.title}}</title>
  5. </head>
  6. <body>
  7. {{if page.exists}}
  8. <h1>Edit: {{page.title}}</h1>
  9. {{else}}
  10. <h1>Create: {{page.title}}</h1>
  11. {{endif}}
  12. <form action="{{req.path_url}}" method="POST">
  13. <input type="hidden" name="mtime" value="{{page.mtime}}">
  14. Title: <input type="text" name="title" style="width: 70%" value="{{page.title}}"><br>
  15. Content: <input type="submit" value="Save">
  16. <a href="{{req.path_url}}">Cancel</a>
  17. <br>
  18. <textarea name="content" style="width: 100%; height: 75%" rows="40">{{page.content}}</textarea>
  19. <br>
  20. <input type="submit" value="Save">
  21. <a href="{{req.path_url}}">Cancel</a>
  22. </form>
  23. </body></html>
  24. """)
  25. class WikiApp(object):
  26. ...
  27. edit_template = EDIT_TEMPLATE
  28. def action_edit_GET(self, req, page):
  29. text = self.edit_template.substitute(
  30. page=page, req=req)
  31. return Response(text)

As you can see, all the action here is in the template.

In <form action="{{req.path_url}}" method="POST"> we submit to req.path_url; that’s everything but ?action=edit. So we are POSTing right over the view page. This has the nice side effect of automatically invalidating any caches of the original page. It also is vaguely RESTful.

We save the last modified time in a hidden mtime field. This way we can detect concurrent updates. If start editing the page who’s mtime is 100000, and someone else edits and saves a revision changing the mtime to 100010, we can use this hidden field to detect that conflict. Actually resolving the conflict is a little tricky and outside the scope of this particular tutorial, we’ll just note the conflict to the user in an error.

From there we just have a very straight-forward HTML form. Note that we don’t quote the values because that is done automatically by HTMLTemplate; if you are using something like string.Template or a templating language that doesn’t do automatic quoting, you have to be careful to quote all the field values.

We don’t have any error conditions in our application, but if there were error conditions we might have to re-display this form with the input values the user already gave. In that case we’d do something like:

  1. <input type="text" name="title"
  2. value="{{req.params.get('title', page.title)}}">

This way we use the value in the request (req.params is both the query string parameters and any variables in a POST response), but if there is no value (e.g., first request) then we use the page values.

Processing the Form

The form submits to action_view_POST (view is the default action). So we have to implement that method:

  1. class WikiApp(object):
  2. ...
  3. def action_view_POST(self, req, page):
  4. submit_mtime = int(req.params.get('mtime') or '0') or None
  5. if page.mtime != submit_mtime:
  6. return exc.HTTPPreconditionFailed(
  7. "The page has been updated since you started editing it")
  8. page.set(
  9. title=req.params['title'],
  10. content=req.params['content'])
  11. resp = exc.HTTPSeeOther(
  12. location=req.path_url)
  13. return resp

The first thing we do is check the mtime value. It can be an empty string (when there’s no mtime, like when you are creating a page) or an integer. int(req.params.get('time') or '0') or None basically makes sure we don’t pass "" to int() (which is an error) then turns 0 into None (0 or None will evaluate to None in Python — false_value or other_value in Python resolves to other_value). If it fails we just give a not-very-helpful error message, using 412 Precondition Failed (typically preconditions are HTTP headers like If-Unmodified-Since, but we can’t really get the browser to send requests like that, so we use the hidden field instead).

Note

Error statuses in HTTP are often under-used because people think they need to either return an error (useful for machines) or an error message or interface (useful for humans). In fact you can do both: you can give any human readable error message with your error response.

One problem is that Internet Explorer will replace error messages with its own incredibly unhelpful error messages. However, it will only do this if the error message is short. If it’s fairly large (4Kb is large enough) it will show the error message it was given. You can load your error with a big HTML comment to accomplish this, like "<!-- %s -->" % ('x'*4000).

You can change the status of any response with resp.status_int = 412, or you can change the body of an exc.HTTPSomething with resp.body = new_body. The primary advantage of using the classes in webob.exc is giving the response a clear name and a boilerplate error message.

After we check the mtime we get the form parameters from req.params and issue a redirect back to the original view page. 303 See Other is a good response to give after accepting a POST form submission, as it gets rid of the POST (no warning messages for the user if they try to go back).

In this example we’ve used req.params for all the form values. If we wanted to be specific about where we get the values from, they could come from req.GET (the query string, a misnomer since the query string is present even in POST requests) or req.POST (a POST form body). While sometimes it’s nice to distinguish between these two locations, for the most part it doesn’t matter. If you want to check the request method (e.g., make sure you can’t change a page with a GET request) there’s no reason to do it by accessing these method-specific getters. It’s better to just handle the method specifically. We do it here by including the request method in our dispatcher (dispatching to action_view_GET or action_view_POST).

Cookies

One last little improvement we can do is show the user a message when they update the page, so it’s not quite so mysteriously just another page view.

A simple way to do this is to set a cookie after the save, then display it in the page view. To set it on save, we add a little to action_view_POST:

  1. def action_view_POST(self, req, page):
  2. ...
  3. resp = exc.HTTPSeeOther(
  4. location=req.path_url)
  5. resp.set_cookie('message', 'Page updated')
  6. return resp

And then in action_view_GET:

  1. VIEW_TEMPLATE = HTMLTemplate("""\
  2. ...
  3. {{if message}}
  4. <div style="background-color: #99f">{{message}}</div>
  5. {{endif}}
  6. ...""")
  7. class WikiApp(object):
  8. ...
  9. def action_view_GET(self, req, page):
  10. ...
  11. if req.cookies.get('message'):
  12. message = req.cookies['message']
  13. else:
  14. message = None
  15. text = self.view_template.substitute(
  16. page=page, req=req, message=message)
  17. resp = Response(text)
  18. if message:
  19. resp.delete_cookie('message')
  20. else:
  21. resp.last_modified = page.mtime
  22. resp.conditional_response = True
  23. return resp

req.cookies is just a dictionary, and we also delete the cookie if it is present (so the message doesn’t keep getting set). The conditional response stuff only applies when there isn’t any message, as messages are private. Another alternative would be to display the message with Javascript, like:

  1. <script type="text/javascript">
  2. function readCookie(name) {
  3. var nameEQ = name + "=";
  4. var ca = document.cookie.split(';');
  5. for (var i=0; i < ca.length; i++) {
  6. var c = ca[i];
  7. while (c.charAt(0) == ' ') c = c.substring(1,c.length);
  8. if (c.indexOf(nameEQ) == 0) return c.substring(nameEQ.length,c.length);
  9. }
  10. return null;
  11. }
  12. function createCookie(name, value, days) {
  13. if (days) {
  14. var date = new Date();
  15. date.setTime(date.getTime()+(days*24*60*60*1000));
  16. var expires = "; expires="+date.toGMTString();
  17. } else {
  18. var expires = "";
  19. }
  20. document.cookie = name+"="+value+expires+"; path=/";
  21. }
  22. function eraseCookie(name) {
  23. createCookie(name, "", -1);
  24. }
  25. function showMessage() {
  26. var message = readCookie('message');
  27. if (message) {
  28. var el = document.getElementById('message');
  29. el.innerHTML = message;
  30. el.style.display = '';
  31. eraseCookie('message');
  32. }
  33. }
  34. </script>

Then put <div id="messaage" style="display: none"></div> in the page somewhere. This has the advantage of being very cacheable and simple on the server side.

Conclusion

We’re done, hurrah!