LukeHackett/tfl_api_client

View on GitHub
lib/tfl_api_client/client.rb

Summary

Maintainability
B
4 hrs
Test Coverage
#
# Copyright (c) 2015 - 2018 Luke Hackett
#
# MIT License
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
#

require 'json'
require 'logger'
require 'openssl'
require 'net/http'
require 'tfl_api_client/exceptions'

module TflApi
  # This is the client class that allows direct access to the subclasses and to
  # the TFL API. The class contains methods that perform GET and POST requests
  # to the API.
  #
  class Client

    # Parameters that are permitted as options while initializing the client
    VALID_PARAMS = %w( app_id app_key host logger log_level log_location ).freeze

    # HTTP verbs supported by the Client
    VERB_MAP = {
      get: Net::HTTP::Get
    }

    # Client accessors
    attr_reader :app_id, :app_key, :host, :logger, :log_level, :log_location

    # Initialize a Client object with TFL API credentials
    #
    # @param args [Hash] Arguments to connect to TFL API
    #
    # @option args [String] :app_id  the application id generated by registering an app with TFL
    # @option args [String] :app_key the application key generated by registering an app with TFL
    # @option args [String] :host    the API's host url - defaults to api.tfl.gov.uk
    #
    # @return [TflApi::Client] a client object to the TFL API
    #
    # @raise [ArgumentError] when required options are not provided.
    #
    def initialize(args)
      args.each do |key, value|
        if value && VALID_PARAMS.include?(key.to_s)
          instance_variable_set("@#{key.to_sym}", value)
        end
      end if args.is_a? Hash

      # Ensure the Application ID and Key is given
      raise ArgumentError, "Application ID (app_id) is required to interact with TFL's APIs" unless app_id
      raise ArgumentError, "Application Key (app_key) is required to interact with TFL's APIs" unless app_key

      # Set client defaults
      @host ||= 'https://api.tfl.gov.uk'
      @host = URI.parse(@host)

      # Create a global Net:HTTP instance
      @http = Net::HTTP.new(@host.host, @host.port)
      @http.use_ssl = true
      @http.verify_mode = OpenSSL::SSL::VERIFY_NONE

      # Logging
      if @logger
        raise ArgumentError, 'logger parameter must be a Logger object' unless @logger.is_a?(Logger)
        raise ArgumentError, 'log_level cannot be set if using custom logger' if @log_level
        raise ArgumentError, 'log_location cannot be set if using custom logger' if @log_location
      else
        @log_level = Logger::INFO unless @log_level
        @log_location = STDOUT unless @log_location
        @logger = Logger.new(@log_location)
        @logger.level = @log_level
        @logger.datetime_format = '%F T%T%z'
        @logger.formatter = proc do |severity, datetime, _progname, msg|
          "[%s] %-6s %s \r\n" %  [datetime, severity, msg]
        end
      end
    end

    # Creates an instance to the AccidentStats class by passing a reference to self
    #
    # @return [TflApi::Client::AccidentStats] An object to AccidentStats subclass
    #
    def accident_stats
      TflApi::Client::AccidentStats.new(self)
    end

    # Creates an instance to the AirQuality class by passing a reference to self
    #
    # @return [TflApi::Client::AirQuality] An object to AirQuality subclass
    #
    def air_quality
      TflApi::Client::AirQuality.new(self)
    end

    # Creates an instance to the BikePoint class by passing a reference to self
    #
    # @return [TflApi::Client::BikePoint] An object to BikePoint subclass
    #
    def bike_point
      TflApi::Client::BikePoint.new(self)
    end

    # Creates an instance to the Cycle class by passing a reference to self
    #
    # @return [TflApi::Client::Cycle] An object to Cycle subclass
    #
    def cycle
      TflApi::Client::Cycle.new(self)
    end

    # Creates an instance to the Cabwise class by passing a reference to self
    #
    # @return [TflApi::Client::Cabwise] An object to Cabwise subclass
    #
    def cabwise
      TflApi::Client::Cabwise.new(self)
    end

    # Creates an instance to the Journey class by passing a reference to self
    #
    # @return [TflApi::Client::Journey] An object to Journey subclass
    #
    def journey
      TflApi::Client::Journey.new(self)
    end

    # Creates an instance to the Mode class by passing a reference to self
    #
    # @return [TflApi::Client::Mode] An object to Mode subclass
    #
    def mode
      TflApi::Client::Mode.new(self)
    end

    # Performs a HTTP GET request to the api, based upon the given URI resource
    # and any additional HTTP query parameters. This method will automatically
    # inject the mandatory application id and application key HTTP query
    # parameters.
    #
    # @return [hash] HTTP response as a hash
    #
    def get(path, query={})
      request_json :get, path, query
    end

    # Overrides the inspect method to prevent the TFL Application ID and Key
    # credentials being shown when the `inspect` method is called. The method
    # will only print the important variables.
    #
    # @return [String] String representation of the current object
    #
    def inspect
      "#<#{self.class.name}:0x#{(self.__id__ * 2).to_s(16)} " +
          "@host=#{host.to_s}, " +
          "@log_level=#{log_level}, " +
          "@log_location=#{log_location.inspect}>"
    end

    private

    # This method requests the given path via the given resource with the additional url
    # params. All successful responses will yield a hash of the response body, whilst
    # all other response types will raise a child of TflApi::Exceptions::ApiException.
    # For example a 404 response would raise a TflApi::Exceptions::NotFound exception.
    #
    # @param method [Symbol] The type of HTTP request to make, e.g. :get
    # @param path [String] the path of the resource (not including the base url) to request
    # @param params [Hash]
    #
    # @return [HTTPResponse] HTTP response object
    #
    # @raise [TflApi::Exceptions::ApiException] when an error has occurred with TFL's API
    #
    def request_json(method, path, params)
      response = request(method, path, params)

      if response.kind_of? Net::HTTPSuccess
        parse_response_json(response)
      else
        raise_exception(response)
      end
    end

    # Creates and performs HTTP request by the request medium to the given path
    # with any additional uri parameters. The method will return the HTTP
    # response object upon completion.
    #
    # @param method [Symbol] The type of HTTP request to make, e.g. :get
    # @param path [String] the path of the resource (not including the base url) to request
    # @param params [Hash] Additional url params to be added to the request
    #
    # @return [HTTPResponse] HTTP response object
    #
    def request(method, path, params)
      full_path = format_request_uri(path, params)
      request = VERB_MAP[method.to_sym].new(full_path)
      # TODO - Enable when supporting other HTTP Verbs
      # request.set_form_data(params) unless method == :get

      @logger.debug "#{method.to_s.upcase} #{path}"
      @http.request(request)
    end

    # Returns a full, well-formatted HTTP request URI that can be used to directly
    # interact with the TFL API.
    #
    # @param path [String] the path of the resource (not including the base url) to request
    # @param params [Hash] Additional url params to be added to the request
    #
    # @return [String] Full HTTP request URI
    #
    def format_request_uri(path, params)
      params.merge!({app_id: app_id, app_key: app_key})
      params_string = URI.encode_www_form(params)
      URI::HTTPS.build(host: host.host, path: URI.escape(path), query: params_string)
    end

    # Parses the given response body as JSON, and returns a hash representation of the
    # the response. Failure to successfully parse the response will result in an
    # TflApi::Exceptions::ApiException being raised.
    #
    # @param response [HTTPResponse] the HTTP response object
    #
    # @return [HTTPResponse] HTTP response object
    #
    # @raise [TflApi::Exceptions::ApiException] when trying to parse a malformed JSON response
    #
    def parse_response_json(response)
      begin
        JSON.parse(response.body)
      rescue JSON::ParserError
        raise TflApi::Exceptions::ApiException, logger, "Invalid JSON returned from #{host.host}"
      end
    end

    # Raises a child of TflApi::Exceptions::ApiException based upon the response code being
    # classified as non-successful, i.e. a non 2xx response code. All non-successful
    # responses will raise an TflApi::Exceptions::ApiException by default. Popular
    # non-successful response codes are mapped to internal exceptions, for example a 404
    # response code would raise TflApi::Exceptions::NotFound.
    #
    # @param response [HTTPResponse] the HTTP response object
    #
    # @raise [TflApi::Exceptions::ApiException] when an error has occurred with TFL's API
    #
    def raise_exception(response)
      case response.code.to_i
        when 401
          raise TflApi::Exceptions::Unauthorized, logger
        when 403
          raise TflApi::Exceptions::Forbidden, logger
        when 404
          raise TflApi::Exceptions::NotFound, logger
        when 500
          raise TflApi::Exceptions::InternalServerError, logger
        when 503
          raise TflApi::Exceptions::ServiceUnavailable, logger
        else
          raise TflApi::Exceptions::ApiException, logger, "non-successful response (#{response.code}) was returned"
      end
    end
  end
end