Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions lib/smartcar.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ class ConfigNotFound < StandardError; end

# Host to connect to smartcar
API_ORIGIN = 'https://api.smartcar.com/'
VEHICLE_API_ORIGIN = 'https://vehicle.api.smartcar.com'
MANAGEMENT_API_ORIGIN = 'https://management.smartcar.com'
PATHS = {
compatibility: '/compatibility',
Expand Down Expand Up @@ -101,6 +102,49 @@ def get_compatibility(vin:, scope:, country: 'US', options: {})
))
end

# Module method to retrieve the Smartcar compatibility matrix for a given region.
# Provides the ability to filter by scope, make, and type.
#
# A compatible vehicle is a vehicle that:
# 1. has the hardware required for internet connectivity,
# 2. belongs to the makes and models Smartcar supports, and
# 3. supports the permissions.
#
# API Documentation - https://smartcar.com/docs/api-reference/compatibility/by-region-and-make
# @param region [String] One of the following regions: US, CA, or EUROPE
# @param options [Hash] Optional parameters
# @option options [Array<String>] :scope List of permissions to filter the matrix by
# @option options [String, Array<String>] :make List of makes to filter the matrix by
# (space-separated string or array)
# @option options [String] :type Engine type to filter the matrix by (e.g., "ICE", "HEV", "PHEV", "BEV")
# @option options [String] :client_id Client ID that overrides ENV
# @option options [String] :client_secret Client Secret that overrides ENV
# @option options [String] :version API version to use, defaults to what is globally set
# @option options [String] :mode Determine what mode Smartcar Connect should be launched in.
# Should be one of test, live or simulated.
# @option options [Faraday::Connection] :service Optional connection object to be used for requests
#
# @return [OpenStruct] An object representing the compatibility matrix organized by make,
# with each make containing an array of compatible models and a meta attribute with response headers.
def get_compatibility_matrix(region, options = {})
raise Base::InvalidParameterValue.new, 'region is a required field' if region.nil? || region.empty?

base_object = Base.new(
{
version: options[:version] || Smartcar.get_api_version,
auth_type: Base::BASIC,
service: options[:service]
}
)

base_object.token = generate_basic_auth(options, base_object)

base_object.build_response(*base_object.get(
"#{PATHS[:compatibility]}/matrix",
build_compatibility_matrix_params(region, options)
))
end

# Module method Used to get user id
#
# API Documentation - https://smartcar.com/docs/api#get-user
Expand Down Expand Up @@ -224,6 +268,45 @@ def delete_connections(amt:, filter: {}, options: {})
))
end

# Module method to retrieve vehicle information using the v3 API.
#
# API Documentation - https://smartcar.com/docs/api-reference/get-vehicle
# @param vehicle_id [String] The vehicle ID
# @param token [String] Access token
# @param options [Hash] Optional parameters
# @option options [Hash] :flags A hash of flag name string as key and a string or boolean value.
# @option options [Faraday::Connection] :service Optional connection object to be used for requests
#
# @return [OpenStruct] An object with a "body" attribute containing the vehicle data
# and a "headers" attribute containing the response headers.
def get_vehicle(vehicle_id:, token:, options: {})
raise Base::InvalidParameterValue.new, 'vehicle_id is a required field' if vehicle_id.nil? || vehicle_id.empty?
raise Base::InvalidParameterValue.new, 'token is a required field' if token.nil? || token.empty?

vehicle_service = Faraday.new(
url: ENV['SMARTCAR_VEHICLE_API_ORIGIN'] || VEHICLE_API_ORIGIN,
request: { timeout: DEFAULT_REQUEST_TIMEOUT }
)

query_params = { flags: stringify_params(options[:flags]) }

response = vehicle_service.get do |request|
request.headers['Authorization'] = "Bearer #{token}"
request.headers['Content-Type'] = 'application/json'
request.headers['User-Agent'] =
"Smartcar/#{VERSION} (#{RbConfig::CONFIG['host_os']}; #{RbConfig::CONFIG['arch']}) Ruby v#{RUBY_VERSION}"

complete_path = "/v3/vehicles/#{vehicle_id}"
complete_path += "?#{URI.encode_www_form(query_params.compact)}" unless query_params.empty?
request.url complete_path
end

raise build_error(response.status, response.body, response.headers) unless [200, 204].include?(response.status)

body = response.body.empty? ? '{}' : response.body
build_v3_response(body, response.headers.to_h)
end

# returns auth token for Basic vehicle management auth
#
# @return [String] Base64 encoding of default:amt
Expand Down Expand Up @@ -252,6 +335,29 @@ def build_compatibility_params(vin, scope, country, options)
query_params
end

