/lib/httparty/request.rb

http://github.com/jnunemaker/httparty · Ruby · 379 lines · 315 code · 60 blank · 4 comment · 68 complexity · a4d4b21a2cf4c0d78aade3332bbc420f MD5 · raw file

  1. require 'erb'
  2. module HTTParty
  3. class Request #:nodoc:
  4. SupportedHTTPMethods = [
  5. Net::HTTP::Get,
  6. Net::HTTP::Post,
  7. Net::HTTP::Patch,
  8. Net::HTTP::Put,
  9. Net::HTTP::Delete,
  10. Net::HTTP::Head,
  11. Net::HTTP::Options,
  12. Net::HTTP::Move,
  13. Net::HTTP::Copy,
  14. Net::HTTP::Mkcol,
  15. Net::HTTP::Lock,
  16. Net::HTTP::Unlock,
  17. ]
  18. SupportedURISchemes = ['http', 'https', 'webcal', nil]
  19. NON_RAILS_QUERY_STRING_NORMALIZER = proc do |query|
  20. Array(query).sort_by { |a| a[0].to_s }.map do |key, value|
  21. if value.nil?
  22. key.to_s
  23. elsif value.respond_to?(:to_ary)
  24. value.to_ary.map {|v| "#{key}=#{ERB::Util.url_encode(v.to_s)}"}
  25. else
  26. HashConversions.to_params(key => value)
  27. end
  28. end.flatten.join('&')
  29. end
  30. JSON_API_QUERY_STRING_NORMALIZER = proc do |query|
  31. Array(query).sort_by { |a| a[0].to_s }.map do |key, value|
  32. if value.nil?
  33. key.to_s
  34. elsif value.respond_to?(:to_ary)
  35. values = value.to_ary.map{|v| ERB::Util.url_encode(v.to_s)}
  36. "#{key}=#{values.join(',')}"
  37. else
  38. HashConversions.to_params(key => value)
  39. end
  40. end.flatten.join('&')
  41. end
  42. attr_accessor :http_method, :options, :last_response, :redirect, :last_uri
  43. attr_reader :path
  44. def initialize(http_method, path, o = {})
  45. @changed_hosts = false
  46. @credentials_sent = false
  47. self.http_method = http_method
  48. self.options = {
  49. limit: o.delete(:no_follow) ? 1 : 5,
  50. assume_utf16_is_big_endian: true,
  51. default_params: {},
  52. follow_redirects: true,
  53. parser: Parser,
  54. uri_adapter: URI,
  55. connection_adapter: ConnectionAdapter
  56. }.merge(o)
  57. self.path = path
  58. set_basic_auth_from_uri
  59. end
  60. def path=(uri)
  61. uri_adapter = options[:uri_adapter]
  62. @path = if uri.is_a?(uri_adapter)
  63. uri
  64. elsif String.try_convert(uri)
  65. uri_adapter.parse(uri).normalize
  66. else
  67. raise ArgumentError,
  68. "bad argument (expected #{uri_adapter} object or URI string)"
  69. end
  70. end
  71. def request_uri(uri)
  72. if uri.respond_to? :request_uri
  73. uri.request_uri
  74. else
  75. uri.path
  76. end
  77. end
  78. def uri
  79. if redirect && path.relative? && path.path[0] != "/"
  80. last_uri_host = @last_uri.path.gsub(/[^\/]+$/, "")
  81. path.path = "/#{path.path}" if last_uri_host[-1] != "/"
  82. path.path = last_uri_host + path.path
  83. end
  84. if path.relative? && path.host
  85. new_uri = options[:uri_adapter].parse("#{@last_uri.scheme}:#{path}").normalize
  86. elsif path.relative?
  87. new_uri = options[:uri_adapter].parse("#{base_uri}#{path}").normalize
  88. else
  89. new_uri = path.clone
  90. end
  91. # avoid double query string on redirects [#12]
  92. unless redirect
  93. new_uri.query = query_string(new_uri)
  94. end
  95. unless SupportedURISchemes.include? new_uri.scheme
  96. raise UnsupportedURIScheme, "'#{new_uri}' Must be HTTP, HTTPS or Generic"
  97. end
  98. @last_uri = new_uri
  99. end
  100. def base_uri
  101. if redirect
  102. base_uri = "#{@last_uri.scheme}://#{@last_uri.host}"
  103. base_uri += ":#{@last_uri.port}" if @last_uri.port != 80
  104. base_uri
  105. else
  106. options[:base_uri] && HTTParty.normalize_base_uri(options[:base_uri])
  107. end
  108. end
  109. def format
  110. options[:format] || (format_from_mimetype(last_response['content-type']) if last_response)
  111. end
  112. def parser
  113. options[:parser]
  114. end
  115. def connection_adapter
  116. options[:connection_adapter]
  117. end
  118. def perform(&block)
  119. validate
  120. setup_raw_request
  121. chunked_body = nil
  122. current_http = http
  123. self.last_response = current_http.request(@raw_request) do |http_response|
  124. if block
  125. chunks = []
  126. http_response.read_body do |fragment|
  127. encoded_fragment = encode_text(fragment, http_response['content-type'])
  128. chunks << encoded_fragment if !options[:stream_body]
  129. block.call ResponseFragment.new(encoded_fragment, http_response, current_http)
  130. end
  131. chunked_body = chunks.join
  132. end
  133. end
  134. handle_host_redirection if response_redirects?
  135. result = handle_unauthorized
  136. result ||= handle_response(chunked_body, &block)
  137. result
  138. end
  139. def handle_unauthorized(&block)
  140. return unless digest_auth? && response_unauthorized? && response_has_digest_auth_challenge?
  141. return if @credentials_sent
  142. @credentials_sent = true
  143. perform(&block)
  144. end
  145. def raw_body
  146. @raw_request.body
  147. end
  148. private
  149. def http
  150. connection_adapter.call(uri, options)
  151. end
  152. def credentials
  153. (options[:basic_auth] || options[:digest_auth]).to_hash
  154. end
  155. def username
  156. credentials[:username]
  157. end
  158. def password
  159. credentials[:password]
  160. end
  161. def normalize_query(query)
  162. if query_string_normalizer
  163. query_string_normalizer.call(query)
  164. else
  165. HashConversions.to_params(query)
  166. end
  167. end
  168. def query_string_normalizer
  169. options[:query_string_normalizer]
  170. end
  171. def setup_raw_request
  172. if options[:headers].respond_to?(:to_hash)
  173. headers_hash = options[:headers].to_hash
  174. else
  175. headers_hash = nil
  176. end
  177. @raw_request = http_method.new(request_uri(uri), headers_hash)
  178. @raw_request.body_stream = options[:body_stream] if options[:body_stream]
  179. if options[:body]
  180. body = Body.new(
  181. options[:body],
  182. query_string_normalizer: query_string_normalizer,
  183. force_multipart: options[:multipart]
  184. )
  185. if body.multipart?
  186. content_type = "multipart/form-data; boundary=#{body.boundary}"
  187. @raw_request['Content-Type'] = content_type
  188. end
  189. @raw_request.body = body.call
  190. end
  191. if options[:basic_auth] && send_authorization_header?
  192. @raw_request.basic_auth(username, password)
  193. @credentials_sent = true
  194. end
  195. setup_digest_auth if digest_auth? && response_unauthorized? && response_has_digest_auth_challenge?
  196. end
  197. def digest_auth?
  198. !!options[:digest_auth]
  199. end
  200. def response_unauthorized?
  201. !!last_response && last_response.code == '401'
  202. end
  203. def response_has_digest_auth_challenge?
  204. !last_response['www-authenticate'].nil? && last_response['www-authenticate'].length > 0
  205. end
  206. def setup_digest_auth
  207. @raw_request.digest_auth(username, password, last_response)
  208. end
  209. def query_string(uri)
  210. query_string_parts = []
  211. query_string_parts << uri.query unless uri.query.nil?
  212. if options[:query].respond_to?(:to_hash)
  213. query_string_parts << normalize_query(options[:default_params].merge(options[:query].to_hash))
  214. else
  215. query_string_parts << normalize_query(options[:default_params]) unless options[:default_params].empty?
  216. query_string_parts << options[:query] unless options[:query].nil?
  217. end
  218. query_string_parts.reject!(&:empty?) unless query_string_parts == [""]
  219. query_string_parts.size > 0 ? query_string_parts.join('&') : nil
  220. end
  221. def assume_utf16_is_big_endian
  222. options[:assume_utf16_is_big_endian]
  223. end
  224. def handle_response(body, &block)
  225. if response_redirects?
  226. options[:limit] -= 1
  227. if options[:logger]
  228. logger = HTTParty::Logger.build(options[:logger], options[:log_level], options[:log_format])
  229. logger.format(self, last_response)
  230. end
  231. self.path = last_response['location']
  232. self.redirect = true
  233. if last_response.class == Net::HTTPSeeOther
  234. unless options[:maintain_method_across_redirects] && options[:resend_on_redirect]
  235. self.http_method = Net::HTTP::Get
  236. end
  237. elsif last_response.code != '307' && last_response.code != '308'
  238. unless options[:maintain_method_across_redirects]
  239. self.http_method = Net::HTTP::Get
  240. end
  241. end
  242. capture_cookies(last_response)
  243. perform(&block)
  244. else
  245. body ||= last_response.body
  246. body = body.nil? ? body : encode_text(body, last_response['content-type'])
  247. Response.new(self, last_response, lambda { parse_response(body) }, body: body)
  248. end
  249. end
  250. def handle_host_redirection
  251. check_duplicate_location_header
  252. redirect_path = options[:uri_adapter].parse(last_response['location']).normalize
  253. return if redirect_path.relative? || path.host == redirect_path.host
  254. @changed_hosts = true
  255. end
  256. def check_duplicate_location_header
  257. location = last_response.get_fields('location')
  258. if location.is_a?(Array) && location.count > 1
  259. raise DuplicateLocationHeader.new(last_response)
  260. end
  261. end
  262. def send_authorization_header?
  263. !@changed_hosts
  264. end
  265. def response_redirects?
  266. case last_response
  267. when Net::HTTPNotModified # 304
  268. false
  269. when Net::HTTPRedirection
  270. options[:follow_redirects] && last_response.key?('location')
  271. end
  272. end
  273. def parse_response(body)
  274. parser.call(body, format)
  275. end
  276. def capture_cookies(response)
  277. return unless response['Set-Cookie']
  278. cookies_hash = HTTParty::CookieHash.new
  279. cookies_hash.add_cookies(options[:headers].to_hash['Cookie']) if options[:headers] && options[:headers].to_hash['Cookie']
  280. response.get_fields('Set-Cookie').each { |cookie| cookies_hash.add_cookies(cookie) }
  281. options[:headers] ||= {}
  282. options[:headers]['Cookie'] = cookies_hash.to_cookie_string
  283. end
  284. # Uses the HTTP Content-Type header to determine the format of the
  285. # response It compares the MIME type returned to the types stored in the
  286. # SupportedFormats hash
  287. def format_from_mimetype(mimetype)
  288. if mimetype && parser.respond_to?(:format_from_mimetype)
  289. parser.format_from_mimetype(mimetype)
  290. end
  291. end
  292. def validate
  293. raise HTTParty::RedirectionTooDeep.new(last_response), 'HTTP redirects too deep' if options[:limit].to_i <= 0
  294. raise ArgumentError, 'only get, post, patch, put, delete, head, and options methods are supported' unless SupportedHTTPMethods.include?(http_method)
  295. raise ArgumentError, ':headers must be a hash' if options[:headers] && !options[:headers].respond_to?(:to_hash)
  296. raise ArgumentError, 'only one authentication method, :basic_auth or :digest_auth may be used at a time' if options[:basic_auth] && options[:digest_auth]
  297. raise ArgumentError, ':basic_auth must be a hash' if options[:basic_auth] && !options[:basic_auth].respond_to?(:to_hash)
  298. raise ArgumentError, ':digest_auth must be a hash' if options[:digest_auth] && !options[:digest_auth].respond_to?(:to_hash)
  299. raise ArgumentError, ':query must be hash if using HTTP Post' if post? && !options[:query].nil? && !options[:query].respond_to?(:to_hash)
  300. end
  301. def post?
  302. Net::HTTP::Post == http_method
  303. end
  304. def set_basic_auth_from_uri
  305. if path.userinfo
  306. username, password = path.userinfo.split(':')
  307. options[:basic_auth] = {username: username, password: password}
  308. @credentials_sent = true
  309. end
  310. end
  311. def encode_text(text, content_type)
  312. TextEncoder.new(
  313. text,
  314. content_type: content_type,
  315. assume_utf16_is_big_endian: assume_utf16_is_big_endian
  316. ).call
  317. end
  318. end
  319. end