tapi 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
data/.document ADDED
@@ -0,0 +1,5 @@
1
+ lib/**/*.rb
2
+ bin/*
3
+ -
4
+ features/**/*.feature
5
+ LICENSE.txt
data/Gemfile ADDED
@@ -0,0 +1,13 @@
1
+ source "http://rubygems.org"
2
+ gem "activesupport", "~> 2.3.5"
3
+ gem 'curb', "~> 0.7.10"
4
+ gem 'json', "~> 1.5.1"
5
+
6
+ # Add dependencies to develop your gem here.
7
+ # Include everything needed to run rake, tests, features, etc.
8
+ group :development do
9
+ gem "rspec", "~> 2.3.0"
10
+ gem "bundler", "~> 1.0.0"
11
+ gem "jeweler", "~> 1.5.2"
12
+ gem "rcov", ">= 0"
13
+ end
data/Gemfile.lock ADDED
@@ -0,0 +1,34 @@
1
+ GEM
2
+ remote: http://rubygems.org/
3
+ specs:
4
+ activesupport (2.3.11)
5
+ curb (0.7.10)
6
+ diff-lcs (1.1.2)
7
+ git (1.2.5)
8
+ jeweler (1.5.2)
9
+ bundler (~> 1.0.0)
10
+ git (>= 1.2.5)
11
+ rake
12
+ json (1.5.1)
13
+ rake (0.8.7)
14
+ rcov (0.9.9)
15
+ rspec (2.3.0)
16
+ rspec-core (~> 2.3.0)
17
+ rspec-expectations (~> 2.3.0)
18
+ rspec-mocks (~> 2.3.0)
19
+ rspec-core (2.3.1)
20
+ rspec-expectations (2.3.0)
21
+ diff-lcs (~> 1.1.2)
22
+ rspec-mocks (2.3.0)
23
+
24
+ PLATFORMS
25
+ ruby
26
+
27
+ DEPENDENCIES
28
+ activesupport (~> 2.3.5)
29
+ bundler (~> 1.0.0)
30
+ curb (~> 0.7.10)
31
+ jeweler (~> 1.5.2)
32
+ json (~> 1.5.1)
33
+ rcov
34
+ rspec (~> 2.3.0)
data/LICENSE.txt ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2011 Travel IQ
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.rdoc ADDED
@@ -0,0 +1,152 @@
1
+ = tapi - Client for the Travel IQ API
2
+
3
+ This is the reference client implementation in Ruby for the Travel IQ API.
4
+
5
+ The API is a RESTful webservice which enables you to build meta travel search services or programs.
6
+
7
+ For the full documentation, visit http://apiv3.travel-iq.com/
8
+
9
+ To access the API, you need an API key - you can get that by signing up for the Travel IQ affiliate program at http://www.travel-iq.com/partners .
10
+
11
+ == Usage
12
+
13
+ The TAPI client provides both easy HTTP request handling (with built-in, transparent HTTP etag handling), and a Parser that
14
+ returns response data as dynamic documents that allow you to access the data and do subsequent requests with ease.
15
+
16
+ This is best illustrated with an example. Here's how you autocomplete a location, start a hotel search, and query the results, using the tapi.
17
+
18
+ Load up tapi in an irb session:
19
+
20
+ $ irb
21
+ => require 'rubygems'
22
+ => gem 'tapi'
23
+ => require 'tapi'
24
+
25
+ Get the first city matching the name "Berlin":
26
+
27
+ => city = TAPI::V3::Client.new_from_get("http://apiv3.travel-iq.com/locations/cities/name/Berlin.json?key=traveliq").first.cities.first
28
+
29
+ The client outputs what it's doing:
30
+
31
+ >> TAPI GET 0.221213 http://apiv3.travel-iq.com/locations/cities/name/Berlin.json?key=traveliq {} "504f7ed4c2170bac4a8537eea29aa7b8"
32
+ >> Unknown ETag.
33
+
34
+ And this is the result:
35
+
36
+ >> #<TAPI::V3::Client:0x1589a48
37
+ @document=
38
+ {:distance=>nil,
39
+ :latitude=>52.5166667,
40
+ :country=>"Deutschland",
41
+ :country_code=>"DE",
42
+ :population=>3383782,
43
+ :longitude=>13.4,
44
+ :resources=>
45
+ #<TAPI::V3::Client:0x1588e90
46
+ @document=
47
+ {:hotels_url=>
48
+ "http://apiv3.travel-iq.com/locations/cities/40784/hotels.json",
49
+ :city_url=>"http://apiv3.travel-iq.com/locations/cities/40784.json",
50
+ :airports_url=>
51
+ "http://apiv3.travel-iq.com/locations/cities/40784/airports.json",
52
+ :country_url=>"http://apiv3.travel-iq.com/locations/countries/10.json",
53
+ :description_url=>
54
+ "http://apiv3.travel-iq.com/locations/cities/40784/description.json"}>,
55
+ :name=>"Berlin",
56
+ :id=>40784,
57
+ :display_name=>"Berlin - Deutschland"}>
58
+
59
+ Since all results are again instances of TAPI::V3::Client, you can both get the data with accessor methods:
60
+
61
+ >> city.name
62
+ => "Berlin"
63
+ >> city.latitude
64
+ => 52.5166667
65
+
66
+ And query further parts of the API, which are always linked by the "resources" key in the API responses, by prepending "fetch_" to the resource name:
67
+
68
+ >> city.fetch_country
69
+ => #<TAPI::V3::Client:0x2137048
70
+ @document=
71
+ {:country=>
72
+ #<TAPI::V3::Client:0x21367b0
73
+ @document=
74
+ {:currency=>"EUR",
75
+ :code=>"DE",
76
+ :population=>83536115,
77
+ :recources=>
78
+ #<TAPI::V3::Client:0x2136648
79
+ @document=
80
+ {:country_url=>
81
+ "http://apiv3.travel-iq.com/locations/countries/10.json",
82
+ :cities_url=>
83
+ "http://apiv3.travel-iq.com/locations/countries/10/cities.json"}>,
84
+ :name=>"Deutschland",
85
+ :id=>10}>},
86
+ @etag="\"3a762ad93a79192f474407a1d0a1f059\"">
87
+
88
+ To start a search, you first need to configure the Search class, and set up the parameters in an instance:
89
+
90
+ >> TAPI::V3::Hotels::Search.config = {:key => "traveliq", :host => "apiv3.travel-iq.com", :port => "80", :path => ''}
91
+ >> search = TAPI::V3::Hotels::Search.new({:arrival_date => Date.today + 1, :departure_date => Date.today + 2, :city_id => city.id, :room_configuration => "[A]"})
92
+
93
+ You can validate the parameters like in ActiveRecord with #valid?
94
+ When everything's set up, start the search:
95
+
96
+ >> search.start!
97
+
98
+ The search will take a little while to finish. You can query its status like this (note the reload, which does a new request to the API):
99
+
100
+ >> search.reload.status_detailed.state
101
+ => "running"
102
+
103
+ Later:
104
+
105
+ >> search.reload.status_detailed.state
106
+ => "finished"
107
+
108
+ You can look at the results of a search at any time. Keep in mind, though, that results will expire after a while - display results while the search is running or when finished,
109
+ don't cache them in your application.
110
+
111
+ >> search.fetch_results.search.results.first.price
112
+ => 24.0
113
+
114
+
115
+ == Installation
116
+
117
+ (sudo) gem install tapi
118
+
119
+ Or, install latest version from Github with bundler by adding this to your Gemfile:
120
+
121
+ gem 'tapi', :git => 'git://github.com/traveliq/tapi.git'
122
+
123
+ == Dependencies and Rubies
124
+
125
+ You'll need the ActiveSupport, curl, and json gems, but that should be handled automatically.
126
+ The client is tested under Ruby 1.8.7 (REE). It may not work well under 1.9 - feel free to contribute !
127
+
128
+ == Authors
129
+
130
+ Dr. Florian Odronitz (odo@mac.com)
131
+ Matthias Georgi (http://www.matthias-georgi.de)
132
+ Github release by Martin Tepper (monogreen.de)
133
+
134
+ == Contact
135
+
136
+ For questions, contact the authors or developer@traveliq.net
137
+
138
+ == Contributing to tapi
139
+
140
+ * Check out the latest master to make sure the feature hasn't been implemented or the bug hasn't been fixed yet
141
+ * Check out the issue tracker to make sure someone already hasn't requested it and/or contributed it
142
+ * Fork the project
143
+ * Start a feature/bugfix branch
144
+ * Commit and push until you are happy with your contribution
145
+ * Make sure to add tests for it. This is important so I don't break it in a future version unintentionally.
146
+ * Please try not to mess with the Rakefile, version, or history. If you want to have your own version, or is otherwise necessary, that is fine, but please isolate to its own commit so I can cherry-pick around it.
147
+
148
+ == Copyright
149
+
150
+ Copyright (c) 2011 Travel IQ. See LICENSE.txt for
151
+ further details.
152
+
data/Rakefile ADDED
@@ -0,0 +1,50 @@
1
+ require 'rubygems'
2
+ require 'bundler'
3
+ begin
4
+ Bundler.setup(:default, :development)
5
+ rescue Bundler::BundlerError => e
6
+ $stderr.puts e.message
7
+ $stderr.puts "Run `bundle install` to install missing gems"
8
+ exit e.status_code
9
+ end
10
+ require 'rake'
11
+
12
+ require 'jeweler'
13
+ Jeweler::Tasks.new do |gem|
14
+ # gem is a Gem::Specification... see http://docs.rubygems.org/read/chapter/20 for more options
15
+ gem.name = "tapi"
16
+ gem.homepage = "http://github.com/traveliq/tapi"
17
+ gem.license = "MIT"
18
+ gem.summary = %Q{Reference client to the Travel IQ API at http://apiv3.travel-iq.com/}
19
+ gem.description = gem.summary
20
+ gem.email = "developer@traveliq.net"
21
+ gem.authors = ["Martin Tepper"]
22
+ # Include your dependencies below. Runtime dependencies are required when using your gem,
23
+ # and development dependencies are only needed for development (ie running rake tasks, tests, etc)
24
+ # gem.add_runtime_dependency 'jabber4r', '> 0.1'
25
+ # gem.add_development_dependency 'rspec', '> 1.2.3'
26
+ end
27
+ Jeweler::RubygemsDotOrgTasks.new
28
+
29
+ require 'rspec/core'
30
+ require 'rspec/core/rake_task'
31
+ RSpec::Core::RakeTask.new(:spec) do |spec|
32
+ spec.pattern = FileList['spec/**/*_spec.rb']
33
+ end
34
+
35
+ RSpec::Core::RakeTask.new(:rcov) do |spec|
36
+ spec.pattern = 'spec/**/*_spec.rb'
37
+ spec.rcov = true
38
+ end
39
+
40
+ task :default => :spec
41
+
42
+ require 'rake/rdoctask'
43
+ Rake::RDocTask.new do |rdoc|
44
+ version = File.exist?('VERSION') ? File.read('VERSION') : ""
45
+
46
+ rdoc.rdoc_dir = 'rdoc'
47
+ rdoc.title = "tapi #{version}"
48
+ rdoc.rdoc_files.include('README*')
49
+ rdoc.rdoc_files.include('lib/**/*.rb')
50
+ end
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.2.0
data/lib/tapi.rb ADDED
@@ -0,0 +1,9 @@
1
+ puts require 'active_support'
2
+ require 'tapi/v3/configurable'
3
+ require 'tapi/v3/errors'
4
+ require 'tapi/v3/client'
5
+ require 'tapi/v3/validations'
6
+ require 'tapi/v3/utils'
7
+ require 'tapi/v3/generic_search'
8
+ require 'tapi/v3/hotels/search'
9
+ require 'tapi/v3/flights/search'
@@ -0,0 +1,261 @@
1
+ # -*- coding: utf-8 -*-
2
+ module TAPI
3
+ module V3
4
+ class Client
5
+ include Configurable
6
+
7
+ require 'uri'
8
+ require 'digest'
9
+ require 'curl'
10
+ require 'json'
11
+ require 'logger'
12
+
13
+ attr_reader :etag, :document
14
+
15
+ HTTP_ERRORS = {
16
+ 302 => MovedError,
17
+ 205 => ExpiredError,
18
+ 401 => InternalServerError,
19
+ 404 => NotFoundError,
20
+ 500 => InternalServerError
21
+ }
22
+
23
+ class << self
24
+
25
+ def http_authentication
26
+ self.class.http_authentication
27
+ end
28
+
29
+ def new_from_post(url, params)
30
+ curl, server_etag = execute_request(:post, url, params)
31
+ new(JSON.parse(curl.body_str), nil, true)
32
+ end
33
+
34
+ def new_from_get(url, options = {}, etag = nil)
35
+ klass = options.delete(:instanciate_as) || self
36
+ curl, server_etag = execute_request(:get, url, options, etag)
37
+
38
+ if server_etag && etag == server_etag
39
+ logger.debug "Known ETag."
40
+ [nil, server_etag]
41
+ else
42
+ logger.debug "Unknown ETag."
43
+ [klass.new(JSON.parse(curl.body_str), server_etag, true), server_etag]
44
+ end
45
+ end
46
+
47
+ def check_for_errors(curl)
48
+ error_class = HTTP_ERRORS[curl.response_code]
49
+ error_class ||= Error if (401..599).include?(curl.response_code)
50
+
51
+ if error_class
52
+ error = error_class.new
53
+ error.request_url = curl.url
54
+ error.response_code = curl.response_code
55
+ error.response_body = curl.body_str
56
+ raise error
57
+ end
58
+ end
59
+
60
+ def parse_etag(str)
61
+ str.split("\r\n").grep(/^etag/i).first.split(' ').last if str =~ /^etag/i
62
+ end
63
+
64
+ def execute_request(method, url, params, etag = nil)
65
+ url = base_url + url
66
+ url = Utils.append_query(url, params) if Hash === params and method == :get
67
+
68
+ curl = Curl::Easy.new(url)
69
+
70
+ if auth = http_authentication
71
+ curl.userpwd = auth
72
+ end
73
+
74
+ curl.headers["If-None-Match"] = etag if etag
75
+
76
+ server_etag = nil
77
+
78
+ time = Time.now
79
+ case method
80
+ when :get
81
+ curl.http_get
82
+ server_etag = parse_etag(curl.header_str)
83
+
84
+ when :post
85
+ fields = params.to_a.map {|key, value| Curl::PostField.content(key.to_s, value.to_s)}
86
+
87
+ curl.http_post(fields)
88
+ end
89
+
90
+ logger.debug "TAPI #{method.to_s.upcase} #{Time.now - time} #{url} #{params.inspect} #{etag} #{server_etag}"
91
+
92
+ check_for_errors(curl)
93
+
94
+ [curl, server_etag]
95
+ end
96
+
97
+
98
+ def logger
99
+ Thread.current[:tapi_logger] || Logger.new(STDOUT)
100
+ end
101
+
102
+ def logger=(logger)
103
+ Thread.current[:tapi_logger] = logger
104
+ end
105
+
106
+ def base_url
107
+ config[:base_url] || ""
108
+ end
109
+
110
+ def http_authentication
111
+ if config[:http_password] && config[:http_user_name]
112
+ "#{config[:http_user_name]}:#{config[:http_password]}"
113
+ else
114
+ nil
115
+ end
116
+ end
117
+
118
+
119
+ end # end class methods
120
+
121
+ def initialize(hash, etag = nil, is_root = false)
122
+ @document = {}
123
+ @etag = etag if etag
124
+
125
+ update(hash)
126
+ end
127
+
128
+ def logger
129
+ self.class.logger
130
+ end
131
+
132
+ undef id if instance_methods.include?('id')
133
+
134
+ def to_hash
135
+ @document.inject({}) do |hash, (key, val)|
136
+ hash[key] = \
137
+ case val
138
+ when Client then val.to_hash
139
+ when Array then val.map {|v| Client === v ? v.to_hash : v }
140
+ else val
141
+ end
142
+ hash
143
+ end
144
+ end
145
+
146
+ def to_json
147
+ to_hash.to_json
148
+ end
149
+
150
+ def attributes
151
+ @document.keys.map(&:to_s)
152
+ end
153
+
154
+ def urls
155
+ if search = @document[:search]
156
+ search.urls
157
+ elsif @document[:resources]
158
+ @document[:resources].to_hash
159
+ else
160
+ Hash.new
161
+ end
162
+ end
163
+
164
+ def remote_calls
165
+ urls.keys.map{|resource| "fetch_#{/(.*)_url$/.match(resource.to_s)[1]}"}.sort
166
+ end
167
+
168
+ def to_param
169
+ id
170
+ end
171
+
172
+ def respond_to?(key, include_private = false)
173
+ @document.has_key?(key) or
174
+ urls["#{url_key(key)}_url".to_sym] or
175
+ super(key, include_private)
176
+ end
177
+
178
+ def class_mapping
179
+ config[:class_mapping] ||= {}
180
+ end
181
+
182
+ protected
183
+
184
+ def update(hash)
185
+ hash.each do |key, value|
186
+ @document[key.to_sym] = \
187
+ case value
188
+ when Hash
189
+ client_class(key).new(value)
190
+ when Array
191
+ klass = client_class(key)
192
+ value.map { |e| Hash === e ? klass.new(e) : e }
193
+ when String
194
+ if value.respond_to?(:force_encoding)
195
+ value.force_encoding(Encoding::UTF_8)
196
+ else
197
+ value
198
+ end
199
+ else
200
+ value
201
+ end
202
+ end
203
+ end
204
+
205
+ def method_missing(key, *args)
206
+ if @document.has_key?(key)
207
+ @document[key]
208
+ else
209
+ if url = urls["#{url_key(key)}_url".to_sym]
210
+ options = args.first || {}
211
+ get(url, client_class(key), options)
212
+ else
213
+ raise NoMethodError, "undefined method `#{key}' for #{self.class}", caller
214
+ end
215
+ end
216
+ end
217
+
218
+ def url_key(key)
219
+ (match = /^fetch_(.*)/.match(key.to_s)) && match[1]
220
+ end
221
+
222
+ def remote_cache
223
+ @remote_cache ||= {}
224
+ end
225
+
226
+ def get(url, klass, options = {})
227
+ cache_key = cache_key(url, options)
228
+ cached_reply = remote_cache[cache_key]
229
+
230
+ return cached_reply[:data] if options.delete(:skip_refresh) and cached_reply
231
+ old_etag = cached_reply ? cached_reply[:etag] : nil
232
+
233
+ if options.delete(:skip_cache)
234
+ return self.class.new_from_get(url, options.merge(:instanciate_as => klass), nil).first
235
+ end
236
+
237
+ data, etag = self.class.new_from_get(url, options.merge(:instanciate_as => klass), old_etag)
238
+
239
+ if etag && etag == old_etag
240
+ logger.debug "ETag match. Returning data from internal cache."
241
+ remote_cache[cache_key][:data]
242
+ else
243
+ logger.debug "Returning fetched data."
244
+ if etag
245
+ remote_cache[cache_key] = {:data => data, :etag => etag}
246
+ end
247
+ data
248
+ end
249
+ end
250
+
251
+ def cache_key(url, options)
252
+ Digest::MD5.hexdigest(url.to_s + options.to_a.sort {|a, b| a.first.to_s <=> b.first.to_s }.flatten.join)
253
+ end
254
+
255
+ def client_class(name)
256
+ class_mapping[name.to_sym] || Client
257
+ end
258
+
259
+ end
260
+ end
261
+ end