Fullscreen/yt

View on GitHub
lib/yt/request.rb

Summary

Maintainability
A
2 hrs
Test Coverage
require 'net/http' # for Net::HTTP.start
require 'uri' # for URI.json
require 'json' # for JSON.parse
require 'active_support' # does not load anything by default, but is required
require 'active_support/core_ext' # for Hash.from_xml, Hash.to_param

require 'yt/errors/forbidden'
require 'yt/errors/missing_auth'
require 'yt/errors/request_error'
require 'yt/errors/server_error'
require 'yt/errors/unauthorized'

module Yt
  # @private
  # A wrapper around Net::HTTP to send HTTP requests to any web API and
  # return their result or raise an error if the result is unexpected.
  # The basic way to use Request is by calling +run+ on an instance.
  # @example List the most popular videos on YouTube.
  #   host = 'www.googleapis.com'
  #   path = '/youtube/v3/videos'
  #   params = {chart: 'mostPopular', key: ENV['API_KEY'], part: 'snippet'}
  #   response = Yt::Request.new(path: path, params: params).run
  #   response.body['items'].map{|video| video['snippet']['title']}
  #
  class Request
    # Initializes a Request object.
    # @param [Hash] options the options for the request.
    # @option options [String, Symbol] :method (:get) The HTTP method to use.
    # @option options [Class] :expected_response (Net::HTTPSuccess) The class
    #   of response that the request should obtain when run.
    # @option options [String, Symbol] :response_format (:json) The expected
    #   format of the response body. If passed, the response body will be
    #   parsed according to the format before being returned.
    # @option options [String] :host The host component of the request URI.
    # @option options [String] :path The path component of the request URI.
    # @option options [Hash] :params ({}) The params to use as the query
    #   component of the request URI, for instance the Hash +{a: 1, b: 2}+
    #   corresponds to the query parameters "a=1&b=2".
    # @option options [Hash] :camelize_params (true) whether to transform
    #   each key of params into a camel-case symbol before sending the request.
    # @option options [Hash] :request_format (:json) The format of the
    #   request body. If a request body is passed, it will be parsed
    #   according to this format before sending it in the request.
    # @option options [#size] :body The body component of the request.
    # @option options [Hash] :headers ({}) The headers component of the
    #   request.
    # @option options [#access_token, #refreshed_access_token?] :auth The
    #   authentication object. If set, must respond to +access_token+ and
    #   return the OAuth token to make an authenticated request, and must
    #   respond to +refreshed_access_token?+ and return whether the access
    #   token can be refreshed if expired.
    def initialize(options = {})
      @method = options.fetch :method, :get
      @expected_response = options.fetch :expected_response, Net::HTTPSuccess
      @response_format = options.fetch :response_format, :json
      @host = options[:host]
      @path = options[:path]
      @params = options.fetch :params, {}
      # Note: This is to be invoked by auth-only YouTube APIs.
      @params[:key] = options[:api_key] if options[:api_key]
      # Note: This is to be invoked by all YouTube API except Annotations,
      # Analyitics and Uploads
      camelize_keys! @params if options.fetch(:camelize_params, true)
      @request_format = options.fetch :request_format, :json
      @body = options[:body]
      @headers = options.fetch :headers, {}
      @auth = options[:auth]
    end

    # Sends the request and returns the response.
    # If the request fails once for a temporary server error or an expired
    # token, tries the request again before eventually raising an error.
    # @return [Net::HTTPResponse] if the request succeeds and matches the
    #   expectations, the response with the body appropriately parsed.
    # @raise [Yt::RequestError] if the request fails or the response does
    #   not match the expectations.
    def run
      if matches_expectations?
        response.tap{parse_response!}
      elsif run_again?
        run
      else
        raise response_error, error_message.to_json
      end
    end

    # Returns the +cURL+ version of the request, useful to re-run the request
    # in a shell terminal.
    # @return [String] the +cURL+ version of the request.
    def as_curl
      'curl'.tap do |curl|
        curl << " -X #{http_request.method}"
        http_request.each_header{|k, v| curl << %Q{ -H "#{k}: #{v}"}}
        curl << %Q{ -d '#{http_request.body}'} if http_request.body
        curl << %Q{ "#{uri.to_s}"}
      end
    end

  private

    # @return [URI::HTTPS] the (memoized) URI of the request.
    def uri
      attributes = {host: @host, path: @path, query: @params.to_param}
      @uri ||= URI::HTTPS.build attributes
    end

    # @return [Net::HTTPRequest] the full HTTP request object,
    #   inclusive of headers of request body.
    def http_request
      net_http_class = "Net::HTTP::#{@method.capitalize}".constantize
      @http_request ||= net_http_class.new(uri.request_uri).tap do |request|
        set_request_body! request
        set_request_headers! request
      end
    end

    # Adds the request body to the request in the appropriate format.
    # if the request body is a JSON Object, transform its keys into camel-case,
    # since this is the common format for JSON APIs.
    def set_request_body!(request)
      case @request_format
        when :json then request.body = (camelize_keys! @body).to_json
        when :form then request.set_form_data @body
        when :file then request.body_stream = @body
      end if @body
    end

    # Destructively converts all the keys of hash to camel-case symbols.
    # Note: This is to be invoked by all YouTube API except Accounts
    def camelize_keys!(hash)
      hash.keys.each do |key|
        hash[key.to_s.camelize(:lower).to_sym] = hash.delete key
      end if hash.is_a? Hash
      hash
    end

    # Adds the request headers to the request in the appropriate format.
    # The User-Agent header is also set to recognize the request, and to
    # tell the server that gzip compression can be used, since Net::HTTP
    # supports it and automatically sets the Accept-Encoding header.
    def set_request_headers!(request)
      case @request_format
      when :json
        request.initialize_http_header 'Content-Type' => 'application/json'
        request.initialize_http_header 'Content-length' => '0' unless @body
      when :file
        request.initialize_http_header 'Content-Length' => @body.size.to_s
        request.initialize_http_header 'Transfer-Encoding' => 'chunked'
      end
      @headers['User-Agent'] = 'Yt::Request (gzip)'
      @headers['Authorization'] = "Bearer #{@auth.access_token}" if @auth
      @headers.each{|name, value| request.add_field name, value}
    end

    # @return [Boolean] whether the class of response returned by running
    #   the request matches the expected class of response.
    def matches_expectations?
      response.is_a? @expected_response
    end

    # Run the request and memoize the response or the server error received.
    def response
      @response ||= send_http_request
    rescue *server_errors => e
      @response ||= e
    end

    # Send the request to the server, allowing ActiveSupport::Notifications
    # client to subscribe to the request.
    def send_http_request
      net_http_options = [uri.host, uri.port, use_ssl: true]
      ActiveSupport::Notifications.instrument 'request.yt' do |payload|
        payload[:method] = @method
        payload[:request_uri] = uri
        payload[:response] = Net::HTTP.start(*net_http_options) do |http|
          http.request http_request
        end
      end
    end

    # Replaces the body of the response with the parsed version of the body,
    # according to the format specified in the Request.
    def parse_response!
      response.body = case @response_format
        when :xml then Hash.from_xml response.body
        when :json then JSON response.body
      end if response.body
    end

    # Returns whether it is worth to run a failed request again.
    # There are three cases in which retrying a request might be worth:
    # - when the server specifies that the request token has expired and
    #   the user has to refresh the token in order to try again
    # - when the server is unreachable, and waiting for a couple of seconds
    #   might solve the connection issues.
    # - when the user has reached the quota for requests/second, and waiting
    #   for a couple of seconds might solve the connection issues.
    def run_again?
      refresh_token_and_retry? && sleep_and_retry?(1) ||
      server_error? && sleep_and_retry?(3)
    end

    # Returns the list of server errors worth retrying the request once.
    def server_errors
      [
        OpenSSL::SSL::SSLError,
        Errno::ETIMEDOUT,
        Errno::EHOSTUNREACH,
        Errno::ENETUNREACH,
        Errno::ECONNRESET,
        Net::OpenTimeout,
        SocketError,
        Net::HTTPServerError
      ] + extra_server_errors
    end

    # Returns the list of server errors that are only raised (and therefore
    # can only be rescued) by specific versions of Ruby.
    # @see: https://github.com/Fullscreen/yt/pull/110
    def extra_server_errors
      if defined? OpenSSL::SSL::SSLErrorWaitReadable
        [OpenSSL::SSL::SSLErrorWaitReadable]
      else
        []
      end
    end

    # Sleeps for a while and returns true for the first +max_retries+ times,
    # then returns false. Useful to try the same request again multiple
    # times with a delay if a connection error occurs.
    def sleep_and_retry?(max_retries = 1)
      @retries_so_far ||= -1
      @retries_so_far += 1
      if (@retries_so_far < max_retries)
        @response = @http_request = @uri = nil
        sleep retry_time
      end
    end

    def retry_time
      3 + (10 * @retries_so_far)
    end

    # In case an authorized request responds with "Unauthorized", checks
    # if the original access token can be refreshed. If that's the case,
    # clears the memoized variables and returns true, so the request can
    # be run again, otherwise raises an error.
    def refresh_token_and_retry?
      if unauthorized? && @auth && @auth.refreshed_access_token?
        @response = @http_request = @uri = nil
        true
      end
    rescue Errors::MissingAuth
      false
    end

    # @return [Yt::RequestError] the error associated to the class of the
    #   response.
    def response_error
      case response
        when *server_errors then Errors::ServerError
        when Net::HTTPUnauthorized then Errors::Unauthorized
        when Net::HTTPForbidden then Errors::Forbidden
        else Errors::RequestError
      end
    end

    # @return [Boolean] whether the response matches any server error.
    def server_error?
      response_error == Errors::ServerError
    end

    # @return [Boolean] whether the request exceeds the YouTube quota
    def exceeded_quota?
      response_error == Errors::Forbidden && response.body =~ /Exceeded/i
    end

    # @return [Boolean] whether the request lacks proper authorization.
    def unauthorized?
      response_error == Errors::Unauthorized
    end

    # Return the elements of the request/response that are worth displaying
    # as an error message if the request fails.
    # If the response format is JSON, showing the parsed body is sufficient,
    # otherwise the whole (inspected) response is worth looking at.
    def error_message
      response_body = JSON(response.body) rescue response.inspect
      {request_curl: as_curl, response_body: response_body}
    end
  end
end