def build_compatibility_matrix_params(region, options)
query_params = { region: region }

# Handle scope - convert array to space-separated string
if options[:scope]
query_params[:scope] = options[:scope].is_a?(Array) ? options[:scope].join(' ') : options[:scope]
end

# Handle make - convert array to space-separated string
if options[:make]
query_params[:make] = options[:make].is_a?(Array) ? options[:make].join(' ') : options[:make]
end

# Handle type
query_params[:type] = options[:type] if options[:type]

# Handle mode
mode = determine_mode(options[:test_mode], options[:mode])
query_params[:mode] = mode unless mode.nil?

query_params
end

# returns auth token for Basic auth
#
# @return [String] Base64 encoding of CLIENT:SECRET
Expand Down
28 changes: 20 additions & 8 deletions lib/smartcar/auth_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,16 @@ class AuthClient
# @param [Hash] options
# @option options[:client_id] [String] - Client ID, if not passed fallsback to ENV['SMARTCAR_CLIENT_ID']
# @option options[:client_secret] [String] - Client Secret, if not passed fallsback to ENV['SMARTCAR_CLIENT_SECRET']
# @option options[:redirect_uri] [String] - Redirect URI, if not passed fallsback to ENV['SMARTCAR_REDIRECT_URI']
# @option options[:redirect_uri] [String] - Redirect URI registered in the
# application settings. The given URL must exactly match one of the registered URLs.
# This parameter is optional and should normally be set within the Smartcar Dashboard.
# @option options[:test_mode] [Boolean] - [DEPRECATED], please use `mode` instead.
# Launch Smartcar Connect in [test mode](https://smartcar.com/docs/guides/testing/).
# @option options[:mode] [String] - Determine what mode Smartcar Connect should be launched in.
# Should be one of test, live or simulated.
# @return [Smartcar::AuthClient] Returns a Smartcar::AuthClient Object that has other methods
def initialize(options)
options[:redirect_uri] ||= get_config('SMARTCAR_REDIRECT_URI')
options[:redirect_uri] ||= get_config('SMARTCAR_REDIRECT_URI', { nullable: true })
options[:client_id] ||= get_config('SMARTCAR_CLIENT_ID')
options[:client_secret] ||= get_config('SMARTCAR_CLIENT_SECRET')
options[:auth_origin] = ENV['SMARTCAR_AUTH_ORIGIN'] || AUTH_ORIGIN
Expand All @@ -30,9 +32,11 @@ def initialize(options)
end

# Generate the OAuth authorization URL.
# @param scope [Array<String>] Array of permissions that specify what the user can access
# @param scope_or_options [Array<String>, Hash] Array of permissions that specify what the user can access
# EXAMPLE : ['read_odometer', 'read_vehicle_info', 'required:read_location']
# For further details refer to https://smartcar.com/docs/guides/scope/
# For further details refer to https://smartcar.com/docs/guides/scope/
# This parameter is optional and should normally be set within the Smartcar Dashboard.
# Can also be a Hash of options if scope is not needed.
# @param [Hash] options
# @option options[:force_prompt] [Boolean] - Setting `force_prompt` to
# `true` will show the permissions approval screen on every authentication
Expand All @@ -57,7 +61,15 @@ def initialize(options)
# is used to aggregate analytics across Connect sessions for each vehicle owner.
#
# @return [String] Authorization URL string
def get_auth_url(scope, options = {})
def get_auth_url(scope_or_options = {}, options = {})
scope = nil
if scope_or_options.is_a?(Array)
scope = scope_or_options
options ||= {}
elsif scope_or_options.is_a?(Hash)
options = scope_or_options
end

initialize_auth_parameters(scope, options)
add_single_select_options(options[:single_select])
connect_client.auth_code.authorize_url(@auth_parameters)
Expand Down Expand Up @@ -126,10 +138,10 @@ def set_token_url(flags)
def initialize_auth_parameters(scope, options)
@auth_parameters = {
response_type: CODE,
redirect_uri: redirect_uri,
mode: mode,
scope: scope.join(' ')
mode: mode
}
@auth_parameters[:redirect_uri] = redirect_uri if redirect_uri
@auth_parameters[:scope] = scope.join(' ') if scope
@auth_parameters[:approval_prompt] = options[:force_prompt] ? FORCE : AUTO unless options[:force_prompt].nil?
@auth_parameters[:state] = options[:state] if options[:state]
@auth_parameters[:make] = options[:make_bypass] if options[:make_bypass]
Expand Down
48 changes: 46 additions & 2 deletions lib/smartcar/utils.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,19 @@ def initialize(options = {})

