β

RESTful Pyramid

道可叨 455 阅读

English Version (英文版)

Recently python web framework Pylons and repoze.bfg merged together as Pyramid and release v 1.0 very soon.

The first impression of Pyramid is that it is more a micro-framework than Pylons. And sometimes I found it too micro that it lacks some of the convenience utility such as build-in support for RESTful style serivce: there is no Pylons's RestController

Thanks to Pyramid's flexible design, it's easy to setup the RESTful route manually and here comes the cookbook.

HTTP Verb Based Route

RESTful style service requires different HTTP verbs mapping to different handlers. In Pyramid we can map different views to same route based on different request_method

config.add_route('post', 'post/{id}')

from pyramid.view import view_config

@view_config(route_name='post', request_method='GET')
def show_post_view(request):
    return Response("GET post %s" % request.matchdict['id'])

@view_config(route_name='post', request_method='DELETE')
def delete_post_view(request):
    # Delete the post ...
    return Response('DELETE post %s' % request.matchdict['id'])

HTTP Method Override

RESTful web service take advantage of all HTTP methods, including DELETE and PUT . However sometimes HTTP clients could only send GET and POST requests because:

  • HTML standard only supports GET and POST via link and form actions. Web browser just cannot send PUT or DELETE without ajax support.
  • Firewalls may block HTTP PUT and DELETE request.

To get around this limitation, client has to tunnel the actual PUT or DELETE request in a POST request. The RESTful service is supposed to look for the real HTTP method embeded in the POST request. There are 2 de facto standards for POST tunnel :

  • Embed HTTP method in a hidden input form parameter named _method :

    POST /post/123 HTTP/1.1
    Content-Length: 19
    
    a=1&_method=DELETE
    

    This is convenience when POST from HTML form.

  • Embed the HTTP method in X-HTTP-Method-Override HTTP header

    POST /post/123 HTTP/1.1
    Content-Length: 5
    X-HTTP-Method-Override: DELETE
    
    {a:1}
    

    This is prefered when POST with data format in JSON or XML

To recognize these 2 HTTP override conversions a custom_predicates is needed for looking for the real HTTP method:

def allowed_methods(*allowed):
    '''Custom predict checking if the HTTP method in the allowed set.
    It also changes the request.method according to "_method" form parameter
    and "X-HTTP-Method-Override" header
    '''
    def predicate(info, request):
        if request.method == 'POST':
            request.method = (
                request.str_POST.get('_method', '').upper() or
                request.headers.get('X-HTTP-Method-Override', '').upper() or
                request.method)

        return request.method in allowed
    return predicate


# We use the allowed_methods to find the real HTTP method and do the route
@view_config(route_name='post', custom_predicates=(allowed_methods('DELETE'),))
def delete_post_view(request):
    # Delete the post ...
    return Response("DELETE post %s" % request.matchdict['id'])

The above code will do the trick to override the HTTP method and dispatch the route to the correct view, Here's another more WSGI way doing it. A dedicate WSGI middleware could be applied here to override the HTTP method before the request ever goes to Pyramid application:

class HttpMethodOverrideMiddleware(object):
    '''WSGI middleware for overriding HTTP Request Method for RESTful support
    '''
    def __init__(self, application):
        self.application = application


    def __call__(self, environ, start_response):
        if 'POST' == environ['REQUEST_METHOD']:
            override_method = ''

            # First check the "_method" form parameter
            if 'form-urlencoded' in environ['CONTENT_TYPE']:
                from webob import Request
                request = Request(environ)
                override_method = request.str_POST.get('_method', '').upper()

            # If not found, then look for "X-HTTP-Method-Override" header
            if not override_method:
                override_method = environ.get('HTTP_X_HTTP_METHOD_OVERRIDE', '').upper()

            if override_method in ('PUT', 'DELETE', 'OPTIONS', 'PATCH'):
                # Save the original HTTP method
                environ['http_method_override.original_method'] = environ['REQUEST_METHOD']
                # Override HTTP method
                environ['REQUEST_METHOD'] = override_method

        return self.application(environ, start_response)

The usage is simple, change main() in __init__.py as the following:

def main(global_config, **settings):
    # ...
    return HttpMethodOverrideMiddleware(config.make_wsgi_app())

Then you could just use the build-in request_method since the request.method is already been override by HttpMethodOverrideMiddleware (Actually this is exactly what Rank::MethodOverride does for Rails).

@view_config(route_name='post', request_method='DELETE')
def delete_post_view(request):
    # Delete the post ...
    return Response("DELETE post %s" % request.matchdict['id'])

MIME-Type Based Route

For RESTful service, an URI could gives different representations for the same resource based on MIME-types (like application/xml or application/json ) in HTTP request's Accept header. Pyramid has build-in support for this:

