advertisement

Print

Shoes Meets Merb: Driving a GUI App through Web Services in Ruby
Pages: 1, 2, 3, 4, 5, 6

Attaching Shoes to the Web

We are going to connect the Shoes user interface to the Merb backend using HTTP. To make this easier, we create a helper module, HttpToYaml, that will function as a super-simple generic web service wrapper. We can include this module into our Shoes application, and it provides four Ruby methods corresponding to the four primary HTTP methods (get, post, put, and delete). It requests YAML data using a custom Accept header, and decodes the response using YAML.load.



We could have avoided the need to write this wrapper by looking for a third-party library that accomplishes these functions for us. However, Shoes doesn't make it terribly easy to structure large applications or package up third-party libraries. Shoes loads the application's code by reading its source file and evaling it in the context of Shoes, so it's not really possible to reference files using paths relative to the source file. With all that in mind, we opted to write our own interface.

Here is the full code for the web service wrapper. Later, we will examine it in detail.

  require "uri" 
  require "net/http" 

  module HttpToYaml
    class TooManyRedirects < StandardError; end

    BACKEND = 'http://localhost:4000'

    protected

    def get(uri, redirection_limit = 10)
      uri = URI.parse("#{BACKEND}#{uri}")
      response = Net::HTTP.start(uri.host, uri.port) do |http|
        http.get(uri.path, {'Accept' => 'text/yaml'})
      end
      handle_or_decode_response(response, :redirection_limit => redirection_limit)
    end

    def post(uri, params = {})
      uri = URI.parse("#{BACKEND}#{uri}")
      response = Net::HTTP.start(uri.host, uri.port) do |http|
        request = Net::HTTP::Post.new(uri.path)
        request['Accept'] = 'text/yaml'
        request.set_form_data(params)
        http.request(request)
      end
      handle_or_decode_response(response)
    end

    def put(uri, params = {})
      post uri, params.merge(:_method => 'put')    
    end

    def delete(uri, params = {})
      post uri, params.merge(:_method => 'delete')
    end

    def handle_or_decode_response(response, options = {})
      case response
      when Net::HTTPSuccess
        YAML.load(response.body)
      when Net::HTTPRedirection
        limit = options[:redirection_limit] || 10
        raise TooManyRedirects if limit == 1
        get response['Location'], limit - 1
      end
    end
  end

This is not terribly fun code, but it serves our purpose of wrapping up Net::HTTP's ugly bits into a higher-level interface, somewhat reminiscent of integration test scripts in Rails. Let's take a look at it, piece by piece. First off, we need to pull in the URI and Net::HTTP libraries from Ruby's standard library.

  require "uri" 
  require "net/http"

