diff --git a/.rubocop.yml b/.rubocop.yml index 5e1ad44..d6edd33 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -7,6 +7,10 @@ AllCops: Metrics/AbcSize: Enabled: false +# Lengthen module line length +Metrics/ModuleLength: + Max: 200 + # Disabling this becuase we are using `set` and `get` prefixed methods to keep some commonality across SDKs Naming/AccessorMethodName: Enabled: false diff --git a/Gemfile.lock b/Gemfile.lock index de04087..dbdff32 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - smartcar (3.5.0) + smartcar (3.6.0) oauth2 (~> 1.4) recursive-open-struct (~> 1.1.3) diff --git a/README.md b/README.md index 8e94bc6..3fd3858 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,8 @@ Example of providing a custom Faraday connection to various methods: # Passing the custom service into a Smartcar::Vehicle object vehicle = Smartcar::Vehicle.new(token: token, id: id, options: { service: service }) + + connections = Smartcar.get_connections(amt: 'amt', filter: {userId: 'user-id'}, options: {service: service}) ``` ## Development diff --git a/lib/smartcar.rb b/lib/smartcar.rb index 790df6a..128563e 100644 --- a/lib/smartcar.rb +++ b/lib/smartcar.rb @@ -15,10 +15,12 @@ class ConfigNotFound < StandardError; end # Host to connect to smartcar API_ORIGIN = 'https://api.smartcar.com/' + MANAGEMENT_API_ORIGIN = 'https://management.smartcar.com' PATHS = { compatibility: '/compatibility', user: '/user', - vehicles: '/vehicles' + vehicles: '/vehicles', + connections: '/management/connections' }.freeze # Path for smartcar oauth @@ -80,8 +82,8 @@ def get_api_version # @return [OpenStruct] And object representing the JSON response mentioned in https://smartcar.com/docs/api#compatibility-api # and a meta attribute with the relevant items from response headers. def get_compatibility(vin:, scope:, country: 'US', options: {}) - raise InvalidParameterValue.new, 'vin is a required field' if vin.nil? - raise InvalidParameterValue.new, 'scope is a required field' if scope.nil? + raise Base::InvalidParameterValue.new, 'vin is a required field' if vin.nil? + raise Base::InvalidParameterValue.new, 'scope is a required field' if scope.nil? || scope.empty? base_object = Base.new( { @@ -166,6 +168,70 @@ def verify_payload(amt, signature, body) hash_challenge(amt, body.to_json) == signature end + # Module method Returns a paged list of all vehicle connections connected to the application. + # + # API Documentation - https://smartcar.com/docs/api#get-connections + # @param amt [String] - Application Management token + # @param filters [Hash] - Optional filter parameters (check documentation) + # @param paging [Hash] - Pass a cursor for paginated results + # @param options [Hash] Other optional parameters including overrides + # @option options [Faraday::Connection] :service Optional connection object to be used for requests + # @option options [String] :version Optional API version to use, defaults to what is globally set + # + # @return [OpenStruct] And object representing the JSON response mentioned in https://smartcar.com/docs/api#get-connections + # and a meta attribute with the relevant items from response headers. + def get_connections(amt:, filter: {}, paging: {}, options: {}) + paging[:limit] ||= 10 + base_object = Base.new( + token: generate_basic_management_auth(amt, options), + version: options[:version] || Smartcar.get_api_version, + service: options[:service], + auth_type: Base::BASIC, + url: ENV['SMARTCAR_MANAGEMENT_API_ORIGIN'] || MANAGEMENT_API_ORIGIN + ) + query_params = filter.merge(paging).compact + + base_object.build_response(*base_object.get( + PATHS[:connections], + query_params + )) + end + + def delete_connections(amt:, filter: {}, options: {}) + user_id = filter[:user_id] + vehicle_id = filter[:vehicle_id] + error_message = nil + error_message = 'Filter can contain EITHER user_id OR vehicle_id, not both.' if user_id && vehicle_id + error_message = 'Filter needs one of user_id OR vehicle_id.' unless user_id || vehicle_id + + raise Base::InvalidParameterValue.new, error_message if error_message + + query_params = {} + query_params['user_id'] = user_id if user_id + query_params['vehicle_id'] = vehicle_id if vehicle_id + + base_object = Base.new( + url: ENV['SMARTCAR_MANAGEMENT_API_ORIGIN'] || MANAGEMENT_API_ORIGIN, + auth_type: Base::BASIC, + token: generate_basic_management_auth(amt, options), + version: options[:version] || Smartcar.get_api_version, + service: options[:service] + ) + + base_object.build_response(*base_object.delete( + PATHS[:connections], + query_params + )) + end + + # returns auth token for Basic vehicle management auth + # + # @return [String] Base64 encoding of default:amt + def generate_basic_management_auth(amt, options = {}) + username = options[:username] || 'default' + Base64.strict_encode64("#{username}:#{amt}") + end + private def build_compatibility_params(vin, scope, country, options) diff --git a/lib/smartcar/base.rb b/lib/smartcar/base.rb index aa9d019..f5f0d0e 100644 --- a/lib/smartcar/base.rb +++ b/lib/smartcar/base.rb @@ -14,7 +14,7 @@ class InvalidParameterValue < StandardError; end # Constant for Basic auth type BASIC = 'Basic' - attr_accessor :token, :error, :unit_system, :version, :auth_type + attr_accessor :token, :error, :unit_system, :version, :auth_type, :url %i[get post patch put delete].each do |verb| # meta programming and define all Restful methods. @@ -54,7 +54,7 @@ class InvalidParameterValue < StandardError; end # @return [OAuth2::AccessToken] An initialized AccessToken instance that acts as service client def service @service ||= Faraday.new( - url: ENV['SMARTCAR_API_ORIGIN'] || API_ORIGIN, + url: url || ENV['SMARTCAR_API_ORIGIN'] || API_ORIGIN, request: { timeout: DEFAULT_REQUEST_TIMEOUT } ) end diff --git a/lib/smartcar/version.rb b/lib/smartcar/version.rb index 3d13947..9e80277 100644 --- a/lib/smartcar/version.rb +++ b/lib/smartcar/version.rb @@ -2,5 +2,5 @@ module Smartcar # Gem current version number - VERSION = '3.5.0' + VERSION = '3.6.0' end diff --git a/spec/smartcar/integration/smartcar_spec.rb b/spec/smartcar/integration/smartcar_spec.rb index 1b7c618..1bd35d8 100644 --- a/spec/smartcar/integration/smartcar_spec.rb +++ b/spec/smartcar/integration/smartcar_spec.rb @@ -9,12 +9,16 @@ before do @api_origin = ENV['SMARTCAR_API_ORIGIN'] ENV['SMARTCAR_API_ORIGIN'] = 'https://pizza.pasta.pi' + @management_origin = ENV['SMARTCAR_MANAGEMENT_API_ORIGIN'] + ENV['SMARTCAR_MANAGEMENT_API_ORIGIN'] = 'https://pizza.pasta.pi' + @amt = 'some-token' WebMock.disable_net_connect! end after do WebMock.allow_net_connect! ENV['SMARTCAR_API_ORIGIN'] = @api_origin + ENV['SMARTCAR_MANAGEMENT_API_ORIGIN'] = @management_origin end describe '.get_compatibility' do @@ -54,6 +58,32 @@ end end + context 'when vin is nil' do + it 'should raise error' do + expect do + subject.get_compatibility(vin: nil, scope: ['scope'], options: { mode: 'invalid' }) + end.to(raise_error do |error| + expect(error.message).to eq('vin is a required field') + end) + end + end + + context 'when scope is nil or empty' do + it 'should raise error' do + expect do + subject.get_compatibility(vin: 'vin', scope: [], options: { mode: 'invalid' }) + end.to(raise_error do |error| + expect(error.message).to eq('scope is a required field') + end) + + expect do + subject.get_compatibility(vin: 'vin', scope: nil, options: { mode: 'invalid' }) + end.to(raise_error do |error| + expect(error.message).to eq('scope is a required field') + end) + end + end + context 'when mode is set to simulated' do it 'should add it in query params' do scopes = %w[read_odometer read_location] @@ -297,4 +327,157 @@ end end end + + describe '.get_connections' do + it 'should return all connections associated with the amt (application management token)' do + header_token = subject.generate_basic_management_auth(@amt) + + stub_request(:get, 'https://pizza.pasta.pi/v2.0/management/connections?limit=10') + .with(headers: { 'Authorization' => "Basic #{header_token}" }) + .to_return( + { + status: 200, + headers: { 'content-type' => 'application/json; charset=utf-8' }, + body: + { + connections: [ + { + connectedAt: '2021-12-25T14:48:00.000Z', + userId: 'user-id-7', + vehicleId: 'vehicle-id-1' + }, + { + connectedAt: '2020-10-05T14:48:00.000Z', + userId: 'user-id-6', + vehicleId: 'vehicle-id-2' + } + ], + paging: { cursor: nil } + }.to_json + } + ) + response = subject.get_connections(amt: @amt) + + expect(response.connections.is_a?(Array)).to be_truthy + expect(response.connections[0].vehicleId).to eq('vehicle-id-1') + expect(response.paging.is_a?(OpenStruct)).to be_truthy + expect(response.paging.cursor).to be nil + end + + it 'should return all connections based on given additional filters and paging options' do + header_token = subject.generate_basic_management_auth(@amt) + + stub_request(:get, 'https://pizza.pasta.pi/v2.0/management/connections?limit=13&user_id=user_id&cursor=cursor') + .with(headers: { 'Authorization' => "Basic #{header_token}" }) + .to_return( + { + status: 200, + headers: { 'content-type' => 'application/json; charset=utf-8' }, + body: + { + connections: [ + { + connectedAt: '2021-12-25T14:48:00.000Z', + userId: 'user_id', + vehicleId: 'vehicle-id-1' + }, + { + connectedAt: '2020-10-05T14:48:00.000Z', + userId: 'user_id', + vehicleId: 'vehicle-id-2' + } + ], + paging: { cursor: 'cursor2' } + }.to_json + } + ) + response = subject.get_connections(amt: @amt, filter: { user_id: 'user_id' }, + paging: { cursor: 'cursor', limit: 13 }) + + expect(response.connections.is_a?(Array)).to be_truthy + expect(response.connections[0].vehicleId).to eq('vehicle-id-1') + expect(response.paging.is_a?(OpenStruct)).to be_truthy + expect(response.paging.cursor).to eq('cursor2') + end + end + + describe '.delete_connections' do + context 'when both user_id and vehicle_id are provided' do + it 'raises an error' do + expect do + subject.delete_connections(amt: @amt, filter: { user_id: 'user_id', vehicle_id: 'vehicle_id' }) + end.to(raise_error do |error| + expect(error.message).to eq( + 'Filter can contain EITHER user_id OR vehicle_id, not both.' + ) + end) + end + end + + context 'when neither user_id or vehicle_id is provided' do + it 'raises an error' do + expect do + subject.delete_connections(amt: @amt) + end.to(raise_error do |error| + expect(error.message).to eq( + 'Filter needs one of user_id OR vehicle_id.' + ) + end) + end + end + + it 'deletes connections by user_id' do + header_token = subject.generate_basic_management_auth(@amt) + + stub_request(:delete, 'https://pizza.pasta.pi/v2.0/management/connections?user_id=user_id') + .with(headers: { 'Authorization' => "Basic #{header_token}" }) + .to_return( + { + status: 200, + headers: { 'content-type' => 'application/json; charset=utf-8' }, + body: + { + connections: [ + { + connectedAt: '2021-12-25T14:48:00.000Z', + userId: 'user_id', + vehicleId: 'vehicle-id-1' + } + ] + }.to_json + } + ) + response = subject.delete_connections(amt: @amt, filter: { user_id: 'user_id' }) + + expect(response.connections.is_a?(Array)).to be_truthy + expect(response.connections[0].userId).to eq('user_id') + end + + it 'deletes connections by vehicle_id' do + header_token = subject.generate_basic_management_auth(@amt) + + stub_request(:delete, 'https://pizza.pasta.pi/v2.0/management/connections?vehicle_id=vehicle_id') + .with(headers: { 'Authorization' => "Basic #{header_token}" }) + .to_return( + { + status: 200, + headers: { 'content-type' => 'application/json; charset=utf-8' }, + body: + { + connections: [ + { + connectedAt: '2021-12-25T14:48:00.000Z', + userId: 'user_id', + vehicleId: 'vehicle_id' + } + ] + }.to_json + } + ) + response = subject.delete_connections(amt: @amt, filter: { vehicle_id: 'vehicle_id' }) + + expect(response.connections.is_a?(Array)).to be_truthy + expect(response.connections[0].vehicleId).to eq('vehicle_id') + end + end end