class Booker::Client

Constants

API_GATEWAY_ERRORS
BOOKER_SERVER_TIMEZONE
CLIENT_CREDENTIALS_GRANT_TYPE
CREATE_TOKEN_CONTENT_TYPE
CREATE_TOKEN_PATH
DEFAULT_AUTH_BASE_URL
DEFAULT_BASE_URL
DEFAULT_CONTENT_TYPE
DEFAULT_REQUEST_TIMEOUT
ENV_BASE_URL_KEY
REFRESH_TOKEN_GRANT_TYPE
UPDATE_TOKEN_CONTEXT_PATH
VALID_ACCESS_TOKEN_SCOPES

Attributes

access_token_scope[RW]
api_subscription_key[RW]
auth_base_url[RW]
auth_with_client_credentials[RW]
base_url[RW]
client_id[RW]
client_secret[RW]
location_id[RW]
refresh_token[RW]
request_timeout[RW]
temp_access_token[RW]
temp_access_token_expires_at[RW]
token_store[RW]
token_store_callback_method[RW]

Public Class Methods

new(options = {}) click to toggle source
# File lib/booker/client.rb, line 29
def initialize(options = {})
  options.each { |key, value| send(:"#{key}=", value) }
  self.request_timeout ||= DEFAULT_REQUEST_TIMEOUT
  self.base_url ||= get_base_url
  self.auth_base_url ||= ENV['BOOKER_API_BASE_URL'] || DEFAULT_AUTH_BASE_URL
  self.client_id ||= ENV['BOOKER_CLIENT_ID']
  self.client_secret ||= ENV['BOOKER_CLIENT_SECRET']
  self.api_subscription_key ||= ENV['BOOKER_API_SUBSCRIPTION_KEY']
  if self.auth_with_client_credentials.nil?
    self.auth_with_client_credentials = ENV['BOOKER_API_AUTH_WITH_CLIENT_CREDENTIALS'] == 'true'
  end
  if self.temp_access_token.present?
    begin
      self.temp_access_token_expires_at = token_expires_at(self.temp_access_token)
      self.access_token_scope = token_scope(self.temp_access_token)
    rescue JWT::ExpiredSignature => ex
      raise ex unless self.auth_with_client_credentials || self.refresh_token.present?
    end
  end
  if self.access_token_scope.blank?
    self.access_token_scope = VALID_ACCESS_TOKEN_SCOPES.first
  elsif !self.access_token_scope.in?(VALID_ACCESS_TOKEN_SCOPES)
    raise ArgumentError, "access_token_scope must be one of: #{VALID_ACCESS_TOKEN_SCOPES.join(', ')}"
  end
end

Public Instance Methods

access_token() click to toggle source
# File lib/booker/client.rb, line 194
def access_token
  (self.temp_access_token && !temp_access_token_expired?) ? self.temp_access_token : get_access_token
end
access_token_response() click to toggle source
# File lib/booker/client.rb, line 226
def access_token_response
  body = {
    grant_type: self.auth_with_client_credentials ? CLIENT_CREDENTIALS_GRANT_TYPE : REFRESH_TOKEN_GRANT_TYPE,
    client_id: self.client_id,
    client_secret: self.client_secret,
    scope: self.access_token_scope
  }
  body[:refresh_token] = self.refresh_token if body[:grant_type] == REFRESH_TOKEN_GRANT_TYPE
  options = {
    headers: {
      'Content-Type' => CREATE_TOKEN_CONTENT_TYPE,
      'Ocp-Apim-Subscription-Key' => self.api_subscription_key
    },
    body: body.to_query,
    timeout: 30
  }

  url = "#{self.auth_base_url}#{CREATE_TOKEN_PATH}"

  begin
    handle_errors! url, options, HTTParty.post(url, options), false
  rescue Booker::ServiceUnavailable, Booker::RateLimitExceeded
    # retry once
    sleep 1
    handle_errors! url, options, HTTParty.post(url, options), false
  end
end
delete(path, params=nil, body=nil, booker_model=nil) click to toggle source
# File lib/booker/client.rb, line 83
def delete(path, params=nil, body=nil, booker_model=nil)
  booker_resources = get_booker_resources(:delete, path, params, body.to_json, booker_model)

  build_resources(booker_resources, booker_model)
end
full_url(path) click to toggle source
# File lib/booker/client.rb, line 159
def full_url(path)
  uri = URI(path)
  uri.scheme ? path : "#{self.base_url}#{path}"
end
get(path, params, booker_model=nil) click to toggle source
# File lib/booker/client.rb, line 59
def get(path, params, booker_model=nil)
  booker_resources = get_booker_resources(:get, path, params, nil, booker_model)

  build_resources(booker_resources, booker_model)