Net::HTTP provides our basic HTTP interface, and the URI library breaks down URI strings (such as http://example.com:8080/index.rdf) into their constituent parts (scheme, host, port, and path).

The most basic HTTP method is GET, which we expose through our module's get method. Here it is again, along with some of its supporting code:

  module HttpToYaml
    class TooManyRedirects < StandardError; end

    BACKEND = 'http://localhost:4000'

    def get(uri, redirection_limit = 10)
      uri = URI.parse("#{BACKEND}#{uri}")
      response = Net::HTTP.start(uri.host, uri.port) do |http|
        http.get(uri.path, {'Accept' => 'text/yaml'})
      end
      handle_or_decode_response(response, :redirection_limit => redirection_limit)
    end

    def handle_or_decode_response(response, options = {})
      case response
      when Net::HTTPSuccess
        YAML.load(response.body)
      when Net::HTTPRedirection
        limit = options[:redirection_limit] || 10
        raise TooManyRedirects if limit == 1
        get response['Location'], limit - 1
      end
    end
  end

Note the BACKEND constant, which is the base URI of our server. Since we are speaking plain HTTP between the client and server, we could separate the client and server on a network or even the Internet. All we would need to do is make the Merb server accessible and specify its URI in the BACKEND constant.

When we perform the request, we need to pass a custom Accept header of text/yaml. This tells Merb, or any other server from which we request data, that the only response format we understand is YAML. Merb will use this information to decide among different representations of the same content. For example, we could augment the Merb application to have a Web interface as well, and it could coexist on the exact same URIs, differentiated only by the version the client requests in the Accept header.

Much of the complexity in these methods comes from our need to gracefully follow HTTP redirects; Net::HTTP makes us decide for ourselves how we want to handle a redirection response. When a paste is created, the Merb server will send a 302 redirection to the paste's URI. For our application, we want to automatically follow this redirect and return the body of the second response. The handle_or_decode_response method fills this need. If it encounters a redirection, it will issue another GET to the URI stored in the current response's Location header. Otherwise, on a successful request, the method will pipe the response body through YAML.load and return the resulting data structure.

We do need to take some care to ensure that we do not follow an infinite loop of redirects, and this is what the redirection_limit option and TooManyRedirects exception are for. On each redirect, we decrement the redirection_limit counter (which is essentially a time-to-live variable); if it would reach zero, we break the redirection loop by raising a TooManyRedirects exception.

The post method is similar to get, but it takes a second parameter: a hash of form parameters to be used as the POST data. These will be unescaped on the server side and will come out looking pretty much the same as the Merb params hash. This interface constrains us to posting form-like data, but it will be good enough for our purposes. Again, we request YAML data and expect YAML back. Here is the code:

  def post(uri, params = {})
    uri = URI.parse("#{BACKEND}#{uri}")
    response = Net::HTTP.start(uri.host, uri.port) do |http|
      request = Net::HTTP::Post.new(uri.path)
      request['Accept'] = 'text/yaml'
      request.set_form_data(params)
      http.request(request)
    end
    handle_or_decode_response(response)
  end

One thing that may not be immediately obvious from this code is that any redirection that a POST request experiences is followed using GET, not POST. This is technically incorrect behavior according to RFC 2616, but it is widespread enough that we will not worry about it (indeed, the RFC specifically mentions that most clients do exactly as we do here, and the server should use 303 or 307 response codes where there could be ambiguity about how to follow such a redirect).

The other two HTTP methods, PUT and DELETE, are very useful in conjunction with Merb's resource-based routing. Unfortunately, they are not as well known on the Web, and their use is not always robust. Some Web proxies do not understand PUT and DELETE. The only methods allowed on HTML forms are GET and POST, which has made the other HTTP verbs second-class citizens on the public Internet.

To ensure that these methods will still work even in the face of intermediaries that do not understand them, we will fake them by using the POST method with a special _method parameter, which is set to the actual method we are emulating. Merb understands this convention and treats these requests just as if they had been issued using the correct method.

  def put(uri, params = {})
    post uri, params.merge(:_method => 'put')    
  end

  def delete(uri, params = {})
    post uri, params.merge(:_method => 'delete')
  end

These two methods are short stubs that simply delegate to the post method, adding a _method parameter specifying the real HTTP method.

Testing the Web Service Bridge

We can fire up irb to test this interface. Just make sure the Merb server is running in the background by changing to its directory and running merb. Then we can play around with the web service client and test it interactively:

  >> require 'http_to_yaml' # wherever the HttpToYaml code is located
  >> include HttpToYaml
  => Object
  >> get '/pastes'
  => [{:created_at=>"Sun Jan 06 14:01:27 -0600 2008", :id=>1, :title=>"Foo"}]
  >> get '/pastes/1'
  => {:created_at=>"Sun Jan 06 14:01:27 -0600 2008", :text=>"the sweetest bar that ever barred", :id=>1, :title=>"Foo"}

Our library appears to be functional now, and we can see the exact data structures that will be returned when we use those method calls in the Shoes application.

Pages: 1, 2, 3, 4, 5, 6

Next Pagearrow