From 0c5fca2ff5effa6bb1c40de110aa9ef031cf5437 Mon Sep 17 00:00:00 2001 From: Jacob Burnim Date: Mon, 11 Jul 2016 13:27:33 -0700 Subject: [PATCH] Adds support for version 204 of the Sift Science API. Version 204 of the Sift Science API is now called by default -- this is an incompatible change, as some fields are no longer returned in the v204 API (unless they are explicitly requested). Also cleans up all Client methods (and the constructor) using a Hash argument for optional paramaters. This is an incompatible change. Also adds support for the Workflow Status API, User Decisions API, and Order Decisions API. --- .travis.yml | 5 + HISTORY | 7 + README.rdoc | 47 +++- lib/sift.rb | 41 ++- lib/sift/client.rb | 457 +++++++++++++++++++++++++-------- lib/sift/version.rb | 4 +- sift.gemspec | 4 +- spec/unit/client_203_spec.rb | 192 ++++++++++++++ spec/unit/client_label_spec.rb | 66 ++++- spec/unit/client_spec.rb | 190 ++++++++++---- 10 files changed, 823 insertions(+), 190 deletions(-) create mode 100644 spec/unit/client_203_spec.rb 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