end
get_access_token() click to toggle source
# File lib/booker/client.rb, line 204
def get_access_token
  unless self.auth_with_client_credentials || self.refresh_token
    raise ArgumentError, 'Cannot get new access token without auth_with_client_credentials or a refresh_token'
  end

  resp = access_token_response
  token = resp.parsed_response['access_token']
  raise Booker::InvalidApiCredentials.new(response: resp) if token.blank?

  if self.auth_with_client_credentials && self.location_id
    self.temp_access_token = get_location_access_token(token, self.location_id)
  else
    self.temp_access_token = token
  end

  self.temp_access_token_expires_at = token_expires_at(self.temp_access_token)

  update_token_store

  self.temp_access_token
end
get_base_url() click to toggle source
# File lib/booker/client.rb, line 55
def get_base_url
  ENV[self.class::ENV_BASE_URL_KEY] || self.class::DEFAULT_BASE_URL
end
get_booker_resources(http_method, path, params=nil, body=nil, booker_model=nil) click to toggle source
# File lib/booker/client.rb, line 128
def get_booker_resources(http_method, path, params=nil, body=nil, booker_model=nil)
  http_options = request_options(params, body)
  url = full_url(path)
  puts "BOOKER REQUEST: #{http_method} #{url} #{http_options}" if ENV['BOOKER_API_DEBUG'] == 'true'

  # Allow it to retry the first time unless it is an authorization error
  begin
    response = handle_errors!(url, http_options, HTTParty.send(http_method, url, http_options))
  rescue Booker::Error, Net::ReadTimeout => ex
    if ex.is_a? Booker::InvalidApiCredentials
      raise ex
    else
      sleep 1
      response = nil # Force a retry (see logic below)
    end
  end

  unless response_is_error?(response, http_method)
    return results_from_response(response, booker_model)
  end

  # Retry on blank responses (happens in certain v4 API methods in lieu of an actual error)
  response = handle_errors!(url, http_options, HTTParty.send(http_method, url, http_options))
  unless response_is_error?(response, http_method)
    return results_from_response(response, booker_model)
  end

  # Raise if response is still blank
  raise Booker::Error.new(url: url, request: http_options, response: response)
end
get_location_access_token(existing_token, location_id) click to toggle source
# File lib/booker/client.rb, line 254
def get_location_access_token(existing_token, location_id)
  options = {
    headers: {
      'Accept' => 'application/json',
      'Authorization' => "Bearer #{existing_token}",
      'Ocp-Apim-Subscription-Key' => self.api_subscription_key
    },
    query: {
      locationId: location_id
    },
    timeout: 30
  }
  url = "#{self.auth_base_url}#{UPDATE_TOKEN_CONTEXT_PATH}"

  begin
    resp = handle_errors! url, options, HTTParty.post(url, options), false
  rescue Booker::ServiceUnavailable, Booker::RateLimitExceeded
    # retry once
    sleep 1
    resp = handle_errors! url, options, HTTParty.post(url, options), false
  end

  resp.parsed_response
end
handle_errors!(url, request, response, retry_unauthorized=true) click to toggle source
# File lib/booker/client.rb, line 164
def handle_errors!(url, request, response, retry_unauthorized=true)
  puts "BOOKER RESPONSE: #{response}" if ENV['BOOKER_API_DEBUG'] == 'true'

  error_class = API_GATEWAY_ERRORS[response.code]

  begin
    raise error_class.new(url: url, request: request, response: response) if error_class
  rescue Booker::InvalidApiCredentials => ex
    raise ex unless response.code == 401 && retry_unauthorized
    get_access_token
    return nil
  end

  ex = Booker::Error.new(url: url, request: request, response: response)

  if ex.error.present? || !response.success?
    case ex.error
      when 'invalid_client'
        raise Booker::InvalidApiCredentials.new(url: url, request: request, response: response)
      when 'invalid access token'
        get_access_token
        return nil
      else
        raise ex
    end
  end

  response
