diff --git a/.travis.yml b/.travis.yml index e8fe38f..c939e47 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,6 +3,11 @@ script: "bundle exec rake spec" rvm: - 1.9.3 - 2.0.0 + - 2.1.5 + - 2.2.1 +before_install: + - gem install bundler + - bundle --version env: # None for now gemfile: diff --git a/HISTORY b/HISTORY index 55668e1..bdeb3e1 100644 --- a/HISTORY +++ b/HISTORY @@ -1,3 +1,10 @@ +=== 2.0.0.0 2016-07-19 +* adds support for v204 of Sift Science's APIs +* adds Workflow Status API, User Decisions API, Order Decisions API +* v204 APIs are now called by default -- this is an incompatible change + (use :version => 203 to call the previous API version) +* uses Hash arg for optional params in Client methods -- incompatible change + === 1.1.7.2 2015-04-13 * Fixed backwards compatibility issue diff --git a/README.rdoc b/README.rdoc index 0bc55b8..d21e355 100644 --- a/README.rdoc +++ b/README.rdoc @@ -1,5 +1,6 @@ = Sift Science Ruby bindings {Build Status}[https://travis-ci.org/SiftScience/sift-ruby] + == Requirements * Ruby 1.8.7 or above. (Ruby 1.8.6 might work if you load ActiveSupport.) @@ -12,6 +13,7 @@ For development only: * webmock, 1.16 or greater * rake, any version + == Installation If you want to build the gem from source: @@ -22,16 +24,19 @@ Alternatively, you can install the gem from Rubyforge: $ gem install sift + == Usage + require "sift" Sift.api_key = '' + Sift.account_id = '' client = Sift::Client.new() # send a transaction event -- note this is blocking event = "$transaction" - user_id = "23056" # User ID's may only contain a-z, A-Z, 0-9, =, ., -, _, +, @, :, &, ^, %, !, $ + user_id = "23056" # User ID's may only contain a-z, A-Z, 0-9, =, ., -, _, +, @, :, &, ^, %, !, $ properties = { "$user_id" => user_id, @@ -50,20 +55,39 @@ Alternatively, you can install the gem from Rubyforge: } response = client.track(event, properties) - - response.ok? # returns true or false - - response.http_status_code # HTTP response code, 200 is ok. - - response.api_status # status field in the return body, Link to Error Codes - response.api_error_message # Error message associated with status Error Code + response.ok? # returns true or false + response.body # API response body + response.http_status_code # HTTP response code, 200 is ok. + response.api_status # status field in the return body, Link to Error Codes + response.api_error_message # Error message associated with status Error Code - # Request a score forthe user with user_id 23056 + + # Request a score for the user with user_id 23056 response = client.score(user_id) - + + # Label the user with user_id 23056 as Bad with all optional fields - response = client.label(user_id,{ "$is_bad" => true, "$reasons" => ["$chargeback", ], "$description" => "Chargeback issued", "$source" => "Manual Review", "$analyst" => "analyst.name@your_domain.com"}) + response = client.label(user_id, { + "$is_bad" => true, + "$abuse_type" => "payment_abuse", + "$description" => "Chargeback issued", + "$source" => "Manual Review", + "$analyst" => "analyst.name@your_domain.com" + }) + + + # Get the status of a workflow run + response = client.get_workflow_status('my_run_id') + + + # Get the latest decisions for a user + response = client.get_user_decisions('example_user_id') + + + # Get the latest decisions for an order + response = client.get_order_decisions('example_order_id') + == Building @@ -78,6 +102,7 @@ Building and publishing the gem is captured by the following steps: $ rake install $ rake release + == Testing To run the various tests use the rake command as follows: diff --git a/lib/sift.rb b/lib/sift.rb index b5584de..2c35174 100644 --- a/lib/sift.rb +++ b/lib/sift.rb @@ -3,21 +3,46 @@ module Sift - # Returns the path for the current API version - def self.current_rest_api_path - "/v#{API_VERSION}/events" + # Returns the path for the specified API version + def self.rest_api_path(version=API_VERSION) + "/v#{version}/events" end - def self.current_users_label_api_path(user_id) - # This API version is a minor version ahead of the /events API - "/v#{API_VERSION}/users/#{URI.encode(user_id)}/labels" + # Returns the Score API path for the specified user ID and API version + def self.score_api_path(user_id, version=API_VERSION) + "/v#{version}/score/#{URI.encode(user_id)}/" end - - # Adding module scoped public API key + + # Returns the users API path for the specified user ID and API version + def self.users_label_api_path(user_id, version=API_VERSION) + "/v#{version}/users/#{URI.encode(user_id)}/labels" + end + + # Returns the path for the Workflow Status API + def self.workflow_status_path(account_id, run_id) + "/v3/accounts/#{account_id}/workflows/runs/#{run_id}" + end + + # Returns the path for User Decisions API + def self.user_decisions_api_path(account_id, user_id) + "/v3/accounts/#{account_id}/users/#{user_id}/decisions" + end + + # Returns the path for Orders Decisions API + def self.order_decisions_api_path(account_id, order_id) + "/v3/accounts/#{account_id}/orders/#{order_id}/decisions" + end + + # Module-scoped public API key class << self attr_accessor :api_key end + # Module-scoped account ID + class << self + attr_accessor :account_id + end + # Sets the Output logger to use within the client. This can be left uninitializaed # but is useful for debugging. def self.logger=(logger) diff --git a/lib/sift/client.rb b/lib/sift/client.rb index 663f22e..17422f9 100644 --- a/lib/sift/client.rb +++ b/lib/sift/client.rb @@ -15,8 +15,9 @@ class Response # Constructor # - # == Parameters: - # http_response + # ==== Parameters: + # + # http_response:: # The HTTP body text returned from the API call. The body is expected to be # a JSON object that can be decoded into status, message and request # sections. @@ -37,10 +38,11 @@ def initialize(http_response, http_response_code, http_raw_response) # Helper method returns true if and only if the response from the API call was # successful # - # == Returns: - # true on success; false otherwise + # ==== Returns: + # + # true on success; false otherwise + # def ok? - if @http_raw_response.kind_of? Net::HTTPNoContent #if there is no content expected, use HTTP code 204 == @http_status_code @@ -51,14 +53,14 @@ def ok? end - # DEPRECIATED - # Getter method for depreciated 'json' member variable. + # DEPRECATED + # Getter method for deprecated 'json' member variable. def json @body end - # DEPRECIATED - # Getter method for depreciated 'original_request' member variable. + # DEPRECATED + # Getter method for deprecated 'original_request' member variable. def original_request @request end @@ -67,29 +69,50 @@ def original_request # This class wraps accesses through the API # class Client - API_ENDPOINT = "https://api.siftscience.com" - API_TIMEOUT = 2 + API_ENDPOINT = 'https://api.siftscience.com' + API3_ENDPOINT = 'https://api3.siftscience.com' include HTTParty base_uri API_ENDPOINT + # Constructor # - # == Parameters: - # api_key - # The Sift Science API key associated with your customer account. This parameter - # cannot be nil or blank. - # path - # The path to the event API, e.g., "/v201/events" + # ==== Parameters: # - def initialize(api_key = Sift.api_key, path = Sift.current_rest_api_path, timeout = API_TIMEOUT) - raise("api_key must be a non-empty string") if !api_key.is_a?(String) || api_key.empty? - raise("path must be a non-empty string") if !path.is_a?(String) || path.empty? - @api_key = api_key - @path = path - @timeout = timeout - - + # opts (optional):: + # A Hash of optional parameters for this Client -- + # + # :api_key:: + # The Sift Science API key associated with your account. + # Sift.api_key is used if this parameter is not set. + # + # :account_id:: + # The ID of your Sift Science account. Sift.account_id is + # used if this parameter is not set. + # + # :timeout:: + # The number of seconds to wait before failing a request. By + # default this is configured to 2 seconds. + # + # :version:: + # The version of the Events API, Score API, and Labels API to call. + # By default, version 204. + # + # :path:: + # The URL path to use for Events API path. By default, the + # official path of the specified-version of the Events API. + # + # + def initialize(opts = {}) + @api_key = opts[:api_key] || Sift.api_key + @account_id = opts[:account_id] || Sift.account_id + @version = opts[:version] || API_VERSION + @timeout = opts[:timeout] || 2 # 2-second timeout by default + @path = opts[:path] || Sift.rest_api_path(@version) + + raise("api_key must be a non-empty string") if !@api_key.is_a?(String) || @api_key.empty? + raise("path must be a non-empty string") if !@path.is_a?(String) || @path.empty? end def api_key @@ -97,64 +120,95 @@ def api_key end def user_agent - "SiftScience/v#{API_VERSION} sift-ruby/#{VERSION}" + "SiftScience/v#{@version} sift-ruby/#{VERSION}" end - # Tracks an event and associated properties through the Sift Science API. This call - # is blocking. + + # Sends an event to the Sift Science Events API. # - # == Parameters: - # event - # The name of the event to send. This can be either a reserved event name, like - # $transaction or $label or a custom event name (that does not start with a $). - # This parameter must be specified. + # See https://siftscience.com/developers/docs/ruby/events-api . # - # properties - # A hash of name-value pairs that specify the event-specific attributes to track. - # This parameter must be specified. + # ==== Parameters: + # + # event:: + # The name of the event to send. This can be either a reserved + # event name, like $transaction or $label or a custom event name + # (that does not start with a $). This parameter must be + # specified. + # + # properties:: + # A hash of name-value pairs that specify the event-specific + # attributes to track. This parameter must be specified. + # + # opts (optional):: + # A Hash of optional parameters for the request -- + # + # :return_score:: + # If true, requests that the response include a score for this + # user, computed using the submitted event. See + # https://siftscience.com/developers/docs/ruby/score-api/synchronous-scores + # + # :abuse_types:: + # List of abuse types, specifying for which abuse types a + # score should be returned (if scoring was requested). By + # default, a score is returned for every abuse type to which + # you are subscribed. # - # timeout (optional) - # The number of seconds to wait before failing the request. By default this is - # configured to 2 seconds (see above). This parameter is optional. + # :return_action:: + # If true, requests that the response include any actions + # triggered as a result of the tracked event. # - # path (optional) - # Overrides the default API path with a different URL. + # :return_workflow_status:: + # If true, requests that the response include the status of + # any workflow run as a result of the tracked event. See + # https://siftscience.com/developers/docs/ruby/workflows-api/workflow-decisions # - # return_score (optional) - # Whether the API response should include a score for this user. The score will - # be calculated using the submitted event. This feature must be - # enabled for your account in order to use it. Please contact - # support@siftscience.com if you are interested in using this feature. + # :timeout:: + # Overrides the timeout (in seconds) for this call. # - # return_action (optional) - # Whether the API response should include an action triggered for this transaction. + # :api_key:: + # Overrides the API key for this call. # - # == Returns: - # In the case of an HTTP error (timeout, broken connection, etc.), this - # method returns nil; otherwise, a Response object is returned and captures - # the status message and status code. In general, you can ignore the returned - # result, though. + # :version:: + # Overrides the version of the Events API to call. # - def track(event, properties = {}, timeout = nil, path = nil, return_score = false, api_key = @api_key, return_action = false) - warn "[WARNING] api_key cannot be empty, fallback to default api_key." if api_key.to_s.empty? - api_key ||= @api_key + # :path:: + # Overrides the URI path for this API call. + # + # ==== Returns: + # + # In the case of a connection error (timeout, broken connection, + # etc.), this method returns nil; otherwise, a Response object is + # returned that captures the status message and status code. + # + def track(event, properties = {}, opts = {}) + api_key = opts[:api_key] || @api_key + version = opts[:version] || @version + path = opts[:path] || (version && Sift.rest_api_path(version)) || @path + timeout = opts[:timeout] || @timeout + return_score = opts[:return_score] + return_action = opts[:return_action] + return_workflow_status = opts[:return_workflow_status] + abuse_types = opts[:abuse_types] + raise("event must be a non-empty string") if (!event.is_a? String) || event.empty? raise("properties cannot be empty") if properties.empty? - raise("Bad api_key parameter") if api_key.empty? - path ||= @path - timeout ||= @timeout + raise("api_key cannot be empty") if api_key.empty? - uri = URI.parse(API_ENDPOINT) - uri.query = URI.encode_www_form(URI.decode_www_form(uri.query.to_s) << ["return_score", "true"]) if return_score - uri.query = URI.encode_www_form(URI.decode_www_form(uri.query.to_s) << ["return_action", "true"]) if return_action - path = path + "?" + uri.query if !uri.query.to_s.empty? + query = {} + query["return_score"] = "true" if return_score + query["return_action"] = "true" if return_action + query["return_workflow_status"] = "true" if return_workflow_status + query["abuse_types"] = abuse_types.join(",") if abuse_types options = { :body => MultiJson.dump(delete_nils(properties).merge({"$type" => event, "$api_key" => api_key})), - :headers => {"User-Agent" => user_agent} + :headers => {"User-Agent" => user_agent}, + :query => query } options.merge!(:timeout => timeout) unless timeout.nil? + begin response = self.class.post(path, options) Response.new(response.body, response.code, response.response) @@ -165,91 +219,280 @@ def track(event, properties = {}, timeout = nil, path = nil, return_score = fals end end - # Retrieves a user's fraud score from the Sift Science API. This call - # is blocking. + + # Retrieves a user's fraud score from the Sift Science API. # - # == Parameters: - # user_id + # See https://siftscience.com/developers/docs/ruby/score-api/score-api . + # + # ==== Parameters: + # + # user_id:: # A user's id. This id should be the same as the user_id used in # event calls. # - # == Returns: - # A Response object is returned and captures the status message and - # status code. In general, you can ignore the returned result, though. + # opts (optional):: + # A Hash of optional parameters for the request -- + # + # :abuse_types:: + # List of abuse types, specifying for which abuse types a + # score should be returned. By default, a score is returned + # for every abuse type to which you are subscribed. + # + # :api_key:: + # Overrides the API key for this call. + # + # :timeout:: + # Overrides the timeout (in seconds) for this call. + # + # :version:: + # Overrides the version of the Events API to call. + # + # ==== Returns: # - def score(user_id, timeout = nil, api_key = @api_key) + # A Response object containing a status code, status message, and, if + # successful, the user's score(s). Returns nil on a connection error + # (timeout, broken connection, etc.). + # + def score(user_id, opts = {}) + abuse_types = opts[:abuse_types] + api_key = opts[:api_key] || @api_key + timeout = opts[:timeout] || @timeout + version = opts[:version] || @version raise("user_id must be a non-empty string") if (!user_id.is_a? String) || user_id.to_s.empty? raise("Bad api_key parameter") if api_key.empty? - timetout ||= @timeout - options = { :headers => {"User-Agent" => user_agent} } + query = {} + query["api_key"] = api_key + query["abuse_types"] = abuse_types.join(",") if abuse_types + + options = { + :headers => {"User-Agent" => user_agent}, + :query => query + } options.merge!(:timeout => timeout) unless timeout.nil? - response = self.class.get("/v#{API_VERSION}/score/#{user_id}/?api_key=#{api_key}", options) + response = self.class.get(Sift.score_api_path(user_id, version), options) Response.new(response.body, response.code, response.response) - end - # Labels a user as either good or bad. This call is blocking. + + # Labels a user. + # + # See https://siftscience.com/developers/docs/ruby/labels-api/label-user . # - # == Parameters: - # user_id + # ==== Parameters: + # + # user_id:: # A user's id. This id should be the same as the user_id used in # event calls. # - # properties + # properties:: # A hash of name-value pairs that specify the label attributes. # This parameter must be specified. # - # timeout (optional) - # The number of seconds to wait before failing the request. By default this is - # configured to 2 seconds (see above). This parameter is optional. + # opts (optional):: + # A Hash of optional parameters for the request -- + # + # :api_key:: + # Overrides the API key for this call. + # + # :timeout:: + # Overrides the timeout (in seconds) for this call. + # + # :version:: + # Overrides the version of the Events API to call. # - # == Returns: - # A Response object is returned and captures the status message and - # status code. In general, you can ignore the returned result, though. + # ==== Returns: # - def label(user_id, properties = {}, timeout = nil, api_key = @api_key) + # In the case of a connection error (timeout, broken connection, + # etc.), this method returns nil; otherwise, a Response object is + # returned that captures the status message and status code. + # + def label(user_id, properties = {}, opts = {}) + api_key = opts[:api_key] || @api_key + timeout = opts[:timeout] || @timeout + version = opts[:version] || @version + path = Sift.users_label_api_path(user_id, version) raise("user_id must be a non-empty string") if (!user_id.is_a? String) || user_id.to_s.empty? - path = Sift.current_users_label_api_path(user_id) - - # No return_action logic supported when using labels. - track("$label", delete_nils(properties), timeout, path, false, api_key, false) + track("$label", delete_nils(properties), + :path => path, :api_key => api_key, :timeout => timeout) end - # Unlabels a user. This call is blocking. + + # Unlabels a user. # - # == Parameters: - # user_id + # See https://siftscience.com/developers/docs/ruby/labels-api/unlabel-user . + # + # ==== Parameters: + # + # user_id:: # A user's id. This id should be the same as the user_id used in # event calls. # - # timeout (optional) - # The number of seconds to wait before failing the request. By default this is - # configured to 2 seconds (see above). This parameter is optional. + # opts (optional):: + # A Hash of optional parameters for this request -- + # + # :abuse_type:: + # The abuse type for which the user should be unlabeled. If + # omitted, the user is unlabeled for all abuse types. + # + # :api_key:: + # Overrides the API key for this call. + # + # :timeout:: + # Overrides the timeout (in seconds) for this call. + # + # :version:: + # Overrides the version of the Events API to call. + # + # ==== Returns: # - # == Returns: - # A Response object is returned with only an http code of 204. + # A Response object is returned with only an http code of 204. + # Returns nil on a connection error (timeout, broken connection, + # etc.). # - def unlabel(user_id, timeout = nil) + def unlabel(user_id, opts = {}) + abuse_type = opts[:abuse_type] + api_key = opts[:api_key] || @api_key + timeout = opts[:timeout] || @timeout + version = opts[:version] || @version raise("user_id must be a non-empty string") if (!user_id.is_a? String) || user_id.to_s.empty? - timetout ||= @timeout - options = { :headers => {"User-Agent" => user_agent} } + query = {} + query[:api_key] = api_key + query[:abuse_type] = abuse_type if abuse_type + + options = { + :headers => {}, + :query => query + } options.merge!(:timeout => timeout) unless timeout.nil? - path = Sift.current_users_label_api_path(user_id) - response = self.class.delete(path + "?api_key=#{@api_key}", options) + + response = self.class.delete(Sift.users_label_api_path(user_id, version), options) Response.new(response.body, response.code, response.response) end + + # Gets the status of a workflow run. + # + # See https://siftscience.com/developers/docs/ruby/workflows-api/workflow-status . + # + # ==== Parameters + # + # run_id:: + # The ID of a workflow run. + # + # opts (optional):: + # A Hash of optional parameters for this request -- + # + # :account_id:: + # Overrides the API key for this call. + # + # :api_key:: + # Overrides the API key for this call. + # + # :timeout:: + # Overrides the timeout (in seconds) for this call. + # + def get_workflow_status(run_id, opts = {}) + account_id = opts[:account_id] || @account_id + api_key = opts[:api_key] || @api_key + timeout = opts[:timeout] || @timeout + + options = { + :headers => { "User-Agent" => user_agent }, + :basic_auth => { :username => api_key, :password => "" } + } + options.merge!(:timeout => timeout) unless timeout.nil? + + uri = API3_ENDPOINT + Sift.workflow_status_path(account_id, run_id) + response = self.class.get(uri, options) + Response.new(response.body, response.code, response.response) + end + + + # Gets the decision status of a user. + # + # See https://siftscience.com/developers/docs/ruby/decisions-api/decision-status . + # + # ==== Parameters + # + # user_id:: + # The ID of user. + # + # opts (optional):: + # A Hash of optional parameters for this request -- + # + # :account_id:: + # Overrides the API key for this call. + # + # :api_key:: + # Overrides the API key for this call. + # + # :timeout:: + # Overrides the timeout (in seconds) for this call. + # + def get_user_decisions(user_id, opts = {}) + account_id = opts[:account_id] || @account_id + api_key = opts[:api_key] || @api_key + timeout = opts[:timeout] || @timeout + + options = { + :headers => { "User-Agent" => user_agent }, + :basic_auth => { :username => api_key, :password => "" } + } + options.merge!(:timeout => timeout) unless timeout.nil? + + uri = API3_ENDPOINT + Sift.user_decisions_api_path(account_id, user_id) + response = self.class.get(uri, options) + Response.new(response.body, response.code, response.response) + end + + + # Gets the decision status of an order. + # + # See https://siftscience.com/developers/docs/ruby/decisions-api/decision-status . + # + # ==== Parameters + # + # order_id:: + # The ID of an order. + # + # opts (optional):: + # A Hash of optional parameters for this request -- + # + # :account_id:: + # Overrides the API key for this call. + # + # :api_key:: + # Overrides the API key for this call. + # + # :timeout:: + # Overrides the timeout (in seconds) for this call. + # + def get_order_decisions(order_id, opts = {}) + account_id = opts[:account_id] || @account_id + api_key = opts[:api_key] || @api_key + timeout = opts[:timeout] || @timeout + + options = { + :headers => { "User-Agent" => user_agent }, + :basic_auth => { :username => api_key, :password => "" } + } + options.merge!(:timeout => timeout) unless timeout.nil? + + uri = API3_ENDPOINT + Sift.order_decisions_api_path(account_id, order_id) + response = self.class.get(uri, options) + Response.new(response.body, response.code, response.response) + end + + private - # def add_query_parameter(query_parameter) - # uri = URI.parse(API_ENDPOINT) - # end + def delete_nils(properties) properties.delete_if do |k, v| case v diff --git a/lib/sift/version.rb b/lib/sift/version.rb index b50263c..b3658ba 100644 --- a/lib/sift/version.rb +++ b/lib/sift/version.rb @@ -1,4 +1,4 @@ module Sift - VERSION = "1.1.7.3" - API_VERSION = "203" + VERSION = "2.0.0.0" + API_VERSION = "204" end diff --git a/sift.gemspec b/sift.gemspec index ba17772..5e6a0da 100644 --- a/sift.gemspec +++ b/sift.gemspec @@ -6,7 +6,7 @@ Gem::Specification.new do |s| s.name = "sift" s.version = Sift::VERSION s.platform = Gem::Platform::RUBY - s.authors = ["Fred Sadaghiani", "Yoav Schatzberg"] + s.authors = ["Fred Sadaghiani", "Yoav Schatzberg", "Jacob Burnim"] s.email = ["support@siftscience.com"] s.homepage = "http://siftscience.com" s.summary = %q{Sift Science Ruby API Gem} @@ -21,7 +21,7 @@ Gem::Specification.new do |s| # Gems that must be intalled for sift to compile and build s.add_development_dependency "rspec", ">=2.14.1" - s.add_development_dependency "webmock", ">= 1.16.0" + s.add_development_dependency "webmock", ">= 1.16.0", "< 2" # Gems that must be intalled for sift to work s.add_dependency "httparty", ">= 0.11.0" diff --git a/spec/unit/client_203_spec.rb b/spec/unit/client_203_spec.rb new file mode 100644 index 0000000..40dd50c --- /dev/null +++ b/spec/unit/client_203_spec.rb @@ -0,0 +1,192 @@ +require File.expand_path(File.join(File.dirname(__FILE__), "..", "spec_helper")) + +describe Sift::Client do + + before :each do + Sift.api_key = nil + end + + def valid_transaction_properties + { + :$buyer_user_id => "123456", + :$seller_user_id => "654321", + :$amount => 1253200, + :$currency_code => "USD", + :$time => Time.now.to_i, + :$transaction_id => "my_transaction_id", + :$billing_name => "Mike Snow", + :$billing_bin => "411111", + :$billing_last4 => "1111", + :$billing_address1 => "123 Main St.", + :$billing_city => "San Francisco", + :$billing_region => "CA", + :$billing_country => "US", + :$billing_zip => "94131", + :$user_email => "mike@example.com" + } + end + + def score_response_json + { + :user_id => "247019", + :score => 0.93, + :reasons => [{ + :name => "UsersPerDevice", + :value => 4, + :details => { + :users => "a, b, c, d" + } + }], + :status => 0, + :error_message => "OK" + } + end + + def action_response_json + { + :user_id => "247019", + :score => 0.93, + :actions => [{ + :action_id => "1234567890abcdefghijklmn", + :time => 1437421587052, + :triggers => [{ + :triggerType => "FORMULA", + :source => "synchronous_action", + :trigger_id => "12345678900987654321abcd" + }], + :entity => { + :type => "USER", + :id => "23056" + } + }, + { + :action_id => "12345678901234567890abcd", + :time => 1437421587410, + :triggers => [{ + :triggerType => "FORMULA", + :source => "synchronous_action", + :trigger_id => "abcd12345678901234567890" + }], + :entity => { + :type => "ORDER", + :id => "order_at_ 1437421587009" + } + }], + :status => 0, + :error_message => "OK" + } + end + + def fully_qualified_api_endpoint + Sift::Client::API_ENDPOINT + Sift.rest_api_path + end + + it "Successfully submits a v203 event with overridden key" do + response_json = { :status => 0, :error_message => "OK"} + stub_request(:post, "https://api.siftscience.com/v203/events"). + with { | request| + parsed_body = JSON.parse(request.body) + expect(parsed_body).to include("$buyer_user_id" => "123456") + expect(parsed_body).to include("$api_key" => "overridden") + }.to_return(:status => 200, :body => MultiJson.dump(response_json), :headers => {}) + + api_key = "foobar" + event = "$transaction" + properties = valid_transaction_properties + + response = Sift::Client.new(:api_key => api_key, :version => "203") + .track(event, properties, :api_key => "overridden") + expect(response.ok?).to eq(true) + expect(response.api_status).to eq(0) + expect(response.api_error_message).to eq("OK") + end + + + it "Successfully fetches a v203 score" do + + api_key = "foobar" + response_json = score_response_json + + stub_request(:get, "https://api.siftscience.com/v203/score/247019/?api_key=foobar") + .to_return(:status => 200, :body => MultiJson.dump(response_json), + :headers => {"content-type"=>"application/json; charset=UTF-8", + "content-length"=> "74"}) + + response = Sift::Client.new(:api_key => api_key) + .score(score_response_json[:user_id], :version => 203) + expect(response.ok?).to eq(true) + expect(response.api_status).to eq(0) + expect(response.api_error_message).to eq("OK") + + expect(response.body["score"]).to eq(0.93) + end + + + it "Successfully fetches a v203 score with an overridden key" do + + api_key = "foobar" + response_json = score_response_json + + stub_request(:get, "https://api.siftscience.com/v203/score/247019/?api_key=overridden") + .to_return(:status => 200, :body => MultiJson.dump(response_json), :headers => {}) + + response = Sift::Client.new(:api_key => api_key, :version => 203) + .score(score_response_json[:user_id], :api_key => "overridden") + expect(response.ok?).to eq(true) + expect(response.api_status).to eq(0) + expect(response.api_error_message).to eq("OK") + + expect(response.body["score"]).to eq(0.93) + end + + + it "Successfuly make a v203 sync score request" do + + api_key = "foobar" + response_json = { + :status => 0, + :error_message => "OK", + :score_response => score_response_json + } + + stub_request(:post, "https://api.siftscience.com/v203/events?return_score=true") + .to_return(:status => 200, :body => MultiJson.dump(response_json), + :headers => {"content-type"=>"application/json; charset=UTF-8", + "content-length"=> "74"}) + + event = "$transaction" + properties = valid_transaction_properties + response = Sift::Client.new(:api_key => api_key) + .track(event, properties, :return_score => true, :version => "203") + expect(response.ok?).to eq(true) + expect(response.api_status).to eq(0) + expect(response.api_error_message).to eq("OK") + expect(response.body["score_response"]["score"]).to eq(0.93) + end + + + it "Successfuly make a v203 sync action request" do + + api_key = "foobar" + response_json = { + :status => 0, + :error_message => "OK", + :score_response => action_response_json + } + + stub_request(:post, "https://api.siftscience.com/v203/events?return_action=true") + .to_return(:status => 200, :body => MultiJson.dump(response_json), + :headers => {"content-type"=>"application/json; charset=UTF-8", + "content-length"=> "74"}) + + event = "$transaction" + properties = valid_transaction_properties + response = Sift::Client.new(:api_key => api_key, :version => "203") + .track(event, properties, :return_action => true) + expect(response.ok?).to eq(true) + expect(response.api_status).to eq(0) + expect(response.api_error_message).to eq("OK") + expect(response.body["score_response"]["actions"].first["entity"]["type"]).to eq("USER") + end + +end diff --git a/spec/unit/client_label_spec.rb b/spec/unit/client_label_spec.rb index 0af8b51..03216e7 100644 --- a/spec/unit/client_label_spec.rb +++ b/spec/unit/client_label_spec.rb @@ -3,6 +3,14 @@ describe Sift::Client do def valid_label_properties + { + :$abuse_type => 'content_abuse', + :$is_bad => true, + :$description => "Listed a fake item" + } + end + + def valid_label_properties_203 { :$reasons => [ "$fake" ], :$is_bad => true, @@ -10,39 +18,75 @@ def valid_label_properties } end - def fully_qualified_users_labels_endpoint(user_id) - Sift::Client::API_ENDPOINT + Sift.current_users_label_api_path(user_id) + + it "Successfuly handles a $label and returns OK" do + + response_json = { :status => 0, :error_message => "OK" } + user_id = "frodo_baggins" + + stub_request(:post, "https://api.siftscience.com/v204/users/frodo_baggins/labels") + .with(:body => ('{"$abuse_type":"content_abuse","$is_bad":true,"$description":"Listed a fake item","$type":"$label","$api_key":"foobar"}')) + .to_return(:body => MultiJson.dump(response_json), :status => 200, + :headers => {"content-type"=>"application/json; charset=UTF-8", + "content-length"=> "74"}) + + api_key = "foobar" + properties = valid_label_properties + + response = Sift::Client.new(:api_key => api_key).label(user_id, properties) + expect(response.ok?).to eq(true) + expect(response.api_status).to eq(0) + expect(response.api_error_message).to eq("OK") end + + it "Successfully handles an $unlabel and returns OK" do + response_json = { :status => 0, :error_message => "OK" } + user_id = "frodo_baggins" + + stub_request(:delete, + "https://api.siftscience.com/v204/users/frodo_baggins/labels?api_key=foobar&abuse_type=payment_abuse") + .to_return(:status => 204) + + api_key = "foobar" + + response = Sift::Client.new(:api_key => api_key).unlabel(user_id, :abuse_type => 'payment_abuse') + expect(response.ok?).to eq(true) + end + + it "Successfuly handles a $label with the v203 API and returns OK" do response_json = { :status => 0, :error_message => "OK" } user_id = "frodo_baggins" - stub_request(:post, "https://api.siftscience.com/v203/users/frodo_baggins/labels"). - with(:body => '{"$reasons":["$fake"],"$is_bad":true,"$description":"Listed a fake item","$type":"$label","$api_key":"foobar"}'). - to_return(:body => MultiJson.dump(response_json), :status => 200, :headers => - {"content-type"=>"application/json; charset=UTF-8","content-length"=> "74"}) + stub_request(:post, "https://api.siftscience.com/v203/users/frodo_baggins/labels") + .with(:body => ('{"$reasons":["$fake"],"$is_bad":true,"$description":"Listed a fake item","$type":"$label","$api_key":"foobar"}')) + .to_return(:body => MultiJson.dump(response_json), :status => 200, + :headers => {"content-type"=>"application/json; charset=UTF-8", + "content-length"=> "74"}) api_key = "foobar" - properties = valid_label_properties + properties = valid_label_properties_203 - response = Sift::Client.new(api_key).label(user_id, properties) + response = Sift::Client.new(:api_key => api_key, :version => 203).label(user_id, properties) expect(response.ok?).to eq(true) expect(response.api_status).to eq(0) expect(response.api_error_message).to eq("OK") end + it "Successfully handles an $unlabel with the v203 API endpoing and returns OK" do response_json = { :status => 0, :error_message => "OK" } user_id = "frodo_baggins" - stub_request(:delete, "https://api.siftscience.com/v203/users/frodo_baggins/labels?api_key=foobar"). - to_return(:status => 204) + stub_request(:delete, + "https://api.siftscience.com/v203/users/frodo_baggins/labels?api_key=foobar") + .to_return(:status => 204) api_key = "foobar" - response = Sift::Client.new(api_key).unlabel(user_id) + response = Sift::Client.new(:api_key => api_key).unlabel(user_id, :version => "203") expect(response.ok?).to eq(true) end diff --git a/spec/unit/client_spec.rb b/spec/unit/client_spec.rb index 4b81e11..fd9e795 100644 --- a/spec/unit/client_spec.rb +++ b/spec/unit/client_spec.rb @@ -78,60 +78,69 @@ def action_response_json end def fully_qualified_api_endpoint - Sift::Client::API_ENDPOINT + Sift.current_rest_api_path + Sift::Client::API_ENDPOINT + Sift.rest_api_path end + it "Can instantiate client with blank api key if Sift.api_key set" do Sift.api_key = "test_global_api_key" expect(Sift::Client.new().api_key).to eq(Sift.api_key) end + it "Parameter passed api key takes precedence over Sift.api_key" do Sift.api_key = "test_global_api_key" api_key = "test_local_api_key" - expect(Sift::Client.new(api_key).api_key).to eq(api_key) + expect(Sift::Client.new(:api_key => api_key).api_key).to eq(api_key) end + it "Cannot instantiate client with nil, empty, non-string, or blank api key" do - expect(lambda { Sift::Client.new(nil) }).to raise_error(StandardError) - expect(lambda { Sift::Client.new("") }).to raise_error(StandardError) - expect(lambda { Sift::Client.new(123456) }).to raise_error(StandardError) + expect(lambda { Sift::Client.new(:api_key => nil) }).to raise_error(StandardError) + expect(lambda { Sift::Client.new(:api_key => "") }).to raise_error(StandardError) + expect(lambda { Sift::Client.new(:api_key => 123456) }).to raise_error(StandardError) expect(lambda { Sift::Client.new() }).to raise_error(StandardError) end - it "Cannot instantiate client with nil, empty, non-string, or blank path" do + + it "Cannot instantiate client with empty, non-string, or blank path" do api_key = "test_local_api_key" - expect(lambda { Sift::Client.new(api_key, nil) }).to raise_error(StandardError) - expect(lambda { Sift::Client.new(api_key, "") }).to raise_error(StandardError) - expect(lambda { Sift::Client.new(api_key, 123456) }).to raise_error(StandardError) + expect(lambda { Sift::Client.new(:path => "") }).to raise_error(StandardError) + expect(lambda { Sift::Client.new(:path => 123456) }).to raise_error(StandardError) end + it "Can instantiate client with non-default timeout" do - expect(lambda { Sift::Client.new("test_local_api_key", Sift.current_rest_api_path, 4) }).not_to raise_error + expect(lambda { Sift::Client.new(:api_key => "foo", :timeout => 4) }) + .not_to raise_error end + it "Track call must specify an event name" do - expect(lambda { Sift::Client.new("foo").track(nil) }).to raise_error(StandardError) - expect(lambda { Sift::Client.new("foo").track("") }).to raise_error(StandardError) + expect(lambda { Sift::Client.new().track(nil) }).to raise_error(StandardError) + expect(lambda { Sift::Client.new().track("") }).to raise_error(StandardError) end + it "Must specify an event name" do - expect(lambda { Sift::Client.new("foo").track(nil) }).to raise_error(StandardError) - expect(lambda { Sift::Client.new("foo").track("") }).to raise_error(StandardError) + expect(lambda { Sift::Client.new().track(nil) }).to raise_error(StandardError) + expect(lambda { Sift::Client.new().track("") }).to raise_error(StandardError) end + it "Must specify properties" do event = "custom_event_name" - expect(lambda { Sift::Client.new("foo").track(event) }).to raise_error(StandardError) + expect(lambda { Sift::Client.new().track(event) }).to raise_error(StandardError) end + it "Score call must specify a user_id" do - expect(lambda { Sift::Client.new("foo").score(nil) }).to raise_error(StandardError) - expect(lambda { Sift::Client.new("foo").score("") }).to raise_error(StandardError) + expect(lambda { Sift::Client.new().score(nil) }).to raise_error(StandardError) + expect(lambda { Sift::Client.new().score("") }).to raise_error(StandardError) end - it "Doesn't raise an exception on Net/HTTP errors" do + it "Doesn't raise an exception on Net/HTTP errors" do api_key = "foobar" event = "$transaction" properties = valid_transaction_properties @@ -140,11 +149,11 @@ def fully_qualified_api_endpoint # This method should just return nil -- the track call failed because # of an HTTP error - expect(Sift::Client.new(api_key).track(event, properties)).to eq(nil) + expect(Sift::Client.new(:api_key => api_key).track(event, properties)).to eq(nil) end - it "Returns nil when a StandardError occurs within the request" do + it "Returns nil when a StandardError occurs within the request" do api_key = "foobar" event = "$transaction" properties = valid_transaction_properties @@ -153,32 +162,35 @@ def fully_qualified_api_endpoint # This method should just return nil -- the track call failed because # a StandardError exception was thrown - expect(Sift::Client.new(api_key).track(event, properties)).to eq(nil) + expect(Sift::Client.new(:api_key => api_key).track(event, properties)).to eq(nil) end - it "Successfuly handles an event and returns OK" do + it "Successfuly handles an event and returns OK" do response_json = { :status => 0, :error_message => "OK" } - stub_request(:post, "https://api.siftscience.com/v203/events"). + stub_request(:post, "https://api.siftscience.com/v204/events"). with { |request| parsed_body = JSON.parse(request.body) expect(parsed_body).to include("$buyer_user_id" => "123456") - }.to_return(:status => 200, :body => MultiJson.dump(response_json), :headers => {"content-type"=>"application/json; charset=UTF-8","content-length"=> "74"}) + }.to_return(:status => 200, :body => MultiJson.dump(response_json), + :headers => {"content-type"=>"application/json; charset=UTF-8", + "content-length"=> "74"}) api_key = "foobar" event = "$transaction" properties = valid_transaction_properties - response = Sift::Client.new(api_key).track(event, properties) + response = Sift::Client.new(:api_key => api_key).track(event, properties) expect(response.ok?).to eq(true) expect(response.api_status).to eq(0) expect(response.api_error_message).to eq("OK") end + it "Successfully submits event with overridden key" do response_json = { :status => 0, :error_message => "OK"} - stub_request(:post, "https://api.siftscience.com/v203/events"). + stub_request(:post, "https://api.siftscience.com/v204/events"). with { | request| parsed_body = JSON.parse(request.body) expect(parsed_body).to include("$buyer_user_id" => "123456") @@ -189,22 +201,25 @@ def fully_qualified_api_endpoint event = "$transaction" properties = valid_transaction_properties - response = Sift::Client.new(api_key).track(event, properties, nil, nil, false, "overridden", false) + response = Sift::Client.new(:api_key => api_key) + .track(event, properties, :api_key => "overridden") expect(response.ok?).to eq(true) expect(response.api_status).to eq(0) expect(response.api_error_message).to eq("OK") end - it "Successfuly scrubs nils" do + it "Successfully scrubs nils" do response_json = { :status => 0, :error_message => "OK" } - stub_request(:post, "https://api.siftscience.com/v203/events"). - with { |request| + stub_request(:post, "https://api.siftscience.com/v204/events") + .with { |request| parsed_body = JSON.parse(request.body) expect(parsed_body).not_to include("fake_property") expect(parsed_body).to include("sub_object" => {"one" => "two"}) - }.to_return(:status => 200, :body => MultiJson.dump(response_json), :headers => {"content-type"=>"application/json; charset=UTF-8","content-length"=> "74"}) + }.to_return(:status => 200, :body => MultiJson.dump(response_json), + :headers => {"content-type"=>"application/json; charset=UTF-8", + "content-length"=> "74"}) api_key = "foobar" event = "$transaction" @@ -215,21 +230,23 @@ def fully_qualified_api_endpoint "three" => nil } ) - response = Sift::Client.new(api_key).track(event, properties) + response = Sift::Client.new(:api_key => api_key).track(event, properties) expect(response.ok?).to eq(true) expect(response.api_status).to eq(0) expect(response.api_error_message).to eq("OK") end - it "Successfully fetches a score" do + it "Successfully fetches a score" do api_key = "foobar" response_json = score_response_json - stub_request(:get, "https://api.siftscience.com/v203/score/247019/?api_key=foobar"). - to_return(:status => 200, :body => MultiJson.dump(response_json), :headers => {"content-type"=>"application/json; charset=UTF-8","content-length"=> "74"}) + stub_request(:get, "https://api.siftscience.com/v204/score/247019/?api_key=foobar") + .to_return(:status => 200, :body => MultiJson.dump(response_json), + :headers => {"content-type"=>"application/json; charset=UTF-8", + "content-length"=> "74"}) - response = Sift::Client.new(api_key).score(score_response_json[:user_id]) + response = Sift::Client.new(:api_key => api_key).score(score_response_json[:user_id]) expect(response.ok?).to eq(true) expect(response.api_status).to eq(0) expect(response.api_error_message).to eq("OK") @@ -237,15 +254,16 @@ def fully_qualified_api_endpoint expect(response.body["score"]).to eq(0.93) end - it "Successfully fetches a score with an overridden key" do + it "Successfully fetches a score with an overridden key" do api_key = "foobar" response_json = score_response_json - stub_request(:get, "https://api.siftscience.com/v203/score/247019/?api_key=overridden"). - to_return(:status => 200, :body => MultiJson.dump(response_json), :headers => {}) + stub_request(:get, "https://api.siftscience.com/v204/score/247019/?api_key=overridden") + .to_return(:status => 200, :body => MultiJson.dump(response_json), :headers => {}) - response = Sift::Client.new(api_key).score(score_response_json[:user_id], nil, "overridden") + response = Sift::Client.new(:api_key => api_key) + .score(score_response_json[:user_id], :api_key => "overridden") expect(response.ok?).to eq(true) expect(response.api_status).to eq(0) expect(response.api_error_message).to eq("OK") @@ -254,8 +272,7 @@ def fully_qualified_api_endpoint end - it "Successfuly make a sync score request" do - + it "Successfully make a sync score request" do api_key = "foobar" response_json = { :status => 0, @@ -263,20 +280,23 @@ def fully_qualified_api_endpoint :score_response => score_response_json } - stub_request(:post, "https://api.siftscience.com/v203/events?return_score=true"). - to_return(:status => 200, :body => MultiJson.dump(response_json), :headers => {"content-type"=>"application/json; charset=UTF-8","content-length"=> "74"}) + stub_request(:post, "https://api.siftscience.com/v204/events?return_score=true") + .to_return(:status => 200, :body => MultiJson.dump(response_json), + :headers => {"content-type"=>"application/json; charset=UTF-8", + "content-length"=> "74"}) event = "$transaction" properties = valid_transaction_properties - response = Sift::Client.new(api_key).track(event, properties, nil, nil, true, nil, nil) + response = Sift::Client.new(:api_key => api_key) + .track(event, properties, :return_score => true) expect(response.ok?).to eq(true) expect(response.api_status).to eq(0) expect(response.api_error_message).to eq("OK") expect(response.body["score_response"]["score"]).to eq(0.93) end - it "Successfuly make a sync action request" do + it "Successfully make a sync action request" do api_key = "foobar" response_json = { :status => 0, @@ -284,12 +304,15 @@ def fully_qualified_api_endpoint :score_response => action_response_json } - stub_request(:post, "https://api.siftscience.com/v203/events?return_action=true"). - to_return(:status => 200, :body => MultiJson.dump(response_json), :headers => {"content-type"=>"application/json; charset=UTF-8","content-length"=> "74"}) + stub_request(:post, "https://api.siftscience.com/v204/events?return_action=true") + .to_return(:status => 200, :body => MultiJson.dump(response_json), + :headers => {"content-type"=>"application/json; charset=UTF-8", + "content-length"=> "74"}) event = "$transaction" properties = valid_transaction_properties - response = Sift::Client.new(api_key).track(event, properties, nil, nil, nil, nil, true) + response = Sift::Client.new(:api_key => api_key) + .track(event, properties, :return_action => true) expect(response.ok?).to eq(true) expect(response.api_status).to eq(0) expect(response.api_error_message).to eq("OK") @@ -297,5 +320,74 @@ def fully_qualified_api_endpoint end + it "Successfully make a sync workflow request" do + api_key = "foobar" + response_json = { + :status => 0, + :error_message => "OK", + :score_response => { + :status => -1, + :error_message => "Internal server error." + } + } + + stub_request(:post, + "https://api.siftscience.com/v204/events?return_workflow_status=true&abuse_types=legacy,payment_abuse") + .to_return(:status => 200, :body => MultiJson.dump(response_json), + :headers => {"content-type"=>"application/json; charset=UTF-8", + "content-length"=> "74"}) + + event = "$transaction" + properties = valid_transaction_properties + response = Sift::Client.new(:api_key => api_key) + .track(event, properties, + :return_workflow_status => true, :abuse_types => ['legacy', 'payment_abuse']) + expect(response.ok?).to eq(true) + expect(response.api_status).to eq(0) + expect(response.api_error_message).to eq("OK") + end + + + it "Successfully make a workflow status request" do + response_text = '{"id":"skdjfnkse","config":{"id":"5rrbr4iaaa","version":"1468367620871"},"config_display_name":"workflow config","abuse_types":["payment_abuse"],"state":"running","entity":{"id":"example_user","type":"user"},"history":[{"app":"user","name":"Entity","state":"finished","config":{}}]}' + + stub_request(:get, "https://foobar:@api3.siftscience.com/v3/accounts/ACCT/workflows/runs/skdjfnkse") + .to_return(:status => 200, :body => response_text, :headers => {}) + + client = Sift::Client.new(:api_key => "foobar", :account_id => "ACCT") + response = client.get_workflow_status("skdjfnkse") + + expect(response.ok?).to eq(true) + expect(response.body["id"]).to eq("skdjfnkse") + expect(response.body["state"]).to eq("running") + end + + + it "Successfully make a user decisions request" do + response_text = '{"decisions":{"content_abuse":{"decision":{"id":"user_decision"},"time":1468707128659,"webhook_succeeded":false}}}' + + stub_request(:get, "https://foobar:@api3.siftscience.com/v3/accounts/ACCT/users/example_user/decisions") + .to_return(:status => 200, :body => response_text, :headers => {}) + + client = Sift::Client.new(:api_key => "foobar", :account_id => "ACCT") + response = client.get_user_decisions("example_user") + + expect(response.ok?).to eq(true) + expect(response.body["decisions"]["content_abuse"]["decision"]["id"]).to eq("user_decision") + end + + + it "Successfully make an order decisions request" do + response_text = '{"decisions":{"payment_abuse":{"decision":{"id":"decision7"},"time":1468599638005,"webhook_succeeded":false},"promotion_abuse":{"decision":{"id":"good_order"},"time":1468517407135,"webhook_succeeded":true}}}' + + stub_request(:get, "https://foobar:@api3.siftscience.com/v3/accounts/ACCT/orders/example_order/decisions") + .to_return(:status => 200, :body => response_text, :headers => {}) + + client = Sift::Client.new(:api_key => "foobar", :account_id => "ACCT") + response = client.get_order_decisions("example_order", :timeout => 3) + + expect(response.ok?).to eq(true) + expect(response.body["decisions"]["payment_abuse"]["decision"]["id"]).to eq("decision7") + end end