diff --git a/.yardopts b/.yardopts new file mode 100644 index 0000000..33a713f --- /dev/null +++ b/.yardopts @@ -0,0 +1,2 @@ +--markup=markdown +--no-private diff --git a/Gemfile.lock b/Gemfile.lock index ec42c4f..fbbf7ce 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,41 +1,47 @@ PATH remote: . specs: - smartcar (0.1.1) + smartcar (1.0.0) oauth2 (~> 1.4) GEM remote: https://rubygems.org/ specs: - byebug (11.0.1) + byebug (11.1.3) + childprocess (3.0.0) diff-lcs (1.3) - faraday (0.17.0) + faraday (1.0.1) multipart-post (>= 1.2, < 3) jwt (2.2.1) multi_json (1.14.1) multi_xml (0.6.0) multipart-post (2.1.1) - oauth2 (1.4.2) + oauth2 (1.4.4) faraday (>= 0.8, < 2.0) jwt (>= 1.0, < 3.0) multi_json (~> 1.3) multi_xml (~> 0.5) rack (>= 1.2, < 3) - rack (2.0.8) - rake (13.0.1) - rspec (3.8.0) - rspec-core (~> 3.8.0) - rspec-expectations (~> 3.8.0) - rspec-mocks (~> 3.8.0) - rspec-core (3.8.2) - rspec-support (~> 3.8.0) - rspec-expectations (3.8.4) + rack (2.2.2) + rake (12.3.3) + redcarpet (3.5.0) + rspec (3.9.0) + rspec-core (~> 3.9.0) + rspec-expectations (~> 3.9.0) + rspec-mocks (~> 3.9.0) + rspec-core (3.9.2) + rspec-support (~> 3.9.3) + rspec-expectations (3.9.2) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.8.0) - rspec-mocks (3.8.1) + rspec-support (~> 3.9.0) + rspec-mocks (3.9.1) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.8.0) - rspec-support (3.8.2) + rspec-support (~> 3.9.0) + rspec-support (3.9.3) + rubyzip (2.3.0) + selenium-webdriver (3.142.7) + childprocess (>= 0.5, < 4.0) + rubyzip (>= 1.2.2) PLATFORMS ruby @@ -43,8 +49,10 @@ PLATFORMS DEPENDENCIES bundler (~> 2.0) byebug (~> 11.0) - rake (>= 12.3.3) + rake (~> 12.3, >= 12.3.3) + redcarpet rspec (~> 3.0) + selenium-webdriver (~> 3.142) smartcar! BUNDLED WITH diff --git a/README.md b/README.md index 27fe5cb..d950576 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,63 @@ +# Smartcar Ruby SDK [![Gem Version][gem-url]][gem-image] Ruby gem library to quickly get started with the Smartcar API. +## Overview + +The [Smartcar API](https://smartcar.com/docs) lets you read vehicle data +(location, odometer) and send commands to vehicles (lock, unlock) using HTTP requests. + +To make requests to a vehicle from a web or mobile application, the end user +must connect their vehicle using +[Smartcar Connect](https://smartcar.com/docs/api#smartcar-connect). +This flow follows the OAuth spec and will return a `code` which can be used to +obtain an access token from Smartcar. + +The Smartcar Ruby Gem provides methods to: + +1. Generate the link to redirect to Connect. +2. Make a request to Smartcar with the `code` obtained from Connect to obtain an + access and refresh token +3. Make requests to the Smartcar API to read vehicle data and send commands to + vehicles using the access token obtained in step 2. + +Before integrating with Smartcar's SDK, you'll need to register an application +in the [Smartcar Developer portal](https://developer.smartcar.com). If you do +not have access to the dashboard, please +[request access](https://smartcar.com/subscribe). + +### Flow + +- Create a new `AuthClient` object with your `clientId`, `clientSecret`, + `redirectUri`, and required `scope`. +- Redirect the user to Smartcar Connect using `getAuthUrl` or one + of our frontend SDKs. +- The user will login, and then accept or deny your `scope`'s permissions. +- Handle the get request to `redirectUri`. + - If the user accepted your permissions, `req.query.code` will contain an + authorization code. + - Use `exchangeCode` with this code to obtain an access object + containing an access token (lasting 2 hours) and a refresh token + (lasting 60 days). + - Save this access object. + - If the user denied your permissions, `req.query.error` will be set + to `"access_denied"`. + - If you passed a state parameter to `getAuthUrl`, `req.query.state` will + contain the state value. +- Get the user's vehicles with `getVehicleIds`. +- Create a new `Vehicle` object using a `vehicleId` from the previous response, + and the `access_token`. +- Make requests to the Smartcar API. +- Use `exchangeRefreshToken` on your saved `refreshToken` to retrieve a new token + when your `accessToken` expires. + ## Installation Add this line to your application's Gemfile: ```ruby -gem 'smartcar-ruby' +gem 'smartcar' ``` And then execute: @@ -16,16 +66,16 @@ And then execute: Or install it yourself as: - $ gem install smartcar-ruby + $ gem install smartcar ## Usage -Setup the environment variables for SMARTCAR_CLIENT_ID and SMARTCAR_SECRET. +Setup the environment variables for CLIENT_ID and CLIENT_SECRET. ```bash # Get your API keys from https://dashboard.smartcar.com/signup -export SMARTCAR_CLIENT_ID= -export SMARTCAR_SECRET= -``` +export CLIENT_ID= +export CLIENT_SECRET= +``` Example Usage for calling the reports API with oAuth token ```ruby @@ -36,13 +86,26 @@ Example Usage for calling the reports API with oAuth token 2.5.5 :010 > vehicle = Smartcar::Vehicle.new(token: token, id: ids.first) => # 2.5.5 :011 > vehicle.permissions - => ["control_security", "read_battery", "read_charge", "read_location", "read_odometer", "read_vehicle_info", "read_vin"] + => # 2.5.5 :012 > vehicle.odometer => # 2.5.5 :013 > vehicle.battery => # 2.5.5 :014 > vehicle.charge => # +2.5.5 :015 > vehicle.lock! + => true +2.5.5 :016 > vehicle.start_charge! +Traceback (most recent call last): + 8: from /usr/share/rvm/rubies/ruby-2.5.5/bin/irb:23:in `
' + 7: from /usr/share/rvm/rubies/ruby-2.5.5/bin/irb:23:in `load' + 6: from /usr/share/rvm/rubies/ruby-2.5.5/lib/ruby/gems/2.7.0/gems/irb-1.2.1/exe/irb:11:in `' + 5: from (irb):7 + 4: from (irb):8:in `rescue in irb_binding' + 3: from /home/st-2vgpnn2/.rvm/gems/ruby-2.5.5/gems/smartcar-0.1.2/lib/smartcar/vehicle.rb:102:in `block (2 levels) in ' + 2: from /home/st-2vgpnn2/.rvm/gems/ruby-2.5.5/gems/smartcar-0.1.2/lib/smartcar/vehicle.rb:192:in `start_or_stop_charge!' + 1: from /home/st-2vgpnn2/.rvm/gems/ruby-2.5.5/gems/smartcar-0.1.2/lib/smartcar/base.rb:39:in `block (2 levels) in ' +Smartcar::ExternalServiceError (API error - {"error":"vehicle_state_error","message":"Charging plug is not connected to the vehicle.","code":"VS_004"}) ``` Example Usage for oAuth - @@ -72,4 +135,7 @@ To contribute, please: 1. Open an issue for the feature (or bug) you would like to resolve. 2. Resolve the issue and add tests in your feature branch. -3. Open a PR from your feature branch into `develop` that tags the issue. \ No newline at end of file +3. Open a PR from your feature branch into `develop` that tags the issue. + +[gem-image]: https://badge.fury.io/rb/smartcar +[gem-url]: https://badge.fury.io/rb/smartcar.svg diff --git a/lib/smartcar.rb b/lib/smartcar.rb index 301c02e..47b275e 100644 --- a/lib/smartcar.rb +++ b/lib/smartcar.rb @@ -15,22 +15,38 @@ require "smartcar/user" -module Smartcar + # Main Smartcar umbrella module + module Smartcar + # Error raised when a config is not found class ConfigNotFound < StandardError; end + # Error raised when Smartcar returns non 400, 404, 401, 200 or 204 response class ExternalServiceError < StandardError; end + # Error raised when Smartcar returns 404 class ServiceUnavailableError < ExternalServiceError; end + # Error raised when Smartcar returns Authentication Error with status 401 class AuthenticationError < ExternalServiceError; end - class ParserError < ExternalServiceError; end + # Error raised when Smartcar returns 400 response class BadRequestError < ExternalServiceError; end + # Smartcar API version being used API_VERSION = "v1.0".freeze + # Host to connect to smartcar SITE = "https://api.smartcar.com/".freeze # Path for smartcar oauth OAUTH_PATH = "https://connect.smartcar.com/oauth/authorize".freeze %w(success code test live force auto metric imperial).each do |constant| + # Constant to represent the value const_set(constant.upcase, constant.freeze) end + + # Lock value sent in request body LOCK = "LOCK".freeze + # Unlock value sent in request body UNLOCK = "UNLOCK".freeze + # Start charge value sent in request body + START_CHARGE = "START".freeze + # Stop charge value sent in request body + STOP_CHARGE = "STOP".freeze + # Constant for units UNITS = [IMPERIAL,METRIC] end diff --git a/lib/smartcar/base.rb b/lib/smartcar/base.rb index ac2675f..9152640 100644 --- a/lib/smartcar/base.rb +++ b/lib/smartcar/base.rb @@ -3,21 +3,24 @@ module Smartcar # The Base class for all of the other class. # Let other classes inherit from here and put common methods here. - # - # @author [ashwin] - # class Base + include Utils + + # Error raised when an invalid parameter is passed. class InvalidParameterValue < StandardError; end + # Constant for Bearer auth type BEARER = 'BEARER'.freeze + # Constant for Basic auth type BASIC = 'BASIC'.freeze - attr_accessor :token - # meta programming and define all Restful methods. - # @param path [String] the path to hit for the request. - # @param token [String] the access token to be used. - # - # @return [Hash] The response Json parsed as a hash. + attr_accessor :token, :error, :meta + %i{get post patch put delete}.each do |verb| + # meta programming and define all Restful methods. + # @param path [String] the path to hit for the request. + # @param data [Hash] request body if needed. + # + # @return [Hash] The response Json parsed as a hash. define_method verb do |path, data=nil| response = service.send(verb) do |request| request.headers['Authorization'] = "BEARER #{token}" @@ -29,21 +32,19 @@ class InvalidParameterValue < StandardError; end request.url complete_path, data else request.url complete_path - request.body = data if data + request.body = data.to_json if data end end - status = response.status - raise ServiceUnavailableError.new, "Service Unavailable - #{response.body}" if status == 404 - raise BadRequestError.new, "Bad Request - #{response.body}" if status == 400 - raise AuthenticationError.new, "Authentication error" if status == 401 - raise ExternalServiceError.new, "API error - #{response.body}" unless [200,204].include?(status) - JSON.parse(response.body) + error = get_error(response) + raise error if error + [JSON.parse(response.body), response.headers] end end # This requires a proc 'PATH' to be defined in the class - # @param token [String] Access token - # @param token [String] Vechicle ID + # @param path [String] resource path + # @param options [Hash] query params + # @param auth [String] type of auth # # @return [Object] def fetch(path: , options: {}, auth: 'BEARER') @@ -58,15 +59,14 @@ def fetch(path: , options: {}, auth: 'BEARER') # # @return [String] Base64 encoding of CLIENT:SECRET def get_basic_auth - Base64.strict_encode64("#{ENV['SMARTCAR_CLIENT_ID']}:#{ENV['SMARTCAR_SECRET']}") + Base64.strict_encode64("#{get_config('CLIENT_ID')}:#{get_config('CLIENT_SECRET')}") end # gets a smartcar API service/client - # @param token [String] Access token. # # @return [OAuth2::AccessToken] An initialized AccessToken instance that acts as service client def service @service ||= Faraday.new(url: SITE) end end -end \ No newline at end of file +end diff --git a/lib/smartcar/battery.rb b/lib/smartcar/battery.rb index a1b9882..9130bf3 100644 --- a/lib/smartcar/battery.rb +++ b/lib/smartcar/battery.rb @@ -1,12 +1,11 @@ module Smartcar - # class to represent battery info - # - # @author [ashwin] - # + # class to represent Battery info + #@attr [Number] percentRemaining Decimal value representing the remaining charge percent. + #@attr [Number] range Remaining range of the vehicle. class Battery < Base - include Utils + # Path Proc for hitting battery end point PATH = Proc.new{|id| "/vehicles/#{id}/battery"} - attr_accessor :percentRemaining, :range + attr_reader :percentRemaining, :range # just to have Ruby-esque method names alias_method :percentage_remaining, :percentRemaining diff --git a/lib/smartcar/charge.rb b/lib/smartcar/charge.rb index ec5dfe0..cede699 100644 --- a/lib/smartcar/charge.rb +++ b/lib/smartcar/charge.rb @@ -1,12 +1,11 @@ module Smartcar # class to represent Charge info - # - # @author [ashwin] - # + #@attr [Boolean] isPluggedIn Specifies if the vehicle is plugged in. + #@attr [String] state Charging state of the vehicle. class Charge < Base - include Utils + # Path Proc for hitting charge end point PATH = Proc.new{|id| "/vehicles/#{id}/charge"} - attr_accessor :isPluggedIn, :state + attr_reader :isPluggedIn, :state # just to have Ruby-esque method names alias_method :is_plugged_in?, :isPluggedIn diff --git a/lib/smartcar/engine_oil.rb b/lib/smartcar/engine_oil.rb index 2c09368..ef39388 100644 --- a/lib/smartcar/engine_oil.rb +++ b/lib/smartcar/engine_oil.rb @@ -1,12 +1,10 @@ module Smartcar - # class to represent Engine oil life - # - # @author [ashwin] - # + # class to represent Engine oil info + #@attr [Number] lifeRemaining Remaining life of the engine oil class EngineOil < Base - include Utils + # Path Proc for hitting engine oil end point PATH = Proc.new{|id| "/vehicles/#{id}/engine/oil"} - attr_accessor :lifeRemaining + attr_reader :lifeRemaining # just to have Ruby-esque method names alias_method :life_remaining, :lifeRemaining diff --git a/lib/smartcar/fuel.rb b/lib/smartcar/fuel.rb index 55a4644..8fbffe6 100644 --- a/lib/smartcar/fuel.rb +++ b/lib/smartcar/fuel.rb @@ -1,12 +1,12 @@ module Smartcar # class to represent Fuel info - # - # @author [ashwin] - # + #@attr [Number] amountRemaining Amount of fuel remaining. + #@attr [Number] percentageRemaining Decimal value representing the remaining fuel percent. + #@attr [Number] range Remaining range of the vehicle. class Fuel < Base - include Utils + # Path Proc for hitting fuel end point PATH = Proc.new{|id| "/vehicles/#{id}/fuel"} - attr_accessor :amountRemaining, :percentRemaining, :range + attr_reader :amountRemaining, :percentRemaining, :range # just to have Ruby-esque method names alias_method :amount_remaining, :amountRemaining diff --git a/lib/smartcar/location.rb b/lib/smartcar/location.rb index 7878234..0871567 100644 --- a/lib/smartcar/location.rb +++ b/lib/smartcar/location.rb @@ -1,11 +1,10 @@ module Smartcar # class to represent Location info - # - # @author [ashwin] - # + #@attr [Number] latitude Latitude of last recorded location. + #@attr [Number] longitude Longitude of last recorded location. class Location < Base - include Utils + # Path Proc for hitting location end point PATH = Proc.new{|id| "/vehicles/#{id}/location"} - attr_accessor :latitude, :longitude + attr_reader :latitude, :longitude end end diff --git a/lib/smartcar/oauth.rb b/lib/smartcar/oauth.rb index fb8791c..bab747e 100644 --- a/lib/smartcar/oauth.rb +++ b/lib/smartcar/oauth.rb @@ -1,9 +1,8 @@ module Smartcar # Oauth class to take care of the Oauth 2.0 with genomelink APIs # - # @author [ashwin] - # class Oauth < Base + extend Utils class << self # Generate the OAuth authorization URL. # @@ -13,26 +12,26 @@ class << self # approval_prompt to `force`. # # @param options [Hash] - # @param options[:state] [String] - OAuth state parameter passed to the + # @option options[:state] [String] - OAuth state parameter passed to the # redirect uri. This parameter may be used for identifying the user who # initiated the request. - # @param options[:test_mode] [Boolean] - Setting this to 'true' runs it in test mode. - # @param options[:force_prompt] [Boolean] - Setting `force_prompt` to + # @option options[:test_mode] [Boolean] - Setting this to 'true' runs it in test mode. + # @option options[:force_prompt] [Boolean] - Setting `force_prompt` to # `true` will show the permissions approval screen on every authentication # attempt, even if the user has previously consented to the exact scope of # permissions. - # @param options[:make] [String] - `make' is an optional parameter that allows + # @option options[:make] [String] - `make' is an optional parameter that allows # users to bypass the car brand selection screen. # For a complete list of supported makes, please see our # [API Reference](https://smartcar.com/docs/api#authorization) documentation. - # @param options[:scope] [Array of Strings] - array of scopes that specify what the user can access + # @option options[:scope] [Array of Strings] - array of scopes 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/ # # @return [String] URL where user needs to be redirected for authorization def authorization_url(options) parameters = { - redirect_uri: get_config('SMARTCAR_CALLBACK_URL'), + redirect_uri: get_config('REDIRECT_URI'), approval_prompt: options[:force_prompt] ? FORCE : AUTO, mode: options[:test_mode] ? TEST : LIVE, response_type: CODE @@ -41,12 +40,12 @@ def authorization_url(options) %I(state make).each do |parameter| parameters[:parameter] = options[:parameter] unless options[:parameter].nil? end - + client.auth_code.authorize_url(parameters) end # [get_token description] - # @param auth_code [String] This is the code that is returned after use + # @param auth_code [String] This is the code that is returned after use r # visits and authorizes on the authorization URL. # # @return [Hash] Hash of token, refresh token, expiry info and token type @@ -54,7 +53,7 @@ def get_token(auth_code) client.auth_code .get_token( auth_code, - redirect_uri: get_config('SMARTCAR_CALLBACK_URL') + redirect_uri: get_config('REDIRECT_URI') ).to_hash end @@ -74,20 +73,11 @@ def refresh_token(token_hash) # # @return [OAuth2::Client] A Oauth Client object. def client - @client ||= OAuth2::Client.new( get_config('SMARTCAR_CLIENT_ID'), - get_config('SMARTCAR_SECRET'), + @client ||= OAuth2::Client.new( get_config('CLIENT_ID'), + get_config('CLIENT_SECRET'), :site => OAUTH_PATH ) end - - # gets a given env variable, checks for existence and throws exception if not present - # @param config_name [String] key of the env variable - # - # @return [String] value of the env variable - def get_config(config_name) - raise ConfigNotFound, "Environment variable #{config_name} not found !" unless ENV[config_name] - ENV[config_name] - end end end end diff --git a/lib/smartcar/odometer.rb b/lib/smartcar/odometer.rb index ce20a32..cbdbc7b 100644 --- a/lib/smartcar/odometer.rb +++ b/lib/smartcar/odometer.rb @@ -1,11 +1,9 @@ module Smartcar - # class to get Charge info - # - # @author [ashwin] - # + # class to represent Odometer + #@attr [Number] distanceLast recorded odometer reading. class Odometer < Base - include Utils + # Path Proc for hitting odometer end point PATH = Proc.new{|id| "/vehicles/#{id}/odometer"} - attr_accessor :distance + attr_reader :distance end end diff --git a/lib/smartcar/permissions.rb b/lib/smartcar/permissions.rb index a98e811..6565b85 100644 --- a/lib/smartcar/permissions.rb +++ b/lib/smartcar/permissions.rb @@ -1,11 +1,9 @@ module Smartcar - # class to get Charge info - # - # @author [ashwin] - # - class Permissions - include Utils + # class to represent permissions response + #@attr [Array] permissions Array of permissions granted on the vehicle. + class Permissions < Base + # Path Proc for hitting permissions end point PATH = Proc.new{|id| "/vehicles/#{id}/permissions"} - attr_accessor :permissions + attr_reader :permissions end end diff --git a/lib/smartcar/tire_pressure.rb b/lib/smartcar/tire_pressure.rb index fa32a37..82bd930 100644 --- a/lib/smartcar/tire_pressure.rb +++ b/lib/smartcar/tire_pressure.rb @@ -1,12 +1,14 @@ module Smartcar - # class to represent Engine oil life - # - # @author [ashwin] - # + # class to represent Tire Pressure response + #@attr [Number] back_left Last recorded tire pressure of the back left tire. + #@attr [Number] back_right Last recorded tire pressure of the back right tire. + #@attr [Number] front_left Last recorded tire pressure of the front left tire. + #@attr [Number] front_right Last recorded tire pressure of the front right tire. + class TirePressure < Base - include Utils + # Path Proc for hitting tire pressure end point PATH = Proc.new{|id| "/vehicles/#{id}/tires/pressure"} - attr_accessor :backLeft, :backRight, :frontLeft, :frontRight + attr_reader :backLeft, :backRight, :frontLeft, :frontRight # just to have Ruby-esque method names alias_method :back_left, :backLeft diff --git a/lib/smartcar/user.rb b/lib/smartcar/user.rb index 4bba94f..a794dd5 100644 --- a/lib/smartcar/user.rb +++ b/lib/smartcar/user.rb @@ -1,11 +1,11 @@ module Smartcar # Class to get to user API. - # - # @author [ashwin] - # + #@attr [String] id Smartcar user id. + #@attr [String] token Access token used to connect to Smartcar API. class User < Base + # Path for hitting user end point USER_PATH = '/user'.freeze - attr_accessor :id, :token + attr_reader :id, :token def initialize(token:) raise InvalidParameterValue.new, "Access Token(token) is a required field" if token.nil? @@ -21,6 +21,6 @@ def initialize(token:) def self.user_id(token:) new(token: token).get(USER_PATH)['id'] end - + end end diff --git a/lib/smartcar/utils.rb b/lib/smartcar/utils.rb index 0b907b3..5f692ed 100644 --- a/lib/smartcar/utils.rb +++ b/lib/smartcar/utils.rb @@ -1,11 +1,35 @@ -module Utils - # A constructor to take a hash and assign it to the instance variables - # @param options = {} [Hash] Could by any class's hash, but the first level keys should be defined in the class - # - # @return [Subclass os Base] Returns object of any subclass like Report - def initialize(options = {}) - options.each do |attribute, value| - instance_variable_set("@#{attribute}", value) - end + # Utils module , provides utility methods to underlying classes + module Utils + # A constructor to take a hash and assign it to the instance variables + # @param options = {} [Hash] Could by any class's hash, but the first level keys should be defined in the class + # + # @return [Subclass os Base] Returns object of any subclass like Report + def initialize(options = {}) + options.each do |attribute, value| + instance_variable_set("@#{attribute}", value) end -end \ No newline at end of file + end + + # gets a given env variable, checks for existence and throws exception if not present + # @param config_name [String] key of the env variable + # + # @return [String] value of the env variable + def get_config(config_name) + config_name = "INTEGRATION_#{config_name}" if ENV['MODE'] == 'test' + raise Smartcar::ConfigNotFound, "Environment variable #{config_name} not found !" unless ENV[config_name] + ENV[config_name] + end + + # Given the response from smartcar API, returns an error object if needed + # @param response [Object] response Object with status and body + # + # @return [Object] nil OR Error object + def get_error(response) + status = response.status + return nil if [200,204].include?(status) + return Smartcar::ServiceUnavailableError.new, "Service Unavailable - #{response.body}" if status == 404 + return Smartcar::BadRequestError.new, "Bad Request - #{response.body}" if status == 400 + return Smartcar::AuthenticationError.new, "Authentication error" if status == 401 + return Smartcar::ExternalServiceError.new, "API error - #{response.body}" + end +end diff --git a/lib/smartcar/vehicle.rb b/lib/smartcar/vehicle.rb index 1ee4932..afad175 100644 --- a/lib/smartcar/vehicle.rb +++ b/lib/smartcar/vehicle.rb @@ -4,14 +4,21 @@ module Smartcar # For Ex. Vehicle object will be treate as an entity and doing vehicle_object. # Battery should return Battery object. # - # @author [ashwin] - # + #@attr [String] token Access token used to connect to Smartcar API. + #@attr [String] id Smartcar vehicle ID. + #@attr [String] unit_system unit system to represent the data in. class Vehicle < Base + include Utils + + # Path for hitting compatibility end point COMPATIBLITY_PATH = '/compatibility'.freeze + + # Path for hitting vehicle ids end point PATH = Proc.new{|id| "/vehicles/#{id}"} - attr_accessor :token, :id, :unit_system + attr_reader :id + attr_accessor :token, :unit_system def initialize(token:, id:, unit_system: IMPERIAL) raise InvalidParameterValue.new, "Invalid Units provided : #{unit_system}" unless UNITS.include?(unit_system) @@ -22,13 +29,6 @@ def initialize(token:, id:, unit_system: IMPERIAL) @unit_system = unit_system end - # Accessor method for vehicle attributes. - %I(make model year).each do |method_name| - define_method method_name do - vehicle_attributes.send(method_name) - end - end - # Class method Used to get all the vehicles in the app. This only returns # API - https://smartcar.com/docs/api#get-all-vehicles # @param token [String] - Access token @@ -36,10 +36,11 @@ def initialize(token:, id:, unit_system: IMPERIAL) # # @return [Array] of vehicle IDs(Strings) def self.all_vehicle_ids(token:, options: {}) - new(token: token, id: 'none').fetch( + response, meta = new(token: token, id: 'none').fetch( path: PATH.call(''), options: options - )['vehicles'] + ) + response['vehicles'] end # Class method Used to check compatiblity for VIN and scope @@ -52,13 +53,26 @@ def self.compatible?(vin:, scope:) raise InvalidParameterValue.new, "vin is a required field" if vin.nil? raise InvalidParameterValue.new, "scope is a required field" if scope.nil? - new(token: 'none', id: 'none').fetch(path: COMPATIBLITY_PATH, + response, meta = new(token: 'none', id: 'none').fetch(path: COMPATIBLITY_PATH, options: { vin: vin, scope: scope.join(' ') }, auth: BASIC - )['compatible'] + ) + response['compatible'] + end + + # Method to get batch requests + # API - https://smartcar.com/docs/api#post-batch-request + # @param attributes [Array] Array of strings or symbols of attributes to be fetched together + # + # @return [Hash] Hash wth key as requested attribute(symbol) and value as Error OR Object of the requested attribute + def batch(attributes = []) + raise InvalidParameterValue.new, "vin is a required field" if attributes.nil? + request_body = get_batch_request_body(attributes) + response, _meta = post(PATH.call(id) + "/batch", request_body) + process_batch_response(response) end # Fetch the list of permissions that this application has been granted for @@ -68,10 +82,7 @@ def self.compatible?(vin:, scope:) # # @return [Array] of permissions (Strings) def permissions(options: {}) - Permissions.new(fetch( - path: Permissions::PATH.call(id), - options: options - )).permissions + get_attribute(Permissions) end # Method Used toRevoke access for the current requesting application @@ -83,104 +94,201 @@ def disconnect! response['status'] == SUCCESS end - # Methods Used lock or unlock car + # Methods Used to lock car # API - https://smartcar.com/docs/api#post-security # # @return [Boolean] true if success - %w(lock unlock).each do |method_name| - define_method "#{method_name}!" do - lock_or_unlock!(action: Smartcar.const_get(method_name.upcase)) - end + def lock! + lock_or_unlock!(action: Smartcar::LOCK) end - # Following section defined methods using meta programing to fetch various - # details of a vehicle. The key is the method name, and value is Class that - # wraps the data. - { - # Returns the state of charge (SOC) and remaining range of an electric or - # plug-in hybrid vehicle's battery. - # API - https://smartcar.com/docs/api#get-ev-battery - # - # @return [Battery] object - battery: Battery, - # Returns the current charge status of the vehicle. - # API - https://smartcar.com/docs/api#get-ev-battery - # - # @return [Charge] object - charge: Charge, - # Returns the remaining life span of a vehicle's engine oil - # API - https://smartcar.com/docs/api#get-engine-oil-life - # - # @return [EngineOil] object - engine_oil: EngineOil, - # Returns the status of the fuel remaining in the vehicle's gas tank. - # API - https://smartcar.com/docs/api#get-fuel-tank - # - # @return [Fuel] object - fuel: Fuel, - # Returns the last known location of the vehicle in geographic coordinates. - # API - https://smartcar.com/docs/api#get-location - # - # @return [Location] object - location: Location, - # Returns the vehicle's last known odometer reading. - # API - https://smartcar.com/docs/api#get-odometer - # - # @return [Odometer] object - odometer: Odometer, - # Returns the air pressure of each of the vehicle's tires. - # API - https://smartcar.com/docs/api#get-tire-pressure - # - # @return [TirePressure] object - tire_pressure: TirePressure, - }.each do |method_name, klass| - define_method method_name do - klass.new( - fetch( - path: klass::PATH.call(id) - ) - ) - end + # Methods Used to unlock car + # API - https://smartcar.com/docs/api#post-security + # + # @return [Boolean] true if success + def unlock! + lock_or_unlock!(action: Smartcar::UNLOCK) end - #NOTE : The following two also could be defined by metaprogramming, - # But these are items that dont change and hence can be cached in the - # vehicle object. + # Method used to start charging a car + # + # + # @return [Boolean] true if success + def start_charge! + start_or_stop_charge!(action: Smartcar::START_CHARGE) + end - # Returns the vehicle's manufacturer identifier. - # API - https://smartcar.com/docs/api#get-vin + # Method used to stop charging a car # - # @return [Vin] object - def vin - @vin ||= Vin.new( - fetch( - path: Vin::PATH.call(id) - ) - ).vin + # + # @return [Boolean] true if success + def stop_charge! + start_or_stop_charge!(action: Smartcar::STOP_CHARGE) end - # Returns the vehicle's model make and year. - # API - https://smartcar.com/docs/api#get-vehicle-attributes + # Returns make model year and id of the vehicle + # API - https://smartcar.com/api#get-vehicle-attributes # # @return [VehicleAttributes] object def vehicle_attributes - @vehicle_attributes ||= VehicleAttributes.new( - fetch( - path: PATH.call(id) - ) - ) + get_attribute(VehicleAttributes) + end + + # Returns the state of charge (SOC) and remaining range of an electric or + # plug-in hybrid vehicle's battery. + # API - https://smartcar.com/docs/api#get-ev-battery + # + # @return [Battery] object + def battery + get_attribute(Battery) + end + + # Returns the current charge status of the vehicle. + # API - https://smartcar.com/docs/api#get-ev-battery + # + # @return [Charge] object + def charge + get_attribute(Charge) + end + + # Returns the remaining life span of a vehicle's engine oil + # API - https://smartcar.com/docs/api#get-engine-oil-life + # + # @return [EngineOil] object + def engine_oil + get_attribute(EngineOil) + end + + # Returns the status of the fuel remaining in the vehicle's gas tank. + # API - https://smartcar.com/docs/api#get-fuel-tank + # + # @return [Fuel] object + def fuel + get_attribute(Fuel) + end + + # Returns the last known location of the vehicle in geographic coordinates. + # API - https://smartcar.com/docs/api#get-location + # + # @return [Location] object + def location + get_attribute(Location) + end + + # Returns the vehicle's last known odometer reading. + # API - https://smartcar.com/docs/api#get-odometer + # + # @return [Odometer] object + def odometer + get_attribute(Odometer) + end + + # Returns the air pressure of each of the vehicle's tires. + # API - https://smartcar.com/docs/api#get-tire-pressure + # + # @return [TirePressure] object + def tire_pressure + get_attribute(TirePressure) + end + + # Returns the vehicle's manufacturer identifier (VIN). + # API - https://smartcar.com/docs/api#get-vin + # + # @return [String] Vin of the vehicle. + def vin + _object = get_attribute(Vin) + @vin ||= _object.vin end private + def allowed_attributes + @allowed_attributes ||= { + battery: get_path(Battery), + charge: get_path(Charge), + engine_oil: get_path(EngineOil), + fuel: get_path(Fuel), + location: get_path(Location), + odometer: get_path(Odometer), + permissions: get_path(Permissions), + tire_pressure: get_path(TirePressure), + vin: get_path(Vin), + } + end + + def path_to_class + @path_to_class ||= { + get_path(Battery) => Battery, + get_path(Charge) => Charge, + get_path(EngineOil) => EngineOil, + get_path(Fuel) => Fuel, + get_path(Location) => Location, + get_path(Odometer) => Odometer, + get_path(Permissions) => Permissions, + get_path(TirePressure) => TirePressure, + get_path(Vin) => Vin, + } + end + + # @private + BatchItemResponse = Struct.new(:body, :status, :headers) do + def body_with_meta + body.merge(meta: headers) + end + end + + def get_batch_request_body(attributes) + attributes = validated_attributes(attributes) + requests = attributes.each_with_object([]) do |item, requests| + requests << { path: allowed_attributes[item] } + end + { requests: requests } + end + + def process_batch_response(responses) + inverted_map = allowed_attributes.invert + responses["responses"].each_with_object({}) do |response, result| + item_response = BatchItemResponse.new(response["body"], response["code"], response["headers"]) + error = get_error(item_response) + path = response["path"] + result[inverted_map[path]] = error || get_object(path_to_class[path], item_response.body_with_meta) + end + end + + def validated_attributes(attributes) + attributes.map!(&:to_sym) + unsupported_attributes = (attributes - allowed_attributes.keys) || [] + unless unsupported_attributes.empty? + message = "Unsupported attribute(s) requested in batch - #{unsupported_attributes.join(',')}" + raise InvalidParameterValue.new, message + end + attributes + end + + def get_attribute(klass) + body, meta = fetch( + path: klass::PATH.call(id) + ) + get_object(klass, body.merge(meta: meta)) + end + + def get_object(klass, data) + klass.new(data) + end + + def get_path(klass) + path = klass::PATH.call(id) + path.split("/vehicles/#{id}").last + end + def lock_or_unlock!(action:) - response = post(PATH.call(id) + "/security", {action: action}.to_json) + response, meta = post(PATH.call(id) + "/security", { action: action }) response['status'] == SUCCESS end - class VehicleAttributes - include Utils - attr_accessor :id, :make, :model, :year + def start_or_stop_charge!(action:) + response, meta = post(PATH.call(id) + "/charge", { action: action }) + response['status'] == SUCCESS end end end diff --git a/lib/smartcar/vehicle_attributes.rb b/lib/smartcar/vehicle_attributes.rb new file mode 100644 index 0000000..bb347a5 --- /dev/null +++ b/lib/smartcar/vehicle_attributes.rb @@ -0,0 +1,12 @@ +module Smartcar + # class to represent Vehicle attributes like make model year + #@attr [String] id Smartcar vehicle ID + #@attr [String] make Manufacturer of the vehicle. + #@attr [String] model Model of the vehicle. + #@attr [Number] year Model year of the vehicle. + class VehicleAttributes < Base + # Path Proc for hitting vehicle attributes end point + PATH = Proc.new{|id| "/vehicles/#{id}"} + attr_accessor :id, :make, :model, :year + end +end diff --git a/lib/smartcar/version.rb b/lib/smartcar/version.rb index 51c1add..059f2f1 100644 --- a/lib/smartcar/version.rb +++ b/lib/smartcar/version.rb @@ -1,3 +1,4 @@ module Smartcar - VERSION = "0.1.1" + # Gem current version number + VERSION = "1.0.0" end diff --git a/lib/smartcar/vin.rb b/lib/smartcar/vin.rb index c2499d3..8df45d9 100644 --- a/lib/smartcar/vin.rb +++ b/lib/smartcar/vin.rb @@ -1,11 +1,10 @@ module Smartcar - # class to represent Engine oil life - # - # @author [ashwin] + # Hidden class to represent vin # + #@attr [String] vin Vin of the vehicle class Vin < Base - include Utils + # Path Proc for hitting vin end point PATH = Proc.new{|id| "/vehicles/#{id}/vin"} - attr_accessor :vin + attr_reader :vin end end diff --git a/ruby-sdk.gemspec b/ruby-sdk.gemspec new file mode 100644 index 0000000..3d21edc --- /dev/null +++ b/ruby-sdk.gemspec @@ -0,0 +1,29 @@ +lib = File.expand_path("lib", __dir__) +$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) +require "smartcar/version" + +Gem::Specification.new do |spec| + spec.name = "smartcar" + spec.version = Smartcar::VERSION + spec.required_ruby_version = ">= 2.5.0" + spec.authors = ["Ashwin Subramanian"] + spec.email = ["ashwin.subramanian@smartcar.com"] + spec.homepage = 'https://rubygems.org/gems/smartcar' + spec.summary = %q{Ruby Gem to access smartcar APIs (https://smartcar.com/docs/)} + spec.description = %q{This is a ruby gem to access the smartcar APIs. It includes the API classes and the OAuth system.} + spec.license = "MIT" + spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do + `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } + end + spec.bindir = "exe" + spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } + spec.require_paths = ["lib"] + + spec.add_development_dependency "bundler", "~> 2.0" + spec.add_development_dependency 'rake', '~> 12.3', '>= 12.3.3' + spec.add_development_dependency "rspec", "~> 3.0" + spec.add_development_dependency "byebug", "~> 11.0" + spec.add_development_dependency "redcarpet" + spec.add_development_dependency "selenium-webdriver", "~> 3.142" + spec.add_dependency "oauth2", "~> 1.4" +end diff --git a/smartcar-ruby.gemspec b/smartcar-ruby.gemspec deleted file mode 100644 index 90d4ff2..0000000 --- a/smartcar-ruby.gemspec +++ /dev/null @@ -1,26 +0,0 @@ -lib = File.expand_path("lib", __dir__) -$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) -require "smartcar/version" - -Gem::Specification.new do |spec| - spec.name = "smartcar" - spec.version = Smartcar::VERSION - spec.authors = ["Ashwin Subramanian"] - spec.email = ["sshwin.subramanian@smartcar.com"] - - spec.summary = %q{Ruby Gem to access smartcar APIs (https://smartcar.com/docs/)} - spec.description = %q{This is a ruby gem to access the smartcar APIs. It includes the API classes and the OAuth system.} - spec.license = "MIT" - spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do - `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } - end - spec.bindir = "exe" - spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } - spec.require_paths = ["lib"] - - spec.add_development_dependency "bundler", "~> 2.0" - spec.add_development_dependency "rake", ">= 12.3.3" - spec.add_development_dependency "rspec", "~> 3.0" - spec.add_development_dependency "byebug", "~> 11.0" - spec.add_dependency "oauth2", "~> 1.4" -end diff --git a/spec/smartcar/e2e/oauth.rb b/spec/smartcar/e2e/oauth.rb new file mode 100644 index 0000000..f300173 --- /dev/null +++ b/spec/smartcar/e2e/oauth.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require_relative '../helpers/auth_helper' +require_relative '../../spec_helper' + +RSpec.describe Smartcar::Oauth do + subject { Smartcar::Oauth } + + describe '.get_token' do + it 'should fetch all the tokens' do + url = subject.authorization_url(AuthHelper.auth_client_params) + code = AuthHelper.run_auth_flow(url) + token_hash = subject.get_token(code) + expect(token_hash.keys.map(&:to_s)).to match_array(%w[token_type access_token refresh_token expires_at]) + end + end + + describe '.refresh_token' do + it 'should refresh and fetch all the tokens' do + url = subject.authorization_url(AuthHelper.auth_client_params) + code = AuthHelper.run_auth_flow(url) + old_token_hash = subject.get_token(code) + new_token_hash = subject.refresh_token(old_token_hash) + expect(new_token_hash.keys.map(&:to_s)).to match_array(%w[token_type access_token refresh_token expires_at]) + end + end +end diff --git a/spec/smartcar/e2e/vehicle.rb b/spec/smartcar/e2e/vehicle.rb new file mode 100644 index 0000000..949089d --- /dev/null +++ b/spec/smartcar/e2e/vehicle.rb @@ -0,0 +1,146 @@ +# frozen_string_literal: true + +require_relative '../helpers/auth_helper' +require_relative '../../spec_helper' + +RSpec.describe Smartcar::Vehicle do + subject { Smartcar::Vehicle } + let(:vehicle) do + url = Smartcar::Oauth.authorization_url(AuthHelper.auth_client_params) + token_hash = Smartcar::Oauth.get_token(AuthHelper.run_auth_flow(url)) + token = token_hash[:access_token] + ids = Smartcar::Vehicle.all_vehicle_ids(token: token) + Smartcar::Vehicle.new(token: token, id: ids.first) + end + + describe '.compatible?' do + it 'should response if vehicle is compatible for given scopes' do + tesla_vin = '5YJXCDE22HF068739' + audi_vin = 'WAUAFAFL1GN014882' + scopes = %w[read_odometer read_location] + + result = subject.compatible?(vin: tesla_vin, scope: scopes) + expect(result).to be_truthy + + result = subject.compatible?(vin: audi_vin, scope: scopes) + expect(result).to be_falsey + end + end + + describe '#battery' do + it 'should return an battery object' do + result = vehicle.battery + expect(result.instance_of?(Smartcar::Battery)).to eq(true) + expect(result.percentage_remaining).not_to be_nil + expect(result.range).not_to be_nil + expect(result.meta).not_to be_nil + end + end + + describe '#charge' do + it 'should return an charge object' do + result = vehicle.charge + expect(result.instance_of?(Smartcar::Charge)).to eq(true) + expect(result.is_plugged_in?).not_to be_nil + expect(result.state).not_to be_nil + expect(result.meta).not_to be_nil + end + end + + describe '#engine_oil' do + it 'should return an engine_oil object' do + result = vehicle.engine_oil + expect(result.instance_of?(Smartcar::EngineOil)).to eq(true) + expect(result.meta).not_to be_nil + expect(result.life_remaining).not_to be_nil + end + end + + describe '#fuel' do + it 'should return an fuel object' do + result = vehicle.fuel + expect(result.instance_of?(Smartcar::Fuel)).to eq(true) + expect(result.percent_remaining).not_to be_nil + expect(result.amount_remaining).not_to be_nil + expect(result.meta).not_to be_nil + expect(result.range).not_to be_nil + end + end + + describe '#location' do + it 'should return an location object' do + result = vehicle.location + expect(result.instance_of?(Smartcar::Location)).to eq(true) + expect(result.latitude).not_to be_nil + expect(result.meta).not_to be_nil + expect(result.longitude).not_to be_nil + end + end + + describe '#permissions' do + it 'should return an permissions object' do + result = vehicle.permissions + expect(result.instance_of?(Smartcar::Permissions)).to eq(true) + expect(result.meta).not_to be_nil + expect(result.permissions).not_to be_nil + end + end + + describe '#tire_pressure' do + it 'should return an tire_pressure object' do + result = vehicle.tire_pressure + expect(result.instance_of?(Smartcar::TirePressure)).to eq(true) + expect(result.meta).not_to be_nil + expect(result.back_left).not_to be_nil + expect(result.front_left).not_to be_nil + expect(result.back_right).not_to be_nil + expect(result.front_right).not_to be_nil + end + end + + describe '#vin' do + it 'should return an vin string' do + result = vehicle.vin + expect(result.instance_of?(String)).to eq(true) + expect(result).not_to be_nil + end + end + + describe '#odometer' do + it 'should return an odometer object' do + result = vehicle.odometer + expect(result.instance_of?(Smartcar::Odometer)).to eq(true) + expect(result.meta).not_to be_nil + expect(result.distance).not_to be_nil + end + end + + describe '#batch - success' do + context 'with valid attributes' do + it 'should return hash of objects with attribute requested as keys' do + attributes = %I[charge battery odometer] + result = vehicle.batch(attributes) + expect(result.instance_of?(Hash)).to eq(true) + expect(result.keys).to match_array(attributes) + expect(result[:charge].instance_of?(Smartcar::Charge)).to eq(true) + expect(result[:charge].is_plugged_in?).not_to be_nil + expect(result[:charge].state).not_to be_nil + expect(result[:charge].meta).not_to be_nil + expect(result[:battery].instance_of?(Smartcar::Battery)).to eq(true) + expect(result[:battery].percentage_remaining).not_to be_nil + expect(result[:battery].range).not_to be_nil + expect(result[:battery].meta).not_to be_nil + expect(result[:odometer].instance_of?(Smartcar::Odometer)).to eq(true) + expect(result[:odometer].meta).not_to be_nil + expect(result[:odometer].distance).not_to be_nil + end + end + + context 'with some invalid attributes' do + it 'should raise InvalidParameterValue error' do + attributes = %I[odometer what where] + expect { vehicle.batch(attributes) }.to raise_error(Smartcar::Base::InvalidParameterValue) + end + end + end +end diff --git a/spec/smartcar/helpers/auth_helper.rb b/spec/smartcar/helpers/auth_helper.rb new file mode 100644 index 0000000..aafdcd4 --- /dev/null +++ b/spec/smartcar/helpers/auth_helper.rb @@ -0,0 +1,57 @@ +require_relative "../../../lib/smartcar.rb" +require "selenium-webdriver" +require "securerandom" +require "cgi" + +class AuthHelper + class << self + def get_code(uri) + code_hash = CGI.parse(URI.parse(uri).query) + code_hash["code"].first + end + + def auth_client_params + { + redirect_uri: "https://example.com/auth", + scope: [ + "required:read_vehicle_info", + "required:read_location", + "required:read_odometer", + "required:control_security", + "required:read_vin", + "required:read_fuel", + "required:read_battery", + "required:read_charge", + "required:read_engine_oil", + "required:read_tires", + "required:control_charge", + ], + test_mode: true, + } + end + + def run_auth_flow(authorization_url) + options = Selenium::WebDriver::Firefox::Options.new(args: ['-headless']) + email = "#{SecureRandom.uuid}@email.com"; + driver = Selenium::WebDriver.for :firefox, options: options + driver.navigate.to authorization_url + driver.find_element(css: "button#continue-button").click + driver.find_element(css: "button.brand-selector-button[data-make=\"CHEVROLET\"]").click + driver.find_element(css: "input[id=username]").send_keys(email) + driver.find_element(css: "input[id=password").send_keys('password') + driver.find_element(css: "button[id=sign-in-button]").click + + wait = Selenium::WebDriver::Wait.new(:timeout => 10) + + wait.until { + element = driver.find_element(:css, "button[id=approval-button]") + element if element.displayed? + }.click + uri = wait.until{ + driver.current_url if driver.current_url.match('example.com') + } + driver.quit + get_code(uri) + end + end +end diff --git a/spec/smartcar/unit/oauth_spec.rb b/spec/smartcar/unit/oauth_spec.rb index 8c70318..6010ef5 100644 --- a/spec/smartcar/unit/oauth_spec.rb +++ b/spec/smartcar/unit/oauth_spec.rb @@ -1,10 +1,9 @@ -require 'byebug' RSpec.describe Smartcar::Oauth do subject { Smartcar::Oauth } let(:obj) { double("dummy object for client") } before do - allow(subject).to receive(:get_config).with('SMARTCAR_CALLBACK_URL').and_return("test_url") + allow(subject).to receive(:get_config).with('REDIRECT_URI').and_return("test_url") allow(subject).to receive_message_chain(:client, :auth_code).and_return(obj) end @@ -77,4 +76,4 @@ end end end -end \ No newline at end of file +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 9821e6c..26eac69 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,13 +1,12 @@ require "bundler/setup" require "smartcar" - RSpec.configure do |config| # Enable flags like --only-failures and --next-failure config.example_status_persistence_file_path = ".rspec_status" # Disable RSpec exposing methods globally on `Module` and `main` config.disable_monkey_patching! - + ENV["INTEGRATION_REDIRECT_URI"] = "https://example.com/auth" if ENV["MODE"] == "test" config.expect_with :rspec do |c| c.syntax = :expect end