# if MIME type is XML we use the xml template for render
@view_config(route_name='post',
             request_method='GET',
             accept='*/xml',
             renderer='post.xml.mako')
# if MIME type is json, a json template is prefered
@view_config(route_name='post',
             request_method='GET',
             accept='*/json',
             renderer='post.json.mako')
def show_post_view(request):
    return dict(post_id=request.matchdict['id'])

File Extension Based Route

Some RESTful services also use file extension of the URI to decide the right resource representation.

For example, server gives data in the default HTML format on URI /post/123 . It will also returns HTML on the explicit request for /post/123.html and only returns JSON on /post/123.json .

We could let multiple routes mapping to the same view to support this schema:

config.add_route('post_html', 'post/{id}.html')
config.add_route('post_json, 'post/{id}.json')
# The raw ID without "." and "/"
config.add_route('post', 'post/{id:[^/\.]+}')

@view_config(route_name='post', request_method='GET', renderer='post.html.mako')
@view_config(route_name='post_html, request_method='GET', renderer='post.html.mako')
@view_config(route_name='post_json, request_method='GET', renderer='json')
def show_post_view(request):
    return dict(post_id=request.matchdict['id'])

It is tedious to repeat so many routes, let's create a dedicate custom predicate instead:

def allowed_extension(*allowed):
    '''Custom predict checking if the the file extension
    of the request URI is in the allowed set.
    '''
    def predicate(info, request):
        import os
        ext = os.path.splitext(request.path)[1]
        request.path_extenstion = ext
        return ext in allowed

    return predicate

config.add_route('post', 'post/{id}')

@view_config(
    route_name='post',
    request_method='GET',
    renderer='post.html.mako',
    custom_predicates=(allowed_extension('', '.html'),))
@view_config(
    route_name='post',
    request_method='GET',
    renderer='json',
    custom_predicates=(allowed_extension('.json'),))
def show_post_view(request):
    return dict(post_id=request.matchdict['id'])

Another way is using regular expression to define a general extension pattern for the URI and dispatch the view manually in the view handle.

config.add_route('post', r'/post/{id:[^/\.]+}{ext:(\.json|\.html)?}')
#or config.add_route('post', r'/post/{id:[^/\.]+}{ext:(\.[^/]+)?}')

@view_config(route_name='post', request_method='GET')
def show_post_view(request):
    ext = request.matchdict['ext']
    if ext in ('.html' ''):
        # return HTML
    elif ext == '.json':
        # return JSON
    else:
        from pyramid.httpexceptions import HTTPNotFount
        return HTTPNotFount("not found the format for " % ext)

Something about Client

As said, you have to use the POST tunnel to fire a PUT or DELETE request in current web browser if javascript is disabled.

The only way to perform an HTTP POST with standard HTML is to use a <form> tag. and you have to use one of <input type="submit"> <input type="image"> or <input type="button"> tag to create the action trigger.

I prefer <input type="image"> which gives you more control on how it looks (and lucky by default it just looks like a link):

<form action="${request.route_url('post', id=id)}" method="post" class="delete">
  <input type="image" name="" value="Delete This Post!" />
  <input type="hidden" name="_method" value="DELETE" />
</form>

Actually, you could do better. You could fire a real DELETE request if javascript is avalible through ajax IO.

You can even use javascript to replace the form tag with the link tag dynamically:

YUI().use('node', 'io-base', function (Y) {
  // Erase the parent node when DELETE success
  function onDeleteSuccess(node) {
    node.get('parentNode').remove();
  }

  // Replace form tag to a ajax IO link
  function changeToLink(node) {
     var url = node.getAttribute('action')
     node.setContent('<a href="http://zhuoqiang.me/restful-pyramid.html">Delete</a>'.replace('#', url))
         .on('click', function(e) {
            e.preventDefault();
            var cfg = {
              method: 'DELETE',
              on: {
                success: function(){onDeleteSuccess(node);}
              }
            };
            Y.io(url, cfg);
    });
  }

  // Do the replacement
  Y.all('form.delete')
   .each(function (node) {
     changeToLink(node);
   });
});

The above javascript code is a demostration of progressive enhancement using YUI3. Users with javascript disabled will have the DELETE function through plain HTML form. And users with javascript enabled will get a better Ajax no-refresh DELETE experience.


中文版 (Chinese Version)

最近 python web 框架 Pylons 和 repoze.bfg 合并成了 Pyramid,很快就推出了 1.0 版本。

对 Pyramid 的第一印象:相对 Pylons 来说,它更加简洁,有时甚至简洁到缺乏一些方便工具。比如对 RESTful 风格服务的内建支持: 你找不到原来 Pylons 中 RestController 相对应的方法。好在 Pyramid 设计的很具弹性,手工搞定 RESTful 风格的 URI 分派也不是什么难事,下面就是实际的例子。

基于 HTTP 动词的分派

RESTful 风格的服务需要根据不同的 HTTP 动词做不同的处理。在 Pyramid 中这很容易做到。只要在相同的 URI 上将不同的 request_method 分派到不同的 view 上就行了:

config.add_route('post', 'post/{id}')

from pyramid.view import view_config

@view_config(route_name='post', request_method='GET')
def show_post_view(request):
    return Response("GET post %s" % request.matchdict['id'])

@view_config(route_name='post', request_method='DELETE')
def delete_post_view(request):
    # Delete the post ...
    return Response('DELETE post %s' % request.matchdict['id'])

HTTP 方法覆盖

RESTful 服务充分利用每一个 HTTP 方法,包括 DELETE PUT 。可有时,HTTP 客户端只能发出 GET POST 请求:

  • HTML 标准只通过链接和表单支持 GET POST 。在没有 Ajax 支持的网页浏览器中不能发出 PUT DELETE 命令

  • 有些防火墙会挡住 HTTP PUT DELETE 请求

    要绕过这个限制,客户端需要把实际的 PUT DELETE 请求通过 POST 请求穿透过来。RESTful 服务则要负责在收到的 POST 请求中找到原始的 HTTP 方法并还原。

现在有两种常用的穿透方法:

  • 将 HTTP 方法放到表单中一个名为 _method 的隐藏 input

    POST /post/123 HTTP/1.1
    Content-Length: 19
    
    a=1&_method=DELETE
    

    在通过 HTML 表单进行 POST 时这种方法很方便。

  • 将 HTTP 方法标注在名为 "X-HTTP-Method-Override" 的 HTTP header 中

    POST /post/123 HTTP/1.1
    Content-Length: 5
    X-HTTP-Method-Override: DELETE
    
    {a:1}
    

    当要传输 JSON 或 XML 数据时,只能用这种方式。

为了识别这两种覆盖惯例,我们需要定义一个 custom_predicates 用来在 HTTP POST 请求中查找真正的 HTTP 方法

def allowed_methods(*allowed):
    '''Custom predict checking if the HTTP method in the allowed set.
    It also changes the request.method according to "_method" form parameter
    and "X-HTTP-Method-Override" header
    '''
    def predicate(info, request):
        if request.method == 'POST':
            request.method = (
                request.str_POST.get('_method', '').upper() or
                request.headers.get('X-HTTP-Method-Override', '').upper() or
                request.method)

        return request.method in allowed
    return predicate


# We use the allowed_methods to find the real HTTP method and do the route
@view_config(route_name='post', custom_predicates=(allowed_methods('DELETE'),))
def delete_post_view(request):
    # Delete the post ...
    return Response("DELETE post %s" % request.matchdict['id'])

上面的代码能帮我们恢复 HTTP 的实际方法,并且根据实际的方法将请求分派到对应的 view 中。不过,还有一种更加 WSGI 的方式来做这件事:我们可以使用专门的 WSGI 中间件,在请求到达 Pyramid 应用之前就把 HTTP 方法覆盖成检测到的实际方法:

class HttpMethodOverrideMiddleware(object):
    '''WSGI middleware for overriding HTTP Request Method for RESTful support
    '''
    def __init__(self, application):
        self.application = application


    def __call__(self, environ, start_response):
        if 'POST' == environ['REQUEST_METHOD']:
            override_method = ''

            # First check the "_method" form parameter
            if 'form-urlencoded' in environ['CONTENT_TYPE']:
                from webob import Request
                request = Request(environ)
                override_method = request.str_POST.get('_method', '').upper()

            # If not found, then look for "X-HTTP-Method-Override" header
            if not override_method:
                override_method = environ.get('HTTP_X_HTTP_METHOD_OVERRIDE', '').upper()

            if override_method in ('PUT', 'DELETE', 'OPTIONS', 'PATCH'):
                # Save the original HTTP method
                environ['http_method_override.original_method'] = environ['REQUEST_METHOD']
                # Override HTTP method
                environ['REQUEST_METHOD'] = override_method

        return self.application(environ, start_response)

使用方式很简单,改动 __init__.py 中的 main() :

def main(global_config, **settings):
    # ...
    return HttpMethodOverrideMiddleware(config.make_wsgi_app())

因为 reqeust.method 已经被 HttpMethodOverrideMiddleware 改写了(实际上这就是 Rails 中 Rank::MethodOverride 所做的事情),接下来只要使用自带的 request_method 去做过滤就可以了

@view_config(route_name='post', request_method='DELETE')
def delete_post_view(request):
    # Delete the post ...
    return Response("DELETE post %s" % request.matchdict['id'])

基于 MIME-Type 的分派

对于 RESTful 服务来说,一个 URI 背后对应的资源可以有不同的表现形式。在 HTTP 请求中可以通过 Accept 头来表明客户端需要的 MIME-Type ( application/xml , application/json )。Pyramid 对此支持的很好:

# if MIME type is XML we use the xml template for render
@view_config(route_name='post',
             request_method='GET',
             accept='*/xml',
             renderer='post.xml.mako')
# if MIME type is json, a json template is prefered
@view_config(route_name='post',
             request_method='GET',
             accept='*/json',
             renderer='post.json.mako')
def show_post_view(request):
    return dict(post_id=request.matchdict['id'])

基于文件扩展名的分派

一些 RESTful 服务还会利用 URI 路径的扩展名来决定客户端需要什么形式的回复。

例如,当请求 /post/123 时,服务器回复默认的 HTML 格式。当明确请求 /post/123.html 时,仍然回复 HTML 格式。当请求 post/123.json 时就会回复 JSON 格式的数据。我们可以通过定义多个 route 来映射到同一个 view 支持这样的模式:

config.add_route('post_html', 'post/{id}.html')
config.add_route('post_json, 'post/{id}.json')
# The raw ID without "." and "/"
config.add_route('post', 'post/{id:[^/\.]+}')

@view_config(route_name='post', request_method='GET', renderer='post.html.mako')
@view_config(route_name='post_html, request_method='GET', renderer='post.html.mako')
@view_config(route_name='post_json, request_method='GET', renderer='json')
def show_post_view(request):
    return dict(post_id=request.matchdict['id'])

这种做法的缺点是定义的 route 太多,让我们自定义一个 predicate 来代替:

def allowed_extension(*allowed):
    '''Custom predict checking if the the file extension
    of the request URI is in the allowed set.
    '''
    def predicate(info, request):
        import os
        ext = os.path.splitext(request.path)[1]
        request.path_extenstion = ext
        return ext in allowed

    return predicate

config.add_route('post', 'post/{id}')

@view_config(
    route_name='post',
    request_method='GET',
    renderer='post.html.mako',
    custom_predicates=(allowed_extension('', '.html'),))
@view_config(
    route_name='post',
    request_method='GET',
    renderer='json',
    custom_predicates=(allowed_extension('.json'),))
def show_post_view(request):
    return dict(post_id=request.matchdict['id'])

另一种方法是使用正则表达式定义 URI 中扩展名的通用格式,然后在 view 的代码中手工做分发:

config.add_route('post', r'/post/{id:[^/\.]+}{ext:(\.json|\.html)?}')
#or config.add_route('post', r'/post/{id:[^/\.]+}{ext:(\.[^/]+)?}')

@view_config(
    route_name='post',
    request_method='GET')
def show_post_view(request):
    ext = request.matchdict['ext']
    if ext in ('.html' ''):
        # return HTML
    elif ext == '.json':
        # return JSON
    else:
        from pyramid.httpexceptions import HTTPNotFount
        return HTTPNotFount("not found the format for " % ext)

关于客户端

如前述,如果禁止 javascript 的话,在浏览器里你只能使用 POST 来伪装一个 PUT DELETE 的请求。

在标准 HTML 中,只能通过 <form> 标签来发送一个 POST 请求。而且,你还必须使用一个 <input type="submit"> , <input type="image"> <input type="button"> 标签。个人喜欢使用 <input type="image"> , 这样可以更好地控制外观 (而且它默认也长的象普通的链接):

<form action="${request.route_url('post', id=id)}" method="post" class="delete">
  <input type="image" name="" value="Delete This Post!" />
  <input type="hidden" name="_method" value="DELETE" />
</form>

实际上,还能做的更好。如果支持 javascript 的话,你可以使用 Ajax IO 动作直接发送一个真正的 DELETE 请求。你甚至可以使用 javascript 动态的用 link 标签替换掉原来的 form 标签:

YUI().use('node', 'io-base', function (Y) {
  // Erase the parent node when DELETE success
  function onDeleteSuccess(node) {
    node.get('parentNode').remove();
  }

  // Replace form tag to a ajax IO link
  function changeToLink(node) {
     var url = node.getAttribute('action')
     node.setContent('<a href="http://zhuoqiang.me/restful-pyramid.html">Delete</a>'.replace('#', url))
         .on('click', function(e) {
            e.preventDefault();
            var cfg = {
              method: 'DELETE',
              on: {
                success: function(){onDeleteSuccess(node);}
              }
            };
            Y.io(url, cfg);
    });
  }

  // Do the replacement
  Y.all('form.delete')
   .each(function (node) {
     changeToLink(node);
   });
});

上面这段 javascript 代码就演示了使用 YUI3 怎样达到渐进增强。当 javascript 关闭时,用户可以通过普通的 HTML form 来使用 DELETE 。当 javascript 打开后,用户将能透过 Ajax 体验到无刷新的 DELETE 效果。

作者:道可叨
原文地址:RESTful Pyramid, 感谢原作者分享。