tylerhunt-relax 0.0.5

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.
@@ -0,0 +1,95 @@
1
+ require 'relax/query'
2
+
3
+ module Relax
4
+ # Request is intended to be a parent class for requests passed to
5
+ # Service#call.
6
+ class Request
7
+ @parameters = {}
8
+
9
+ # New takes an optional hash of default parameter values. When passed,
10
+ # the values will be set on the request if the key exists as a valid
11
+ # parameter name.
12
+ def initialize(defaults = {})
13
+ # initialize default parameter values
14
+ self.class.parameters.each do |parameter, options|
15
+ if defaults.has_key?(parameter)
16
+ value = defaults[parameter]
17
+ elsif options[:value]
18
+ value = options[:value]
19
+ end
20
+
21
+ instance_variable_set("@#{parameter}", value) if value
22
+ end
23
+ end
24
+
25
+ # Converts this request into a Query object.
26
+ def to_query
27
+ self.class.parameters.keys.inject(Query.new) do |query, key|
28
+ value = send(key)
29
+ options = self.class.parameters[key]
30
+ if value && !options[:type]
31
+ query[convert_key(key)] = value if value
32
+ elsif options[:type]
33
+ options[:type].parameters.each do |parameter, options|
34
+ query[convert_complex_key(key, parameter)] = value.send(parameter) if value
35
+ end
36
+ end
37
+ query
38
+ end
39
+ end
40
+
41
+ # Converts this request into a query string for use in a URL.
42
+ def to_s
43
+ to_query.to_s
44
+ end
45
+
46
+ # Converts a key when the Request is converted to a query. By default, no
47
+ # conversion actually takes place, but this method can be overridden by
48
+ # child classes to perform standard manipulations, such as replacing
49
+ # underscores.
50
+ def convert_key(key)
51
+ key
52
+ end
53
+ protected :convert_key
54
+
55
+ # Converts a complex key (i.e. a parameter with a custom type) when the
56
+ # Request is converted to a query. By default, this means the key name and
57
+ # the parameter name separated by two underscores. This method can be
58
+ # overridden by child classes.
59
+ def convert_complex_key(key, parameter)
60
+ "#{convert_key(key)}.#{convert_key(parameter)}"
61
+ end
62
+ protected :convert_complex_key
63
+
64
+ class << self
65
+ # Create the parameters hash for the subclass.
66
+ def inherited(subclass) #:nodoc:
67
+ subclass.instance_variable_set('@parameters', {})
68
+ end
69
+
70
+ # Specifies a parameter to create on the request class.
71
+ #
72
+ # Options:
73
+ # - <tt>:type</tt>: An optional custom data type for the parameter.
74
+ # This must be a class that is a descendent of Request.
75
+ # - <tt>:value</tt>: The default value for this parameter.
76
+ def parameter(name, options = {})
77
+ attr_accessor name
78
+ options = @parameters[name].merge(options) if @parameters.has_key?(name)
79
+ @parameters[name] = options
80
+ end
81
+
82
+ # Adds a template value to a request class. Equivalent to creating a
83
+ # parameter with a default value.
84
+ def []=(key, value)
85
+ parameter(key, {:value => value})
86
+ end
87
+
88
+ # Returns a hash of all of the parameters for this request, including
89
+ # those that are inherited.
90
+ def parameters #:nodoc:
91
+ (superclass.respond_to?(:parameters) ? superclass.parameters : {}).merge(@parameters)
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,78 @@
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
21
+ self.class.instance_variable_get('@parser') || :default
22
+ end
23
+
24
+ def method_missing(method, *args) #:nodoc:
25
+ if @parser.respond_to?(method)
26
+ @parser.__send__(method, *args)
27
+ else
28
+ super
29
+ end
30
+ end
31
+
32
+ class << self
33
+ # When a Response is extended, the superclass's parameters are copied
34
+ # into the new class. This behavior has the following side-effect: if
35
+ # parameters are added to the superclass after it has been extended,
36
+ # those new paramters won't be passed on to its children. This shouldn't
37
+ # be a problem in most cases.
38
+ def inherited(subclass)
39
+ @parameters.each do |name, options|
40
+ subclass.parameter(name, options)
41
+ end if @parameters
42
+ subclass.parser(@parser) if @parser
43
+ end
44
+
45
+ # Specifes a parameter that will be automatically parsed when the
46
+ # Response is instantiated.
47
+ #
48
+ # Options:
49
+ # - <tt>:attribute</tt>: An attribute name to use, or <tt>true</tt> to
50
+ # use the <tt>:element</tt> value as the attribute name on the root.
51
+ # - <tt>:collection</tt>: A class used to instantiate each item when
52
+ # selecting a collection of elements.
53
+ # - <tt>:element</tt>: The XML element name.
54
+ # - <tt>:object</tt>: A class used to instantiate an element.
55
+ # - <tt>:type</tt>: The type of the parameter. Should be one of
56
+ # <tt>:text</tt>, <tt>:integer</tt>, <tt>:float</tt>, or <tt>:date</tt>.
57
+ def parameter(name, options = {})
58
+ attr_accessor name
59
+ @parameters ||= {}
60
+ @parameters[name] = options
61
+ end
62
+
63
+ # Specifies the parser to use when decoding the server response. If
64
+ # no parser is specified for the response, then the default parser will
65
+ # be used.
66
+ #
67
+ # See Relax::Parsers for a list of available parsers.
68
+ def parser(name)
69
+ @parser ||= name
70
+ end
71
+
72
+ def ===(response)
73
+ response.is_a?(Class) ? response.ancestors.include?(self) : super
74
+ end
75
+ end
76
+
77
+ end
78
+ end
@@ -0,0 +1,101 @@
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
+ uri = @endpoint.clone
70
+ uri.query = query(request).to_s
71
+ response = request(uri)
72
+ puts "Response:\n#{response.body}\n\n" if $DEBUG
73
+ response_class.new(response.body)
74
+ end
75
+
76
+ private
77
+
78
+ def default_query
79
+ Query.new
80
+ end
81
+
82
+ def query(request)
83
+ Query.new(default_query.merge(request.to_query))
84
+ end
85
+
86
+ def request(uri)
87
+ puts "Request:\n#{uri.to_s}\n\n" if $DEBUG
88
+ http = Net::HTTP.new(uri.host, uri.port)
89
+
90
+ if uri.scheme == 'https'
91
+ http.use_ssl = true
92
+ http.verify_mode = OpenSSL::SSL::VERIFY_NONE
93
+ end
94
+
95
+ http.start do |http|
96
+ request = Net::HTTP::Get.new("#{uri.path}?#{uri.query}")
97
+ http.request(request)
98
+ end
99
+ end
100
+ end
101
+ 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
+ protected
70
+
71
+ def convert_key(key)
72
+ !key.kind_of?(Symbol) ? key.to_sym : key
73
+ end
74
+
75
+ def convert_value(value)
76
+ value
77
+ end
78
+ end
79
+ end
data/lib/relax.rb ADDED
@@ -0,0 +1,13 @@
1
+ $:.unshift(File.dirname(__FILE__)) unless $:.include?(File.dirname(__FILE__)) || $:.include?(File.expand_path(File.dirname(__FILE__)))
2
+
3
+ require 'relax/query'
4
+ require 'relax/parsers'
5
+ require 'relax/request'
6
+ require 'relax/response'
7
+ require 'relax/service'
8
+ require 'relax/symbolic_hash'
9
+
10
+ module Relax
11
+ class MissingParameter < ArgumentError ; end
12
+ class UnrecognizedParser < ArgumentError ; end
13
+ 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,35 @@
1
+ require File.dirname(__FILE__) + '/../spec_helper'
2
+ require File.dirname(__FILE__) + '/../parser_helper'
3
+
4
+
5
+ class HpricotTestResponse < Relax::Response
6
+ class Token < Relax::Response
7
+ parser :hpricot
8
+ parameter :token_id, :element => :tokenid
9
+ parameter :status
10
+ end
11
+
12
+ class Error < Relax::Response
13
+ parser :hpricot
14
+ parameter :code, :type => :integer
15
+ parameter :message
16
+ end
17
+
18
+ parser :hpricot
19
+ parameter :status, :required => true
20
+ parameter :request_id, :element => :requestid, :type => :integer
21
+ parameter :valid_request, :element => :requestid, :attribute => :valid
22
+ parameter :tokens, :collection => Token
23
+ parameter :error, :type => Error
24
+ end
25
+
26
+
27
+ describe 'an Hpricot parser' do
28
+
29
+ before(:each) do
30
+ @response = HpricotTestResponse.new(XML)
31
+ end
32
+
33
+ it_should_behave_like 'a successfully parsed response'
34
+
35
+ end
@@ -0,0 +1,40 @@
1
+ require File.dirname(__FILE__) + '/../spec_helper'
2
+ require File.dirname(__FILE__) + '/../parser_helper'
3
+
4
+
5
+ class RexmlTestResponse < Relax::Response
6
+ class Token < Relax::Response
7
+ parser :rexml
8
+ parameter :token_id, :element => 'TokenId'
9
+ parameter :status, :element => 'Status'
10
+ end
11
+
12
+ class Error < Relax::Response
13
+ parser :rexml
14
+ parameter :code, :element => 'Code', :type => :integer
15
+ parameter :message, :element => 'Message'
16
+ end
17
+
18
+ parser :rexml
19
+ parameter :status, :element => 'Status', :required => true
20
+ parameter :request_id, :element => 'RequestId', :type => :integer
21
+ parameter :valid_request, :element => 'RequestId', :attribute => :valid
22
+ parameter :namespace, :element => 'Namespace', :namespace => 'ns1'
23
+ parameter :tokens, :element => 'Tokens', :collection => Token
24
+ parameter :error, :element => 'Error', :type => Error
25
+ end
26
+
27
+
28
+ describe 'a REXML parser' do
29
+
30
+ before(:each) do
31
+ @response = RexmlTestResponse.new(XML)
32
+ end
33
+
34
+ it_should_behave_like 'a successfully parsed response'
35
+
36
+ it 'should parse namespaced parameters' do
37
+ @response.namespace.should eql('Passed')
38
+ end
39
+
40
+ end
@@ -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
@@ -0,0 +1,108 @@
1
+ require File.dirname(__FILE__) + '/spec_helper'
2
+
3
+ require 'relax/request'
4
+
5
+ class Amount < Relax::Request
6
+ parameter :amount
7
+ parameter :currency
8
+ end
9
+
10
+ class TestRequest < Relax::Request
11
+ parameter :action
12
+ parameter :token_id
13
+ parameter :user_id
14
+ parameter :amount, :type => Amount
15
+ end
16
+
17
+ class ChildRequest < TestRequest
18
+ parameter :child_id
19
+ end
20
+
21
+ describe 'an option initialized request', :shared => true do
22
+ it 'should have its values set by the options hash' do
23
+ request = TestRequest.new(:action => 'FetchAll', :token_id => 123)
24
+ request.action.should eql('FetchAll')
25
+ request.token_id.should eql(123)
26
+ request.user_id.should be_nil
27
+ end
28
+ end
29
+
30
+ describe 'a request that converts to a query', :shared => true do
31
+ before(:each) do
32
+ @query = TestRequest.new(:action => 'Search', :token_id => 123).to_query
33
+ end
34
+
35
+ it 'should include its parameters in the query' do
36
+ @query[:action].should eql('Search')
37
+ @query[:token_id].should eql('123')
38
+ @query[:user_id].should be_nil
39
+ @query[:amount].should be_nil
40
+ end
41
+
42
+ it 'should only include parameters in the query if they are set' do
43
+ @query.key?(:action).should be_true
44
+ @query.key?(:token_id).should be_true
45
+ @query.key?(:user_id).should be_false
46
+ @query.key?(:amount).should be_false
47
+ end
48
+ end
49
+
50
+ describe 'a normal request' do
51
+ it_should_behave_like 'a request that converts to a query'
52
+ it_should_behave_like 'an option initialized request'
53
+ end
54
+
55
+ describe 'a template request' do
56
+ it_should_behave_like 'a request that converts to a query'
57
+ it_should_behave_like 'an option initialized request'
58
+
59
+ before(:each) do
60
+ # this syntax may need to go away unless we can find a way to make it work
61
+ TestRequest[:api_key] = '123456'
62
+ TestRequest[:secret] = 'shhh!'
63
+ end
64
+
65
+ it 'should always have the template values in its query' do
66
+ request = TestRequest.new
67
+ request.api_key.should eql('123456')
68
+ request.secret.should eql('shhh!')
69
+ end
70
+
71
+ it 'should allow its template variables to be overridden' do
72
+ request = TestRequest.new(:secret => 'abracadabra')
73
+ request.api_key.should eql('123456')
74
+ request.secret.should eql('abracadabra')
75
+ end
76
+
77
+ it 'should pass its template on to its children' do
78
+ request = ChildRequest.new
79
+ request.api_key.should eql('123456')
80
+ request.secret.should eql('shhh!')
81
+ end
82
+
83
+ it 'should allow template parameters on its children that are additive' do
84
+ ChildRequest[:query] = '1a2b3c'
85
+ child = ChildRequest.new
86
+ child.api_key.should eql('123456')
87
+ child.secret.should eql('shhh!')
88
+ child.query.should eql('1a2b3c')
89
+
90
+ parent = TestRequest.new
91
+ parent.api_key.should eql('123456')
92
+ parent.secret.should eql('shhh!')
93
+ parent.respond_to?(:query).should be_false
94
+ end
95
+ end
96
+
97
+ describe 'a request with a custom type' do
98
+ before(:each) do
99
+ request = TestRequest.new(:action => 'Pay', :token_id => 123)
100
+ request.amount = Amount.new(:amount => 3.50, :currency => 'USD')
101
+ @query = request.to_query
102
+ end
103
+
104
+ it 'should add the type parameters to the query' do
105
+ @query.key?(:"amount.amount").should be_true
106
+ @query.key?(:"amount.currency").should be_true
107
+ end
108
+ end