end
paginated_request(method:, path:, params:, model: nil, fetched: [], fetch_all: true) click to toggle source
# File lib/booker/client.rb, line 89
def paginated_request(method:, path:, params:, model: nil, fetched: [], fetch_all: true)
  page_size = params[:PageSize]
  page_number = params[:PageNumber]

  if page_size.nil? || page_size < 1 || page_number.nil? || page_number < 1 || !params[:UsePaging]
    raise ArgumentError, 'params must include valid PageSize, PageNumber and UsePaging'
  end

  puts "fetching #{path} with #{params.except(:access_token)}. #{fetched.length} results so far."

  begin
    results = self.send(method, path, params, model)
  rescue Net::ReadTimeout
    results = nil
  end

  unless results.is_a?(Array)
    error_msg = "Result from paginated request to #{path} with params: #{params} is not a collection"
    raise Booker::MidPaginationError.new(message: error_msg, error_occurred_during_params: params,
                                         results_fetched_prior_to_error: fetched)
  end

  fetched.concat(results)
  results_length = results.length

  if fetch_all
    if results_length > 0
      # TODO (#111186744): Add logging to see if any pages with less than expected data (as seen in the /appointments endpoint)
      new_params = params.deep_dup
      new_params[:PageNumber] = page_number + 1
      paginated_request(method: method, path: path, params: new_params, model: model, fetched: fetched)
    else
      fetched
    end
  else
    results
  end
end
patch(path, data, booker_model=nil) click to toggle source
# File lib/booker/client.rb, line 77
def patch(path, data, booker_model=nil)
  booker_resources = get_booker_resources(:patch, path, nil, data.to_json, booker_model)

  build_resources(booker_resources, booker_model)
end
post(path, data, booker_model=nil) click to toggle source
# File lib/booker/client.rb, line 65
def post(path, data, booker_model=nil)
  booker_resources = get_booker_resources(:post, path, nil, data.to_json, booker_model)

  build_resources(booker_resources, booker_model)
end
put(path, data, booker_model=nil) click to toggle source
# File lib/booker/client.rb, line 71
def put(path, data, booker_model=nil)
  booker_resources = get_booker_resources(:put, path, nil, data.to_json, booker_model)

  build_resources(booker_resources, booker_model)
end
update_token_store() click to toggle source
# File lib/booker/client.rb, line 198
def update_token_store
  if self.token_store.present? && self.token_store_callback_method.present?
    self.token_store.send(self.token_store_callback_method, self.temp_access_token, self.temp_access_token_expires_at)
  end
end

Private Instance Methods

build_resources(resources, booker_model) click to toggle source
# File lib/booker/client.rb, line 297
def build_resources(resources, booker_model)
  return resources if booker_model.nil?

  if resources.is_a? Hash
    booker_model.from_hash(resources)
  elsif resources.is_a? Array
    booker_model.from_list(resources)
  else
    resources
  end
end
decoded_token_info(token) click to toggle source
# File lib/booker/client.rb, line 348
def decoded_token_info(token)
  JWT.decode(token, nil, false, verify_not_before: false)[0]
end
nil_or_empty_hash?(obj) click to toggle source
# File lib/booker/client.rb, line 336
def nil_or_empty_hash?(obj)
  obj.nil? || (obj.is_a?(Hash) && obj.blank?)
end
request_options(query=nil, body=nil) click to toggle source
# File lib/booker/client.rb, line 280
def request_options(query=nil, body=nil)
  options = {
    # Headers must use stringified keys due to how they are transformed in some Net::HTTP versions
    headers: {
      'Content-Type' => DEFAULT_CONTENT_TYPE,
      'Accept' => DEFAULT_CONTENT_TYPE,
      'Authorization' => "Bearer #{access_token}",
      'Ocp-Apim-Subscription-Key' => self.api_subscription_key
    },
    timeout: self.request_timeout
  }

  options[:body] = body if body.present?
  options[:query] = query if query.present?
  options
end
response_is_error?(response, http_method) click to toggle source
# File lib/booker/client.rb, line 330
def response_is_error?(response, http_method)
  return false if (http_method == :delete) && (response.try(:code) == 204)

  response.nil? || nil_or_empty_hash?(response.parsed_response)
end
results_from_response(response, booker_model=nil) click to toggle source
# File lib/booker/client.rb, line 313
def results_from_response(response, booker_model=nil)
  parsed_response = response.parsed_response

  return parsed_response unless parsed_response.is_a?(Hash)
  return parsed_response['Results'] unless parsed_response['Results'].nil?

  if booker_model
    response_results_key = booker_model.response_results_key
    return parsed_response[response_results_key] unless parsed_response[response_results_key].nil?

    pluralized = response_results_key.pluralize
    return parsed_response[pluralized] unless parsed_response[pluralized].nil?
  end

  parsed_response
end
temp_access_token_expired?() click to toggle source
# File lib/booker/client.rb, line 309
def temp_access_token_expired?
  self.temp_access_token_expires_at.nil? || self.temp_access_token_expires_at <= Time.now
end
token_expires_at(token) click to toggle source
# File lib/booker/client.rb, line 340
def token_expires_at(token)
  Time.at(decoded_token_info(token)['exp'])
end
token_scope(token) click to toggle source
# File lib/booker/client.rb, line 344
def token_scope(token)
  decoded_token_info(token)['scope']
end