# gets a given env variable, checks for existence and throws exception if not present
# @param config_name [String] key of the env variable
# @param options [Hash] options hash, supports :nullable key (default: false)
#
# @return [String] value of the env variable
def get_config(config_name)
def get_config(config_name, options = {})
# ENV.MODE is set to test by e2e tests.
config_name = "E2E_#{config_name}" if ENV['MODE'] == 'test'
raise Smartcar::ConfigNotFound, "Environment variable #{config_name} not found !" unless ENV[config_name]

unless ENV[config_name]
nullable = options.fetch(:nullable, false)
return nil if nullable

raise Smartcar::ConfigNotFound, "Environment variable #{config_name} not found !"
end

ENV.fetch(config_name, nil)
end
Expand Down Expand Up @@ -53,6 +60,30 @@ def convert_to_ostruct_recursively(obj)
end
end

# Helper method to convert string from camelCase or kebab-case to snake_case
def to_snake_case(str)
str.to_s
.gsub('-', '_') # Convert kebab-case to snake_case
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
.downcase
end

# Helper method to recursively convert hash keys to snake_case
def deep_transform_keys_to_snake_case(obj)
case obj
when Array
obj.map { |el| deep_transform_keys_to_snake_case(el) }
when Hash
obj.each_with_object({}) do |(key, value), result|
new_key = to_snake_case(key)
result[new_key] = deep_transform_keys_to_snake_case(value)
end
else
obj
end
end

# Parse date string to DateTime or return nil on error
def parse_date_safely(date_string)
return nil unless date_string
Expand Down Expand Up @@ -92,6 +123,19 @@ def build_response(body, headers)
response
end

def build_v3_response(body, headers)
body_data = body.is_a?(String) ? JSON.parse(body) : body
headers_data = headers.is_a?(String) ? JSON.parse(headers) : headers

body_snake = deep_transform_keys_to_snake_case(body_data)
headers_snake = deep_transform_keys_to_snake_case(headers_data)

OpenStruct.new(
body: json_to_ostruct(body_snake),
headers: json_to_ostruct(headers_snake)
)
end

def build_aliases(response, aliases)
(aliases || []).each do |original_name, alias_name|
# rubocop:disable Lint/SymbolConversion
Expand Down
58 changes: 58 additions & 0 deletions lib/smartcar/vehicle.rb
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,34 @@ def batch(paths)
process_batch_response(response, headers)
end

# Retrieve a specific signal by signal code from the vehicle.
# Uses the Vehicle API (v3) endpoint.
#
# API Documentation - https://smartcar.com/docs/api-reference/get-signal
#
# @param signal_code [String] The code of the signal to retrieve.
#
# @return [OpenStruct] An object with a "body" attribute containing the signal data
# and a "headers" attribute containing the response headers.
def get_signal(signal_code)
raise InvalidParameterValue.new, 'signal_code is a required field' if signal_code.nil? || signal_code.empty?

path = "/vehicles/#{id}/signals/#{signal_code}"
request_v3(path)
end

# Retrieve all available signals from the vehicle.
# Uses the Vehicle API (v3) endpoint.
#
# API Documentation - https://smartcar.com/docs/api-reference/get-signals
#
# @return [OpenStruct] An object with a "body" attribute containing all signals data
# and a "headers" attribute containing the response headers.
def get_signals
path = "/vehicles/#{id}/signals"
request_v3(path)
end

# General purpose method to make requests to the Smartcar API - can be
# used to make requests to brand specific endpoints.
#
Expand All @@ -387,5 +415,35 @@ def request(method, path, body = {}, headers = {})
meta = build_meta(headers)
json_to_ostruct({ body: raw_response, meta: meta })
end

private

# Makes a request to the vehicles API using the vehicle API origin and v3 version.
#
# @param path [String] The API path to request.
#
# @return [OpenStruct] An object with body and headers attributes.
def request_v3(path)
vehicle_service = Faraday.new(
url: ENV['SMARTCAR_VEHICLE_API_ORIGIN'] || VEHICLE_API_ORIGIN,
request: { timeout: DEFAULT_REQUEST_TIMEOUT }
)

response = vehicle_service.get do |request|
request.headers['Authorization'] = "Bearer #{token}"
request.headers['Content-Type'] = 'application/json'
request.headers['User-Agent'] =
"Smartcar/#{VERSION} (#{RbConfig::CONFIG['host_os']}; #{RbConfig::CONFIG['arch']}) Ruby v#{RUBY_VERSION}"

complete_path = "/v3#{path}"
complete_path += "?#{URI.encode_www_form(@query_params.compact)}" unless @query_params.empty?
request.url complete_path
end

handle_error(response)

body = response.body.empty? ? '{}' : response.body
build_v3_response(body, response.headers.to_h)
end
end
end
Loading