dbalatero-relax 0.0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/lib/relax.rb +13 -0
- data/lib/relax/parsers.rb +13 -0
- data/lib/relax/parsers/base.rb +30 -0
- data/lib/relax/parsers/factory.rb +29 -0
- data/lib/relax/parsers/hpricot.rb +133 -0
- data/lib/relax/parsers/rexml.rb +147 -0
- data/lib/relax/query.rb +46 -0
- data/lib/relax/request.rb +107 -0
- data/lib/relax/response.rb +82 -0
- data/lib/relax/service.rb +102 -0
- data/lib/relax/symbolic_hash.rb +79 -0
- data/spec/parser_helper.rb +49 -0
- data/spec/parsers/factory_spec.rb +29 -0
- data/spec/parsers/hpricot_spec.rb +31 -0
- data/spec/parsers/rexml_spec.rb +36 -0
- data/spec/query_spec.rb +60 -0
- data/spec/request_spec.rb +114 -0
- data/spec/response_spec.rb +98 -0
- data/spec/spec_helper.rb +27 -0
- data/spec/symbolic_hash_spec.rb +67 -0
- metadata +72 -0
@@ -0,0 +1,82 @@
|
|
1
|
+
module Relax
|
2
|
+
# Response is intended to be a parent class for responses passed to
|
3
|
+
# Service#call.
|
4
|
+
#
|
5
|
+
# A response is in essence an object used to facilitate XML parsing. It
|
6
|
+
# stores an XML document, and provides access to it through methods like
|
7
|
+
# #element and #attribute.
|
8
|
+
class Response
|
9
|
+
attr_accessor :raw
|
10
|
+
|
11
|
+
# New takes in and parses the raw response.
|
12
|
+
#
|
13
|
+
# This will raise a MissingParameter error if a parameterd marked as
|
14
|
+
# required is not present in the parsed response.
|
15
|
+
def initialize(xml)
|
16
|
+
@raw = xml
|
17
|
+
@parser = Relax::Parsers::Factory.get(parser_name).new(xml.to_s, self)
|
18
|
+
end
|
19
|
+
|
20
|
+
def parser_name #:nodoc:
|
21
|
+
self.class.instance_variable_get('@parser') || :default
|
22
|
+
end
|
23
|
+
|
24
|
+
def node_name(name, namespace=nil) #:nodoc:
|
25
|
+
"#{namespace.to_s + ':' if namespace}#{name}"
|
26
|
+
end
|
27
|
+
|
28
|
+
def method_missing(method, *args) #:nodoc:
|
29
|
+
if @parser.respond_to?(method)
|
30
|
+
@parser.__send__(method, *args)
|
31
|
+
else
|
32
|
+
super
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
class << self
|
37
|
+
# When a Response is extended, the superclass's parameters are copied
|
38
|
+
# into the new class. This behavior has the following side-effect: if
|
39
|
+
# parameters are added to the superclass after it has been extended,
|
40
|
+
# those new paramters won't be passed on to its children. This shouldn't
|
41
|
+
# be a problem in most cases.
|
42
|
+
def inherited(subclass)
|
43
|
+
@parameters.each do |name, options|
|
44
|
+
subclass.parameter(name, options)
|
45
|
+
end if @parameters
|
46
|
+
|
47
|
+
subclass.parser(@parser) if @parser
|
48
|
+
end
|
49
|
+
|
50
|
+
# Specifes a parameter that will be automatically parsed when the
|
51
|
+
# Response is instantiated.
|
52
|
+
#
|
53
|
+
# Options:
|
54
|
+
# - <tt>:attribute</tt>: An attribute name to use, or <tt>true</tt> to
|
55
|
+
# use the <tt>:element</tt> value as the attribute name on the root.
|
56
|
+
# - <tt>:collection</tt>: A class used to instantiate each item when
|
57
|
+
# selecting a collection of elements.
|
58
|
+
# - <tt>:element</tt>: The XML element name.
|
59
|
+
# - <tt>:object</tt>: A class used to instantiate an element.
|
60
|
+
# - <tt>:type</tt>: The type of the parameter. Should be one of
|
61
|
+
# <tt>:text</tt>, <tt>:integer</tt>, <tt>:float</tt>, or <tt>:date</tt>.
|
62
|
+
def parameter(name, options={})
|
63
|
+
attr_accessor name
|
64
|
+
@parameters ||= {}
|
65
|
+
@parameters[name] = options
|
66
|
+
end
|
67
|
+
|
68
|
+
# Specifies the parser to use when decoding the server response. If no
|
69
|
+
# parser is specified for the response, then the default parser will be
|
70
|
+
# used.
|
71
|
+
#
|
72
|
+
# See Relax::Parsers for a list of available parsers.
|
73
|
+
def parser(name)
|
74
|
+
@parser ||= name
|
75
|
+
end
|
76
|
+
|
77
|
+
def ===(response)
|
78
|
+
response.is_a?(Class) ? response.ancestors.include?(self) : super
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
@@ -0,0 +1,102 @@
|
|
1
|
+
require 'openssl'
|
2
|
+
require 'net/https'
|
3
|
+
require 'uri'
|
4
|
+
require 'date'
|
5
|
+
require 'base64'
|
6
|
+
require 'erb'
|
7
|
+
|
8
|
+
module Relax
|
9
|
+
# Service is the starting point for any REST consumer written with Relax. It
|
10
|
+
# is responsible for setting up the endpoint for the service, and issuing the
|
11
|
+
# HTTP requests for each call made.
|
12
|
+
#
|
13
|
+
# == Extending Service
|
14
|
+
#
|
15
|
+
# When writing consumers, you should start by extending Service by inheriting
|
16
|
+
# from it and calling its constructor with the endpoint for the service.
|
17
|
+
#
|
18
|
+
# === Example
|
19
|
+
#
|
20
|
+
# class Service < Relax::Service
|
21
|
+
# ENDPOINT = 'http://example.com/services/rest/'
|
22
|
+
#
|
23
|
+
# def initialize
|
24
|
+
# super(ENDPOINT)
|
25
|
+
# end
|
26
|
+
# end
|
27
|
+
#
|
28
|
+
# == Calling a Service
|
29
|
+
#
|
30
|
+
# Each call made to the service goes through the #call method of Service,
|
31
|
+
# which takes in a Request object and a Response class. The Request object is
|
32
|
+
# used to generate the query string that will be passed to the endpoint. The
|
33
|
+
# Reponse class is instantiated with the body of the response from the HTTP
|
34
|
+
# request.
|
35
|
+
#
|
36
|
+
# === Example
|
37
|
+
#
|
38
|
+
# This example show how to create a barebones call. This module can be then
|
39
|
+
# included into your Service class.
|
40
|
+
#
|
41
|
+
# module Search
|
42
|
+
# class SearchRequest < Relax::Request
|
43
|
+
# end
|
44
|
+
#
|
45
|
+
# class SearchResponse < Relax::Response
|
46
|
+
# end
|
47
|
+
#
|
48
|
+
# def search(options = {})
|
49
|
+
# call(SearchRequest.new(options), SearchResponse)
|
50
|
+
# end
|
51
|
+
# end
|
52
|
+
#
|
53
|
+
class Service
|
54
|
+
attr_reader :endpoint
|
55
|
+
|
56
|
+
# This constructor should be called from your Service with the endpoint URL
|
57
|
+
# for the REST service.
|
58
|
+
def initialize(endpoint)
|
59
|
+
@endpoint = URI::parse(endpoint)
|
60
|
+
end
|
61
|
+
|
62
|
+
protected
|
63
|
+
|
64
|
+
# Calls the service using a query built from the Request object passed in
|
65
|
+
# as its first parameter. Once the response comes back from the service,
|
66
|
+
# the body of the response is used to instantiate the response class, and
|
67
|
+
# this response object is returned.
|
68
|
+
def call(request, response_class)
|
69
|
+
request.valid?
|
70
|
+
uri = @endpoint.clone
|
71
|
+
uri.query = query(request).to_s
|
72
|
+
response = request(uri)
|
73
|
+
puts "Response:\n#{response.body}\n\n" if $DEBUG
|
74
|
+
response_class.new(response.body)
|
75
|
+
end
|
76
|
+
|
77
|
+
private
|
78
|
+
|
79
|
+
def default_query
|
80
|
+
Query.new
|
81
|
+
end
|
82
|
+
|
83
|
+
def query(request)
|
84
|
+
Query.new(default_query.merge(request.to_query))
|
85
|
+
end
|
86
|
+
|
87
|
+
def request(uri)
|
88
|
+
puts "Request:\n#{uri.to_s}\n\n" if $DEBUG
|
89
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
90
|
+
|
91
|
+
if uri.scheme == 'https'
|
92
|
+
http.use_ssl = true
|
93
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_NONE
|
94
|
+
end
|
95
|
+
|
96
|
+
http.start do |http|
|
97
|
+
request = Net::HTTP::Get.new("#{uri.path}?#{uri.query}")
|
98
|
+
http.request(request)
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
@@ -0,0 +1,79 @@
|
|
1
|
+
module Relax
|
2
|
+
# SymbolicHash provides an extension of Hash, but one that only supports keys
|
3
|
+
# that are symbols. This has been done in an effort to prevent the case where
|
4
|
+
# both a string key and a symbol key are set on the same hash, and espcially
|
5
|
+
# for dealing with this particular case when convert the hash to a string.
|
6
|
+
#
|
7
|
+
# === Example
|
8
|
+
#
|
9
|
+
# hash = Relax::SymbolicHash.new
|
10
|
+
# hash[:one] = 1
|
11
|
+
# hash['one'] = 2
|
12
|
+
# puts hash[:one] # => 2
|
13
|
+
#
|
14
|
+
# === Credits
|
15
|
+
#
|
16
|
+
# Some of the inspiration (and code) for this class comes from the
|
17
|
+
# HashWithIndifferentAccess that ships with Rails.
|
18
|
+
class SymbolicHash < Hash
|
19
|
+
def initialize(constructor = {})
|
20
|
+
if constructor.is_a?(Hash)
|
21
|
+
super()
|
22
|
+
update(constructor)
|
23
|
+
else
|
24
|
+
super(constructor)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def [](key)
|
29
|
+
super(convert_key(key))
|
30
|
+
end
|
31
|
+
|
32
|
+
def []=(key, value)
|
33
|
+
super(convert_key(key), convert_value(value))
|
34
|
+
end
|
35
|
+
|
36
|
+
def update(other_hash)
|
37
|
+
other_hash.each_pair { |key, value| store(convert_key(key), convert_value(value)) }
|
38
|
+
self
|
39
|
+
end
|
40
|
+
alias :merge! :update
|
41
|
+
|
42
|
+
def fetch(key, *extras)
|
43
|
+
super(convert_key(key), *extras)
|
44
|
+
end
|
45
|
+
|
46
|
+
def values_at(*indices)
|
47
|
+
indices.collect { |key| self[convert_key(key)] }
|
48
|
+
end
|
49
|
+
|
50
|
+
def dup
|
51
|
+
SymbolicHash.new(self)
|
52
|
+
end
|
53
|
+
|
54
|
+
def merge(hash)
|
55
|
+
self.dup.update(hash)
|
56
|
+
end
|
57
|
+
|
58
|
+
def delete(key)
|
59
|
+
super(convert_key(key))
|
60
|
+
end
|
61
|
+
|
62
|
+
def key?(key)
|
63
|
+
super(convert_key(key))
|
64
|
+
end
|
65
|
+
alias :include? :key?
|
66
|
+
alias :has_key? :key?
|
67
|
+
alias :member? :key?
|
68
|
+
|
69
|
+
def convert_key(key)
|
70
|
+
!key.kind_of?(Symbol) ? key.to_sym : key
|
71
|
+
end
|
72
|
+
protected :convert_key
|
73
|
+
|
74
|
+
def convert_value(value)
|
75
|
+
value
|
76
|
+
end
|
77
|
+
protected :convert_value
|
78
|
+
end
|
79
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
describe 'a successfully parsed response', :shared => true do
|
2
|
+
it 'should allow access to the root' do
|
3
|
+
root = @response.root
|
4
|
+
root.should_not be_nil
|
5
|
+
root.name.should eql('RESTResponse')
|
6
|
+
end
|
7
|
+
|
8
|
+
it 'should be checkable by the name of its root' do
|
9
|
+
@response.is?(:RESTResponse).should be_true
|
10
|
+
end
|
11
|
+
|
12
|
+
it 'should allow access to an element by its name' do
|
13
|
+
@response.element(:RequestId).should_not be_nil
|
14
|
+
end
|
15
|
+
|
16
|
+
it 'should allow access to an element\'s elements by its name' do
|
17
|
+
tokens = @response.elements(:Tokens)
|
18
|
+
tokens.should respond_to(:each)
|
19
|
+
tokens.should_not be_empty
|
20
|
+
end
|
21
|
+
|
22
|
+
it 'should allow access to an element\'s value by its name' do
|
23
|
+
token = Relax::Response.new(@response.elements(:Tokens).first)
|
24
|
+
token.element(:TokenId).inner_text.should eql('JPMQARDVJK')
|
25
|
+
token.element(:Status).inner_text.should eql('Active')
|
26
|
+
end
|
27
|
+
|
28
|
+
it 'should have a means of checking for the existence of a node' do
|
29
|
+
@response.has?(:Status).should_not be_nil
|
30
|
+
@response.has?(:Errors).should be_nil
|
31
|
+
end
|
32
|
+
|
33
|
+
it 'should set known parameters' do
|
34
|
+
@response.status.should eql('Success')
|
35
|
+
@response.request_id.should eql(44287)
|
36
|
+
@response.valid_request.should eql("true")
|
37
|
+
end
|
38
|
+
|
39
|
+
it 'should automatically pull parameters from the XML' do
|
40
|
+
@response.tokens.length.should eql(2)
|
41
|
+
@response.tokens.first.status.should eql('Active')
|
42
|
+
@response.error.code.should eql(1)
|
43
|
+
@response.error.message.should eql('Failed')
|
44
|
+
end
|
45
|
+
|
46
|
+
it 'should raise MissingParameter if required parameters are missing' do
|
47
|
+
proc { @response.class.new('') }.should raise_error(Relax::MissingParameter)
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
require File.dirname(__FILE__) + '/../spec_helper'
|
2
|
+
|
3
|
+
require 'relax'
|
4
|
+
require 'relax/parsers/factory'
|
5
|
+
|
6
|
+
class TestParser ; end
|
7
|
+
|
8
|
+
describe 'a parser factory' do
|
9
|
+
|
10
|
+
before(:each) do
|
11
|
+
@factory = Relax::Parsers::Factory
|
12
|
+
Relax::Parsers::Factory.register(:test, TestParser)
|
13
|
+
end
|
14
|
+
|
15
|
+
it 'should raise UnrecognizedParser for un-registered names' do
|
16
|
+
lambda {
|
17
|
+
@factory.get(:bad_name)
|
18
|
+
}.should raise_error(Relax::UnrecognizedParser)
|
19
|
+
end
|
20
|
+
|
21
|
+
it 'should return a registered parser class' do
|
22
|
+
@factory.get(:test).should ==TestParser
|
23
|
+
end
|
24
|
+
|
25
|
+
it 'should register the first registered parser as the default' do
|
26
|
+
@factory.get(:default).should ==Relax::Parsers::Hpricot
|
27
|
+
end
|
28
|
+
|
29
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
require File.dirname(__FILE__) + '/../spec_helper'
|
2
|
+
require File.dirname(__FILE__) + '/../parser_helper'
|
3
|
+
|
4
|
+
class HpricotTestResponse < Relax::Response
|
5
|
+
class Token < Relax::Response
|
6
|
+
parser :hpricot
|
7
|
+
parameter :token_id, :element => :tokenid
|
8
|
+
parameter :status
|
9
|
+
end
|
10
|
+
|
11
|
+
class Error < Relax::Response
|
12
|
+
parser :hpricot
|
13
|
+
parameter :code, :type => :integer
|
14
|
+
parameter :message
|
15
|
+
end
|
16
|
+
|
17
|
+
parser :hpricot
|
18
|
+
parameter :status, :required => true
|
19
|
+
parameter :request_id, :element => :requestid, :type => :integer
|
20
|
+
parameter :valid_request, :element => :requestid, :attribute => :valid
|
21
|
+
parameter :tokens, :collection => Token
|
22
|
+
parameter :error, :type => Error
|
23
|
+
end
|
24
|
+
|
25
|
+
describe 'an Hpricot parser' do
|
26
|
+
before(:each) do
|
27
|
+
@response = HpricotTestResponse.new(XML)
|
28
|
+
end
|
29
|
+
|
30
|
+
it_should_behave_like 'a successfully parsed response'
|
31
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
require File.dirname(__FILE__) + '/../spec_helper'
|
2
|
+
require File.dirname(__FILE__) + '/../parser_helper'
|
3
|
+
|
4
|
+
class RexmlTestResponse < Relax::Response
|
5
|
+
class Token < Relax::Response
|
6
|
+
parser :rexml
|
7
|
+
parameter :token_id, :element => 'TokenId'
|
8
|
+
parameter :status, :element => 'Status'
|
9
|
+
end
|
10
|
+
|
11
|
+
class Error < Relax::Response
|
12
|
+
parser :rexml
|
13
|
+
parameter :code, :element => 'Code', :type => :integer
|
14
|
+
parameter :message, :element => 'Message'
|
15
|
+
end
|
16
|
+
|
17
|
+
parser :rexml
|
18
|
+
parameter :status, :element => 'Status', :required => true
|
19
|
+
parameter :request_id, :element => 'RequestId', :type => :integer
|
20
|
+
parameter :valid_request, :element => 'RequestId', :attribute => :valid
|
21
|
+
parameter :namespace, :element => 'Namespace', :namespace => 'ns1'
|
22
|
+
parameter :tokens, :element => 'Tokens', :collection => Token
|
23
|
+
parameter :error, :element => 'Error', :type => Error
|
24
|
+
end
|
25
|
+
|
26
|
+
describe 'a REXML parser' do
|
27
|
+
before(:each) do
|
28
|
+
@response = RexmlTestResponse.new(XML)
|
29
|
+
end
|
30
|
+
|
31
|
+
it_should_behave_like 'a successfully parsed response'
|
32
|
+
|
33
|
+
it 'should parse namespaced parameters' do
|
34
|
+
@response.namespace.should eql('Passed')
|
35
|
+
end
|
36
|
+
end
|
data/spec/query_spec.rb
ADDED
@@ -0,0 +1,60 @@
|
|
1
|
+
require File.dirname(__FILE__) + '/spec_helper'
|
2
|
+
|
3
|
+
require 'relax/query'
|
4
|
+
|
5
|
+
describe 'a query' do
|
6
|
+
before(:each) do
|
7
|
+
@uri = URI::parse('http://example.com/?action=search&query=keyword')
|
8
|
+
@query = Relax::Query.new
|
9
|
+
end
|
10
|
+
|
11
|
+
it 'should convert to a query string' do
|
12
|
+
@query[:action] = 'Search'
|
13
|
+
@query[:query] = 'strings'
|
14
|
+
@query.to_s.should eql('action=Search&query=strings')
|
15
|
+
end
|
16
|
+
|
17
|
+
it 'should convert its values to strings' do
|
18
|
+
date = Date.today
|
19
|
+
@query[:date] = date
|
20
|
+
@query.to_s.should eql("date=#{date.to_s}")
|
21
|
+
end
|
22
|
+
|
23
|
+
it 'should escape its values using "+" instead of "%20"' do
|
24
|
+
Relax::Query.send(:escape_value, 'two words').should == 'two+words'
|
25
|
+
end
|
26
|
+
|
27
|
+
it 'should sort its parameters' do
|
28
|
+
@query[:charlie] = 3
|
29
|
+
@query[:alpha] = 1
|
30
|
+
@query[:bravo] = 2
|
31
|
+
@query.to_s.should eql('alpha=1&bravo=2&charlie=3')
|
32
|
+
end
|
33
|
+
|
34
|
+
it 'should encode its parameter values' do
|
35
|
+
@query[:spaces] = 'two words'
|
36
|
+
@query[:url] = 'http://example.com/'
|
37
|
+
@query.to_s.should eql('spaces=two+words&url=http%3A%2F%2Fexample.com%2F')
|
38
|
+
end
|
39
|
+
|
40
|
+
it 'should be able to parse query strings' do
|
41
|
+
parsed_query = Relax::Query.parse(@uri)
|
42
|
+
parsed_query[:action].should eql('search')
|
43
|
+
parsed_query[:query].should eql('keyword')
|
44
|
+
end
|
45
|
+
|
46
|
+
it 'should parse key value pairs into only two parts' do
|
47
|
+
parsed_query = Relax::Query.parse(URI.parse("http://example.com/?action=test=&foo=bar"))
|
48
|
+
parsed_query[:action].should eql('test=')
|
49
|
+
end
|
50
|
+
|
51
|
+
it 'should unescape query string key-value pair keys' do
|
52
|
+
parsed_query = Relax::Query.parse(URI.parse("http://example.com/?action%20helper=test"))
|
53
|
+
parsed_query[:"action helper"].should eql('test')
|
54
|
+
end
|
55
|
+
|
56
|
+
it 'should unescape query string key-value pair values' do
|
57
|
+
parsed_query = Relax::Query.parse(URI.parse("http://example.com/?action=test%20action"))
|
58
|
+
parsed_query[:action].should eql('test action')
|
59
|
+
end
|
60
|
+
end
|