pezra/hal-client

View on GitHub
lib/hal_client.rb

Summary

Maintainability
B
4 hrs
Test Coverage
require "hal_client/version"
require 'http'
require 'multi_json'
require 'benchmark'

# Adapter used to access resources.
#
# Operations on a HalClient instance are not thread-safe.  If you'd like to
# use a HalClient instance in a threaded environment, consider using the
# method #clone_for_use_in_different_thread to create a copy for each new
# thread
class HalClient
  autoload :Interpreter, 'hal_client/interpreter'
  autoload :Representation, 'hal_client/representation'
  autoload :RepresentationFuture, 'hal_client/representation_future'
  autoload :RepresentationSet, 'hal_client/representation_set'
  autoload :CurieResolver, 'hal_client/curie_resolver'
  autoload :Link, 'hal_client/link'
  autoload :LinksSection, 'hal_client/links_section'
  autoload :Collection, 'hal_client/collection'
  autoload :InvalidRepresentationError, 'hal_client/errors'
  autoload :NotACollectionError, 'hal_client/errors'
  autoload :HttpError, 'hal_client/errors'
  autoload :HttpClientError, 'hal_client/errors'
  autoload :HttpServerError, 'hal_client/errors'
  autoload :NullLogger, 'hal_client/null_logger'
  autoload :Retryinator, 'hal_client/retryinator'

  autoload :RepresentationEditor, 'hal_client/representation_editor'

  # Initializes a new client instance
  #
  # options - hash of configuration options
  #   :accept - one or more content types that should be
  #     prepended to the `Accept` header field of each request.
  #   :content_type - a single content type that should be
  #     prepended to the `Content-Type` header field of each request.
  #   :authorization - a `#call`able which takes the url being
  #     requested and returns the authorization header value to use
  #     for the request or a string which will always be the value of
  #     the authorization header
  #   :headers - a hash of other headers to send on each request.
  #   :base_client - An HTTP::Client object to use.
  #   :logger - a Logger object to which benchmark and activity info
  #      will be written. Benchmark data will be written at info level
  #      and activity at debug level.
  #   :timeout - number of seconds that after which any request will be
  #      terminated and an exception raised. Default: Float::INFINITY
  def initialize(options={})
    @default_message_request_headers = HTTP::Headers.new
    @default_entity_request_headers = HTTP::Headers.new
    @auth_helper = as_callable(options.fetch(:authorization, NullAuthHelper))
    @base_client ||= options[:base_client]
    @logger = options.fetch(:logger, NullLogger.new)
    @timeout = options.fetch(:timeout, Float::INFINITY)
    @base_client_with_headers = {}
    @retry_duration = options.fetch(:retry_duration, Retryinator::DEFAULT_DURATION)

    @retryinator = Retryinator.new(logger: logger, duration: retry_duration)

    default_message_request_headers.set('Accept', options[:accept]) if
      options[:accept]
    # Explicit accept option has precedence over accepts in the
    # headers option.

    options.fetch(:headers, {}).each do |name, value|
      if entity_header_field? name
        default_entity_request_headers.add(name, value)
      else
        default_message_request_headers.add(name, value)
      end
    end

    default_entity_request_headers.set('Content-Type', options[:content_type]) if
      options[:content_type]
    # Explicit content_content options has precedence over content
    # type in the headers option.

    default_entity_request_headers.set('Content-Type', 'application/hal+json') unless
      default_entity_request_headers['Content-Type']
    # We always want a content type. If the user doesn't explicitly
    # specify one we provide a default.

    accept_values = Array(default_message_request_headers.get('Accept')) +
      ['application/hal+json;q=0']
    default_message_request_headers.set('Accept', accept_values.join(", "))
    # We can work with HAL so provide a back stop accept.
  end
  protected :initialize

  # Returns a `Representation` of the resource identified by `url`.
  #
  # url - The URL of the resource of interest.
  # headers - custom header fields to use for this request
  def get(url, headers={})
    headers = auth_headers(url).merge(headers)
    client = client_for_get(override_headers: headers)
    resp = retryinator.retryable { bmtb("GET <#{url}>") { client.get(url) } }
    interpret_response resp

  rescue HttpError => e
    fail e.class.new("GET <#{url}> failed with code #{e.response.status}", e.response)
  end

  class << self
    protected

    def def_unsafe_request(method)
      verb = method.to_s.upcase

      define_method(method) do |url, data, headers={}|
        headers = auth_headers(url).merge(headers)

        req_body = if data.respond_to? :to_hal
                     data.to_hal
                   elsif data.is_a? Hash
                     data.to_json
                   else
                     data
                   end

        begin
          client = client_for_post(override_headers: headers)
          resp = bmtb("#{verb} <#{url}>") {
            client.request(method, url, body: req_body)
          }
          interpret_response resp

        rescue HttpError => e
          fail e.class.new("#{verb} <#{url}> failed with code #{e.response.status}", e.response)
        end
      end
    end

    def def_idempotent_unsafe_request(method)
      verb = method.to_s.upcase

      define_method(method) do |url, data, headers={}|
        headers = auth_headers(url).merge(headers)

        req_body = if data.respond_to? :to_hal
                     data.to_hal
                   elsif data.is_a? Hash
                     data.to_json
                   else
                     data
                   end

        begin
          client = client_for_post(override_headers: headers)
          resp = bmtb("#{verb} <#{url}>") {
            retryinator.retryable { client.request(method, url, body: req_body) }
          }
          interpret_response resp

        rescue HttpError => e
          fail e.class.new("#{verb} <#{url}> failed with code #{e.response.status}", e.response)
        end
      end
    end
  end

  # Post a `Representation`, `String` or `Hash` to the resource identified at `url`.
  #
  # url - The URL of the resource of interest.
  # data - a `String`, a `Hash` or an object that responds to `#to_hal`
  # headers - custom header fields to use for this request
  def_unsafe_request :post

  # Put a `Representation`, `String` or `Hash` to the resource identified at `url`.
  #
  # url - The URL of the resource of interest.
  # data - a `String`, a `Hash` or an object that responds to `#to_hal`
  # headers - custom header fields to use for this request
  def_idempotent_unsafe_request :put

  # Patch a `Representation`, `String` or `Hash` to the resource identified at `url`.
  #
  # url - The URL of the resource of interest.
  # data - a `String`, a `Hash` or an object that responds to `#to_hal`
  # headers - custom header fields to use for this request
  def_unsafe_request :patch

  # Delete a `Representation` or `String` to the resource identified at `url`.
  #
  # url - The URL of the resource of interest.
  # headers - custom header fields to use for this request
  def delete(url, headers={})
    headers = auth_headers(url).merge(headers)

    begin
      client = client_for_post(override_headers: headers)
      resp = bmtb("DELETE <#{url}>") { retryinator.retryable { client.request(:delete, url) } }
      interpret_response resp
    rescue HttpError => e
      fail e.class.new("DELETE <#{url}> failed with code #{e.response.status}", e.response)
    end
  end

  protected

  attr_reader :headers, :auth_helper, :logger, :timeout, :retry_duration, :retryinator

  NullAuthHelper = ->(_url) { nil }

  def as_callable(thing)
    if thing.respond_to?(:call)
      thing
    else
      ->(*_args) { thing }
    end
  end

  def auth_headers(url)
    if h_val = auth_helper.call(url)
      {"Authorization" => h_val}
    else
      {}
    end
  end

  def interpret_response(resp)
    case resp.status
    when 200...300
      location = resp.headers["Location"]

      begin
        Interpreter.new(MultiJson.load(resp.to_s), self, content_location: location).extract_repr
      rescue MultiJson::ParseError, InvalidRepresentationError
        if location
          # response doesn't have a HAL body but we know what resource
          # was created so we can be helpful.
          RepresentationFuture.new(location, self)
        else
          # nothing useful to be done
          resp
        end
      end

    when 400...500
      raise HttpClientError.new(nil, resp)

    when 500...600
      raise HttpServerError.new(nil, resp)

    else
      raise HttpError.new(nil, resp)

    end
  end

  # Returns the HTTP client to be used to make get requests.
  #
  # options
  #   :override_headers -
  def client_for_get(options={})
    headers = default_message_request_headers.merge(options[:override_headers])

    base_client_with_headers(headers)
  end

  # Returns the HTTP client to be used to make post requests.
  #
  # options
  #   :override_headers -
  def client_for_post(options={})
    headers = default_entity_and_message_request_headers.merge(options[:override_headers])

    base_client_with_headers(headers)
  end

  # Returns an HTTP client.
  def base_client
    @base_client ||= begin
      logger.debug 'Created base_client'
      HTTP::Client.new(follow: true)
    end
  end

  def base_client_with_headers(headers)
    @base_client_with_headers[headers.to_h] ||= begin
      logger.debug { "Created base_client with headers #{headers.inspect}" }
      base_client.headers(headers)
    end
  end

  attr_reader :default_entity_request_headers, :default_message_request_headers

  def default_entity_and_message_request_headers
    @default_entity_and_message_request_headers ||=
      default_message_request_headers.merge(default_entity_request_headers)
  end

  def entity_header_field?(field_name)
    [:content_type, /^content-type$/i].any?{|pat| pat === field_name}
  end

  def bmtb(msg, &blk)
    benchmark(msg) { timebox(msg, &blk) }
  end

  def timebox(msg, &blk)
    if timeout < Float::INFINITY
      Timeout.timeout(timeout, &blk)
    else
      yield
    end

  rescue Timeout::Error
    timeout_ms = timeout * 1000
    raise TimeoutError, "Killed %s for taking more than %.1fms." % [msg, timeout_ms]
  end
  def benchmark(msg, &blk)
    result = nil
    elapsed = Benchmark.realtime do
      result = yield
    end

    logger.info '%s (%.1fms)' % [ msg, elapsed*1000 ]

    result
  end


  module EntryPointCovenienceMethods
    # Returns a `Representation` of the resource identified by `url`.
    #
    # url - The URL of the resource of interest.
    # options - set of options to pass to `RestClient#get`
    def get(url, options={})
      default_client.get(url, options)
    end

    # Post a `Representation` or `String` to the resource identified at `url`.
    #
    # url - The URL of the resource of interest.
    # data - a `String` or an object that responds to `#to_hal`
    # options - set of options to pass to `RestClient#get`
    def post(url, data, options={})
      default_client.post(url, data, options)
    end

    # Patch a `Representation` or `String` to the resource identified at `url`.
    #
    # url - The URL of the resource of interest.
    # data - a `String` or an object that responds to `#to_hal`
    # options - set of options to pass to `RestClient#get`
    def patch(url, data, options={})
      default_client.patch(url, data, options)
    end

    # Delete the resource identified at `url`.
    #
    # url - The URL of the resource of interest.
    # options - set of options to pass to `RestClient#get`
    def delete(url, options={})
      default_client.delete(url, options)
    end


    protected

    def default_client
      @default_client ||= self.new
    end
  end
  extend EntryPointCovenienceMethods
end