lib/amee/connection.rb in amee-4.3.2 vs lib/amee/connection.rb in amee-4.4.0
- old
+ new
@@ -1,20 +1,29 @@
# Copyright (C) 2008-2011 AMEE UK Ltd. - http://www.amee.com
# Released as Open Source Software under the BSD 3-Clause license. See LICENSE.txt for details.
-require 'net/http'
-require 'net/https'
+require 'typhoeus'
+require 'json'
+require 'log_buddy'
+
+
+# LogBuddy.init :log_to_stdout => false
+LogBuddy.init :disabled => true
+# Set this to true to output curl debug messages in development
+DEBUG = false
+
module AMEE
class Connection
RootCA = File.dirname(__FILE__) + '/../../cacert.pem'
def initialize(server, username, password, options = {})
unless options.is_a?(Hash)
raise AMEE::ArgumentError.new("Fourth argument must be a hash of options!")
end
+
@server = server
@username = username
@password = password
@ssl = (options[:ssl] == false) ? false : true
@port = @ssl ? 443 : 80
@@ -23,19 +32,22 @@
@amee_source = options[:amee_source]
@retries = options[:retries] || 0
if !valid?
raise "You must supply connection details - server, username and password are all required!"
end
+
+ # Working with caching
+
# Handle old option
if options[:enable_caching]
Kernel.warn '[DEPRECATED] :enable_caching => true is deprecated. Use :cache => :memory_store instead'
options[:cache] ||= :memory_store
end
# Create cache store
if options[:cache] &&
(options[:cache_store].class.name == "ActiveSupport::Cache::MemCacheStore" ||
- options[:cache].to_sym == :mem_cache_store)
+ options[:cache].to_sym == :mem_cache_store)
raise 'ActiveSupport::Cache::MemCacheStore is not supported, as it doesn\'t allow regexp expiry'
end
if options[:cache_store].is_a?(ActiveSupport::Cache::Store)
# Allows assignment of the entire cache store in Rails apps
@cache = options[:cache_store]
@@ -44,37 +56,42 @@
@cache = ActiveSupport::Cache.lookup_store(options[:cache].to_sym, options[:cache_options])
else
@cache = ActiveSupport::Cache.lookup_store(options[:cache].to_sym)
end
end
- # Make connection to server
- @http = Net::HTTP.new(@server, @port)
+
+ # set up hash to pass to builder block
+ @params = {
+ :ssl => @ssl,
+ :params => {},
+ :headers => {}
+ }
+
if @ssl == true
- @http.use_ssl = true
+ @params[:ssl] = true
if File.exists? RootCA
- @http.ca_file = RootCA
- @http.verify_mode = OpenSSL::SSL::VERIFY_PEER
- @http.verify_depth = 5
+ @params[:ca_file] = RootCA
end
end
+
self.timeout = options[:timeout] || 60
- @http.set_debug_output($stdout) if options[:enable_debug]
@debug = options[:enable_debug]
end
attr_reader :format
attr_reader :server
attr_reader :username
attr_reader :password
attr_reader :retries
+ attr_accessor :auth_token #Only used in tests really
def timeout
- @http.read_timeout
+ @params[:timeout]
end
def timeout=(t)
- @http.open_timeout = @http.read_timeout = t
+ @params[:open_timeout] = @params[:timeout] = t
end
def version
authenticate if @version.nil?
@version
@@ -82,103 +99,146 @@
def valid?
@username && @password && @server
end
+ # check if we have a valid authentication token
def authenticated?
- !@auth_token.nil?
+ @auth_token =~ /^.*$/
end
+ # GET data from the API, passing in a hash of parameters
def get(path, data = {})
# Allow format override
format = data.delete(:format) || @format
- # Create URL parameters
- params = []
- data.each_pair do |key, value|
- params << "#{CGI::escape(key.to_s)}=#{CGI::escape(value.to_s)}"
- end
- if params.size > 0
- path += "?#{params.join('&')}"
- end
- # Send request
- cache(path) { do_request(Net::HTTP::Get.new(path), format) }
+ # Add parameters to URL query string
+ get_params = {
+ :method => "get",
+ :verbose => DEBUG
+ }
+ get_params[:params] = data unless data.empty?
+ # Create GET request
+ get = Typhoeus::Request.new("#{protocol}#{@server}#{path}", get_params)
+ # Send request
+ do_request(get, format, :cache => true)
end
+ # POST to the AMEE API, passing in a hash of values
def post(path, data = {})
# Allow format override
format = data.delete(:format) || @format
# Clear cache
expire_matching "#{raw_path(path)}.*"
+ # Extract return unit params
+ query_params = {}
+ query_params[:returnUnit] = data.delete(:returnUnit) if data[:returnUnit]
+ query_params[:returnPerUnit] = data.delete(:returnPerUnit) if data[:returnPerUnit]
# Create POST request
- post = Net::HTTP::Post.new(path)
- body = []
- data.each_pair do |key, value|
- body << "#{CGI::escape(key.to_s)}=#{CGI::escape(value.to_s)}"
- end
- post.body = body.join '&'
+ post_params = {
+ :verbose => DEBUG,
+ :method => "post",
+ :body => form_encode(data)
+ }
+ post_params[:params] = query_params unless query_params.empty?
+ post = Typhoeus::Request.new("#{protocol}#{@server}#{path}", post_params)
# Send request
- do_request(post, format)
+ do_request(post, format)
end
+ # POST to the AMEE API, passing in a string of data
def raw_post(path, body, options = {})
# Allow format override
format = options.delete(:format) || @format
# Clear cache
expire_matching "#{raw_path(path)}.*"
# Create POST request
- post = Net::HTTP::Post.new(path)
- post['Content-type'] = options[:content_type] || content_type(format)
- post.body = body
+ post = Typhoeus::Request.new("#{protocol}#{@server}#{path}",
+ :verbose => DEBUG,
+ :method => "post",
+ :body => body,
+ :headers => { :'Content-type' => options[:content_type] || content_type(format) }
+ )
+
# Send request
do_request(post, format)
end
+ # PUT to the AMEE API, passing in a hash of data
def put(path, data = {})
# Allow format override
format = data.delete(:format) || @format
# Clear cache
expire_matching "#{parent_path(path)}.*"
+ # Extract return unit params
+ query_params = {}
+ query_params[:returnUnit] = data.delete(:returnUnit) if data[:returnUnit]
+ query_params[:returnPerUnit] = data.delete(:returnPerUnit) if data[:returnPerUnit]
# Create PUT request
- put = Net::HTTP::Put.new(path)
- body = []
- data.each_pair do |key, value|
- body << "#{CGI::escape(key.to_s)}=#{CGI::escape(value.to_s)}"
- end
- put.body = body.join '&'
- # Send request
+ put_params = {
+ :verbose => DEBUG,
+ :method => "put",
+ :body => form_encode(data)
+ }
+ put_params[:params] = query_params unless query_params.empty?
+ put = Typhoeus::Request.new("#{protocol}#{@server}#{path}", put_params)
+ # Send request
do_request(put, format)
end
+ # PUT to the AMEE API, passing in a string of data
def raw_put(path, body, options = {})
# Allow format override
format = options.delete(:format) || @format
# Clear cache
expire_matching "#{parent_path(path)}.*"
# Create PUT request
- put = Net::HTTP::Put.new(path)
- put['Content-type'] = options[:content_type] || content_type(format)
- put.body = body
+ put = Typhoeus::Request.new("#{protocol}#{@server}#{path}",
+ :verbose => DEBUG,
+ :method => "put",
+ :body => body,
+ :headers => { :'Content-type' => options[:content_type] || content_type(format) }
+ )
# Send request
do_request(put, format)
end
def delete(path)
+ # Clear cache
expire_matching "#{parent_path(path)}.*"
# Create DELETE request
- delete = Net::HTTP::Delete.new(path)
- # Send request
+ delete = Typhoeus::Request.new("#{protocol}#{@server}#{path}",
+ :verbose => DEBUG,
+ :method => "delete"
+ )
+ # Send request
do_request(delete)
end
+ # Post to the sign in resource on the API, so that all future
+ # requests are signed
def authenticate
- response = nil
- post = Net::HTTP::Post.new("/auth/signIn")
- post.body = "username=#{@username}&password=#{@password}"
- post['Accept'] = content_type(:xml)
- post['X-AMEE-Source'] = @amee_source if @amee_source
- response = @http.request(post)
- @auth_token = response['authToken']
+ # :x_amee_source = "X-AMEE-Source".to_sym
+ request = Typhoeus::Request.new("#{protocol}#{@server}/auth/signIn",
+ :method => "post",
+ :verbose => DEBUG,
+ :headers => {
+ :Accept => content_type(:xml),
+ },
+ :body => form_encode(:username=>@username, :password=>@password)
+ )
+
+ hydra.queue(request)
+ hydra.run
+ response = request.response
+
+ @auth_token = response.headers_hash['AuthToken']
+ d {request.url}
+ d {response.code}
+ d {@auth_token}
+
+ connection_failed if response.code == 0
+
unless authenticated?
raise AMEE::AuthFailed.new("Authentication failed. Please check your username and password. (tried #{@username},#{@password})")
end
# Detect API version
if response.body.is_json?
@@ -190,10 +250,26 @@
end
end
protected
+ def protocol
+ @ssl == true ? 'https://' : 'http://'
+ end
+
+ # Encode a hash into a application/x-www-form-urlencoded format
+ def form_encode(data)
+ data.map { |datum|
+ "#{CGI::escape(datum[0].to_s)}=#{CGI::escape(datum[1].to_s)}"
+ }.join('&')
+ end
+
+ ## set up the hydra for running http requests. Increase concurrency as needed
+ def hydra
+ @hydra ||= Typhoeus::Hydra.new(:max_concurrency => 1)
+ end
+
def content_type(format = @format)
case format
when :xml
return 'application/xml'
when :json
@@ -202,88 +278,136 @@
return 'application/atom+xml'
end
end
def redirect?(response)
- response.code == '301' || response.code == '302'
+ response.code == 301 || response.code == 302
end
-
+
+ def connection_failed
+ raise AMEE::ConnectionFailed.new("Connection failed. Check server name or network connection.")
+ end
+
+ # run each request through some basic error checking, and
+ # if needed log requests
def response_ok?(response, request)
- case response.code
- when '200', '201'
- return true
- when '404'
- raise AMEE::NotFound.new("The URL was not found on the server.\nRequest: #{request.method} #{request.path}")
- when '403'
- raise AMEE::PermissionDenied.new("You do not have permission to perform the requested operation.\nRequest: #{request.method} #{request.path}\n#{request.body}\Response: #{response.body}")
- when '401'
- authenticate
- return false
- when '400'
- if response.body.include? "would have resulted in a duplicate resource being created"
- raise AMEE::DuplicateResource.new("The specified resource already exists. This is most often caused by creating an item that overlaps another in time.\nRequest: #{request.method} #{request.path}\n#{request.body}\Response: #{response.body}")
- else
- raise AMEE::BadRequest.new("Bad request. This is probably due to malformed input data.\nRequest: #{request.method} #{request.path}\n#{request.body}\Response: #{response.body}")
- end
+
+ # first allow for debugging
+ d {request.object_id}
+ d {request}
+ d {response.object_id}
+ d {response.code}
+ d {response.headers_hash}
+ d {response.body}
+
+ case response.code.to_i
+
+ when 502, 503, 504
+ raise AMEE::ConnectionFailed.new("A connection error occurred while talking to AMEE: HTTP response code #{response.code}.\nRequest: #{request.method.upcase} #{request.url.gsub(request.host, '')}")
+ when 408
+ raise AMEE::TimeOut.new("Request timed out.")
+ when 404
+ raise AMEE::NotFound.new("The URL was not found on the server.\nRequest: #{request.method.upcase} #{request.url.gsub(request.host, '')}")
+ when 403
+ raise AMEE::PermissionDenied.new("You do not have permission to perform the requested operation.\nRequest: #{request.method.upcase} #{request.url.gsub(request.host, '')}\n#{request.body}\Response: #{response.body}")
+ when 401
+ authenticate
+ return false
+ when 400
+ if response.body.include? "would have resulted in a duplicate resource being created"
+ raise AMEE::DuplicateResource.new("The specified resource already exists. This is most often caused by creating an item that overlaps another in time.\nRequest: #{request.method.upcase} #{request.url.gsub(request.host, '')}\n#{request.body}\Response: #{response.body}")
+ else
+ raise AMEE::BadRequest.new("Bad request. This is probably due to malformed input data.\nRequest: #{request.method.upcase} #{request.url.gsub(request.host, '')}\n#{request.body}\Response: #{response.body}")
+ end
+ when 200, 201, 204
+ return response
+ when 0
+ connection_failed
end
- raise AMEE::UnknownError.new("An error occurred while talking to AMEE: HTTP response code #{response.code}.\nRequest: #{request.method} #{request.path}\n#{request.body}\Response: #{response.body}")
+ # If we get here, something unhandled has happened, so raise an unknown error.
+ raise AMEE::UnknownError.new("An error occurred while talking to AMEE: HTTP response code #{response.code}.\nRequest: #{request.method.upcase} #{request.url}\n#{request.body}\Response: #{response.body}")
end
- def do_request(request, format = @format)
- # Open HTTP connection
- @http.start
- begin
- # Set auth token in cookie (and header just in case someone's stripping cookies)
- request['Cookie'] = "authToken=#{@auth_token}"
- request['authToken'] = @auth_token
- # Do request
- timethen=Time.now
- response = send_request(@http, request, format)
- Logger.log.debug("Requesting #{request.class} at #{request.path} with #{request.body} in format #{format}, taking #{(Time.now-timethen)*1000} miliseconds")
- end while !response_ok?(response, request)
- # Return response
- return response
- rescue SocketError
- raise AMEE::ConnectionFailed.new("Connection failed. Check server name or network connection.")
- ensure
- # Close HTTP connection
- @http.finish if @http.started?
- end
+ # Wrapper for sending requests through to the API.
+ # Takes care of making sure requests authenticated, and
+ # if set, attempts to retry a number of times set when
+ # initialising the class
+ def do_request(request, format = @format, options = {})
- def send_request(connection, request, format = @format)
- # Set accept header
- request['Accept'] = content_type(format)
+ # Is this a v3 request?
+ v3_request = request.url.include?("/#{v3_hostname}/")
+
+ # make sure we have our auth token before we start
+ # any v1 or v2 requests
+ if !@auth_token && !v3_request
+ d "Authenticating first before we hit #{request.url}"
+ authenticate
+ end
+
+ request.headers['Accept'] = content_type(format)
# Set AMEE source header if set
- request['X-AMEE-Source'] = @amee_source if @amee_source
- # Do the business
+ request.headers['X-AMEE-Source'] = @amee_source if @amee_source
+
+ # path+query string only (split with an int limits the number of splits)
+ path_and_query = '/' + request.url.split('/', 4)[3]
+
+ if options[:cache]
+ # Get response with caching
+ response = cache(path_and_query) { run_request(request, :xml) }
+ else
+ response = run_request(request, :xml)
+ end
+ response
+ end
+
+ # run request. Extracted from do_request to make
+ # cache code simpler
+ def run_request(request, format)
+ # Is this a v3 request?
+ v3_request = request.url.include?("/#{v3_hostname}/")
+ # Execute with retries
retries = [1] * @retries
- begin
- response = connection.request(request)
- # 500-series errors fail early
- if ['502', '503', '504'].include? response.code
- raise AMEE::ConnectionFailed.new("A connection error occurred while talking to AMEE: HTTP response code #{response.code}.\nRequest: #{request.method} #{request.path}")
- end
- rescue Timeout::Error, Errno::EINVAL, Errno::ECONNRESET, EOFError,
- Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Net::ProtocolError, AMEE::ConnectionFailed => e
+ begin
+ begin
+ d "Queuing the request for #{request.url}"
+ add_authentication_to(request) if @auth_token && !v3_request
+ hydra.queue request
+ hydra.run
+ # Return response if OK
+ end while !response_ok?(request.response, request)
+ # Store updated authToken
+ @auth_token = request.response.headers_hash['AuthToken']
+ return request.response
+ rescue AMEE::ConnectionFailed, AMEE::TimeOut => e
if delay = retries.shift
sleep delay
retry
else
raise
end
end
- # Done
- response
end
+
+ # Take an existing request, and add authentication
+ # may no longer be needed now that we authenticate before
+ # making a request anyway
+ def add_authentication_to(request=nil)
+ if @auth_token
+ request.headers['Cookie'] = "AuthToken=#{@auth_token}"
+ request.headers['AuthToken'] = @auth_token
+ else
+ raise "The connection can't authenticate. Check if the auth_token is being set by the server"
+ end
+ end
def cache(path, &block)
key = cache_key(path)
if @cache && @cache.exist?(key)
- puts "CACHE HIT on #{key}" if @debug
+ d "CACHE HIT on #{key}" if @debug
return @cache.read(key)
end
- puts "CACHE MISS on #{key}" if @debug
+ d "CACHE MISS on #{key}" if @debug
data = block.call
@cache.write(key, data) if @cache
return data
end
@@ -296,11 +420,11 @@
end
def cache_key(path)
# We have to make sure cache keys don't get too long for the filesystem,
# so we cut them off if they're too long and add a digest for uniqueness.
- newpath = @server + path.gsub(/[^0-9a-z\/]/i, '').gsub(/\//i, '_')
- newpath = (newpath.length < 250) ? newpath : newpath.first(218)+Digest::MD5.hexdigest(newpath)
+ key = @server + path.gsub(/[^0-9a-z\/]/i, '').gsub(/\//i, '_')
+ key = (key.length < 250) ? key : key.first(218)+Digest::MD5.hexdigest(key)
end
public
def expire(path, options = nil)