From 339474627bd4325d6e1d0e11e6b23b8e9da62993 Mon Sep 17 00:00:00 2001 From: Jarrett Lusso Date: Thu, 12 Nov 2020 07:53:45 -0500 Subject: [PATCH 1/5] Added active record validator, removed faraday version restriction and updated readme. --- Gemfile.lock | 14 +++++---- README.md | 30 +++++++++++++++++++ blazeverify.gemspec | 2 +- config/locales/en.yml | 11 +++++++ lib/blazeverify.rb | 4 +++ lib/blazeverify/email_validator.rb | 47 ++++++++++++++++++++++++++++++ lib/blazeverify/version.rb | 2 +- 7 files changed, 102 insertions(+), 8 deletions(-) create mode 100644 config/locales/en.yml create mode 100644 lib/blazeverify/email_validator.rb diff --git a/Gemfile.lock b/Gemfile.lock index 3e2890e..31ac191 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,7 +2,7 @@ PATH remote: . specs: blazeverify (1.3.1) - faraday (~> 0.13) + faraday faraday_middleware net-http-persistent @@ -13,11 +13,12 @@ GEM awesome_print (1.8.0) builder (3.2.3) coderay (1.1.2) - connection_pool (2.2.2) - faraday (0.17.0) + connection_pool (2.2.3) + faraday (1.1.0) multipart-post (>= 1.2, < 3) - faraday_middleware (0.13.1) - faraday (>= 0.7.4, < 1.0) + ruby2_keywords + faraday_middleware (1.0.0) + faraday (~> 1.0) method_source (0.9.2) minitest (5.11.3) minitest-reporters (1.3.6) @@ -26,13 +27,14 @@ GEM minitest (>= 5.0) ruby-progressbar multipart-post (2.1.1) - net-http-persistent (3.1.0) + net-http-persistent (4.0.0) connection_pool (~> 2.2) pry (0.12.2) coderay (~> 1.1.0) method_source (~> 0.9.0) rake (13.0.1) ruby-progressbar (1.10.1) + ruby2_keywords (0.0.2) PLATFORMS ruby diff --git a/README.md b/README.md index 7a2bba6..0276022 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,8 @@ This is the official ruby wrapper for the Blaze Verify API. +It also includes an Active Record (Rails) validator to verify email attributes. + ## Documentation See the [Ruby API docs](https://blazeverify.com/docs/api/?ruby). @@ -98,6 +100,34 @@ batch.status.reason_counts batch.complete? ``` +### Active Record Validator + +Define a validator on an Active Record model for your email attribute(s). +It'll validate the attribute only when it's present and has changed. + +#### Options + +* `smtp`, `timeout`: Passed directly to API as options. +* `states`: An array of states you'd like to be valid. +* `free`, `role`, `disposable`, `accept_all`: If you'd like any of these to be valid. + +```ruby +validates :email, email: { + smtp: true, states: %i[deliverable risky unknown], + free: true, role: true, disposable: false, accept_all: true, timeout: 3 +} +``` + +#### Access Verification Result + +You can define an `attr_accessor` with the following format to gain +access to the verification result. + +```ruby +# [attribute_name]_verification_result +attr_accessor :email_verification_result +``` + ## Development After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. diff --git a/blazeverify.gemspec b/blazeverify.gemspec index 45e7d2d..c2d4a30 100644 --- a/blazeverify.gemspec +++ b/blazeverify.gemspec @@ -27,7 +27,7 @@ Gem::Specification.new do |s| end s.require_paths = ['lib'] - s.add_dependency 'faraday', '~> 0.13' + s.add_dependency 'faraday' s.add_dependency 'faraday_middleware' s.add_dependency 'net-http-persistent' s.add_development_dependency 'bundler' diff --git a/config/locales/en.yml b/config/locales/en.yml new file mode 100644 index 0000000..1125191 --- /dev/null +++ b/config/locales/en.yml @@ -0,0 +1,11 @@ +en: + blazeverify: + validations: + email: + undeliverable: is undeliverable + risky: is risky + unknown: is unknown + free: is a free address + role: is a role address + disposable: is a disposable address + accept_all: is an accept-all address diff --git a/lib/blazeverify.rb b/lib/blazeverify.rb index 4d36039..e4aa4c4 100644 --- a/lib/blazeverify.rb +++ b/lib/blazeverify.rb @@ -7,6 +7,10 @@ require 'blazeverify/resources/account' require 'blazeverify/resources/batch_status' require 'blazeverify/resources/verification' +if defined?(ActiveModel) + require 'blazeverify/email_validator' + I18n.load_path += Dir.glob(File.expand_path('../../config/locales/**/*', __FILE__)) +end module BlazeVerify @max_network_retries = 1 diff --git a/lib/blazeverify/email_validator.rb b/lib/blazeverify/email_validator.rb new file mode 100644 index 0000000..e671fc7 --- /dev/null +++ b/lib/blazeverify/email_validator.rb @@ -0,0 +1,47 @@ +# ActiveRecord validator for validating an email address with Blaze Verify +# +# Usage: +# validates :email, presence: true, email: { +# smtp: true, states: %i[deliverable risky unknown], +# free: true, role: true, disposable: false, accept_all: true, +# timeout: 3 +# } +# +# Define an attr_accessor to access verification results. +# attr_accessor :email_verification_result +# +class EmailValidator < ActiveModel::EachValidator + + def validate_each(record, attribute, value) + return if record.errors[attribute].present? + return unless value.present? + return unless record.changes[attribute].present? + + smtp = options.fetch(:smtp, true) + states = options.fetch(:states, %i(deliverable risky unknown)) + free = options.fetch(:free, true) + role = options.fetch(:role, true) + disposable = options.fetch(:disposable, false) + accept_all = options.fetch(:accept_all, false) + timeout = options.fetch(:timeout, 3) + + ev = BlazeVerify.verify(value, timeout: timeout, smtp: smtp) + + result_accessor = "#{attribute}_verification_result" + if record.respond_to?(result_accessor) + record.instance_variable_set("@#{result_accessor}", ev) + end + + error ||= ev.state unless ev.state.to_sym.in?(states) + error ||= :free if ev.free? && !free + error ||= :role if ev.role? && !role + error ||= :disposable if ev.disposable? && !disposable + error ||= :accept_all if ev.accept_all? && !accept_all + + translation = I18n.t(error, :scope => 'blazeverify.validations.email') + record.errors.add(attribute, translation) if error + rescue BlazeVerify::Error + # silence errors + end + +end diff --git a/lib/blazeverify/version.rb b/lib/blazeverify/version.rb index b0c7b5d..2e1fd79 100644 --- a/lib/blazeverify/version.rb +++ b/lib/blazeverify/version.rb @@ -1,3 +1,3 @@ module BlazeVerify - VERSION = '1.3.1' + VERSION = '1.3.2' end From 20927d1f3b99615560cdb3b18df07c0fe24fa881 Mon Sep 17 00:00:00 2001 From: Jarrett Lusso Date: Thu, 12 Nov 2020 09:45:57 -0500 Subject: [PATCH 2/5] Added email_validator_test --- Gemfile.lock | 18 ++++++++++- blazeverify.gemspec | 1 + lib/blazeverify/email_validator.rb | 2 +- test/email_validator_test.rb | 49 ++++++++++++++++++++++++++++++ test/test_helper.rb | 1 + 5 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 test/email_validator_test.rb diff --git a/Gemfile.lock b/Gemfile.lock index 31ac191..1ba45a2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - blazeverify (1.3.1) + blazeverify (1.3.2) faraday faraday_middleware net-http-persistent @@ -9,16 +9,27 @@ PATH GEM remote: https://rubygems.org/ specs: + activemodel (6.0.3.4) + activesupport (= 6.0.3.4) + activesupport (6.0.3.4) + concurrent-ruby (~> 1.0, >= 1.0.2) + i18n (>= 0.7, < 2) + minitest (~> 5.1) + tzinfo (~> 1.1) + zeitwerk (~> 2.2, >= 2.2.2) ansi (1.5.0) awesome_print (1.8.0) builder (3.2.3) coderay (1.1.2) + concurrent-ruby (1.1.7) connection_pool (2.2.3) faraday (1.1.0) multipart-post (>= 1.2, < 3) ruby2_keywords faraday_middleware (1.0.0) faraday (~> 1.0) + i18n (1.8.5) + concurrent-ruby (~> 1.0) method_source (0.9.2) minitest (5.11.3) minitest-reporters (1.3.6) @@ -35,11 +46,16 @@ GEM rake (13.0.1) ruby-progressbar (1.10.1) ruby2_keywords (0.0.2) + thread_safe (0.3.6) + tzinfo (1.2.8) + thread_safe (~> 0.1) + zeitwerk (2.4.1) PLATFORMS ruby DEPENDENCIES + activemodel awesome_print blazeverify! bundler diff --git a/blazeverify.gemspec b/blazeverify.gemspec index c2d4a30..55ae59a 100644 --- a/blazeverify.gemspec +++ b/blazeverify.gemspec @@ -36,4 +36,5 @@ Gem::Specification.new do |s| s.add_development_dependency 'awesome_print' s.add_development_dependency 'minitest', '~> 5.0' s.add_development_dependency 'minitest-reporters' + s.add_development_dependency 'activemodel' end diff --git a/lib/blazeverify/email_validator.rb b/lib/blazeverify/email_validator.rb index e671fc7..5c68035 100644 --- a/lib/blazeverify/email_validator.rb +++ b/lib/blazeverify/email_validator.rb @@ -32,7 +32,7 @@ def validate_each(record, attribute, value) record.instance_variable_set("@#{result_accessor}", ev) end - error ||= ev.state unless ev.state.to_sym.in?(states) + error ||= ev.state unless states.include?(ev.state.to_sym) error ||= :free if ev.free? && !free error ||= :role if ev.role? && !role error ||= :disposable if ev.disposable? && !disposable diff --git a/test/email_validator_test.rb b/test/email_validator_test.rb new file mode 100644 index 0000000..1195296 --- /dev/null +++ b/test/email_validator_test.rb @@ -0,0 +1,49 @@ +require 'test_helper' + +class EmailValidatorTest < Minitest::Test + + def user_class + Class.new do + include ActiveModel::Model + attr_accessor :email, :email_verification_result + + validates :email, presence: true, email: { + smtp: true, states: %i[deliverable risky unknown], + free: true, role: true, disposable: true, accept_all: true + } + + # stub changes to always be true + def changes + { email: true } + end + end + end + + def setup + BlazeVerify.api_key = 'test_7aff7fc0142c65f86a00' + sleep(0.25) + end + + def test_valid + @user = user_class.new(email: 'deliverable@example.com') + + assert @user.valid? + assert @user.errors.empty? + end + + def test_invalid + @user = user_class.new(email: 'undeliverable@example.com') + + assert !@user.valid? + assert @user.errors[:email].present? + end + + def test_verification_result + @user = user_class.new(email: 'undeliverable@example.com') + @user.valid? + + refute_nil @user.email_verification_result + assert @user.email_verification_result.state, :undeliverable + end + +end diff --git a/test/test_helper.rb b/test/test_helper.rb index e7850c8..54b4505 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,4 +1,5 @@ $LOAD_PATH.unshift File.expand_path("../../lib", __FILE__) +require 'active_model' require 'blazeverify' require 'pry' From fbce2e9050574c8a0bc63672fbbabb1edb33db50 Mon Sep 17 00:00:00 2001 From: Jarrett Lusso Date: Thu, 12 Nov 2020 10:58:54 -0500 Subject: [PATCH 3/5] Added options validation to validator and additional test coverage. --- lib/blazeverify/email_validator.rb | 41 +++++++++++++++++++++++------ test/email_validator_test.rb | 42 +++++++++++++++++++++++++++--- 2 files changed, 72 insertions(+), 11 deletions(-) diff --git a/lib/blazeverify/email_validator.rb b/lib/blazeverify/email_validator.rb index 5c68035..86df1b4 100644 --- a/lib/blazeverify/email_validator.rb +++ b/lib/blazeverify/email_validator.rb @@ -13,17 +13,28 @@ class EmailValidator < ActiveModel::EachValidator def validate_each(record, attribute, value) - return if record.errors[attribute].present? - return unless value.present? - return unless record.changes[attribute].present? + smtp = boolean_option_or_raise_error(:smtp, true) - smtp = options.fetch(:smtp, true) states = options.fetch(:states, %i(deliverable risky unknown)) - free = options.fetch(:free, true) - role = options.fetch(:role, true) - disposable = options.fetch(:disposable, false) - accept_all = options.fetch(:accept_all, false) + allowed_states = %i[deliverable undeliverable risky unknown] + unless (states - allowed_states).empty? + raise ArgumentError, ":states must be an array of symbols containing "\ + "any or all of :#{allowed_states.join(', :')}" + end + + free = boolean_option_or_raise_error(:free, true) + role = boolean_option_or_raise_error(:role, true) + disposable = boolean_option_or_raise_error(:disposable, false) + accept_all = boolean_option_or_raise_error(:accept_all, false) + timeout = options.fetch(:timeout, 3) + unless timeout.is_a?(Integer) && timeout > 1 + raise ArgumentError, ":timeout must be an Integer greater than 1" + end + + return if record.errors[attribute].present? + return unless value.present? + return unless record.changes[attribute].present? ev = BlazeVerify.verify(value, timeout: timeout, smtp: smtp) @@ -32,6 +43,9 @@ def validate_each(record, attribute, value) record.instance_variable_set("@#{result_accessor}", ev) end + # if response is taking too long + return unless ev.respond_to?(:state) + error ||= ev.state unless states.include?(ev.state.to_sym) error ||= :free if ev.free? && !free error ||= :role if ev.role? && !role @@ -44,4 +58,15 @@ def validate_each(record, attribute, value) # silence errors end + private + + def boolean_option_or_raise_error(name, default) + option = options.fetch(name, default) + unless [true, false].include?(option) + raise ArgumentError, ":#{name} must by a Boolean" + end + + option + end + end diff --git a/test/email_validator_test.rb b/test/email_validator_test.rb index 1195296..726c90e 100644 --- a/test/email_validator_test.rb +++ b/test/email_validator_test.rb @@ -2,16 +2,24 @@ class EmailValidatorTest < Minitest::Test - def user_class + def user_class( + smtp: true, states: %i[deliverable risky unknown], free: true, role: true, + accept_all: true, disposable: true, timeout: 3 + ) Class.new do include ActiveModel::Model attr_accessor :email, :email_verification_result validates :email, presence: true, email: { - smtp: true, states: %i[deliverable risky unknown], - free: true, role: true, disposable: true, accept_all: true + smtp: smtp, states: states, + free: free, role: role, disposable: disposable, accept_all: accept_all, + timeout: timeout } + def self.name + 'TestClass' + end + # stub changes to always be true def changes { email: true } @@ -46,4 +54,32 @@ def test_verification_result assert @user.email_verification_result.state, :undeliverable end + def test_boolean_options + %i[smtp free role disposable accept_all].each do |option| + invalid_user = user_class(option => 'string').new + valid_user = user_class.new + + assert !valid_user.valid? + assert_raises(ArgumentError) { invalid_user.valid? } + end + end + + def test_states_option + invalid_user = user_class(states: %i[invalid_state]).new + valid_user = user_class.new + + assert !valid_user.valid? + assert_raises(ArgumentError) { invalid_user.valid? } + end + + def test_timeout_option + invalid_user1 = user_class(timeout: 'string').new + invalid_user2 = user_class(timeout: 1).new + valid_user = user_class.new + + assert !valid_user.valid? + assert_raises(ArgumentError) { invalid_user1.valid? } + assert_raises(ArgumentError) { invalid_user2.valid? } + end + end From 878557c1309395b46395dbe8ddb6226fdc2dedf4 Mon Sep 17 00:00:00 2001 From: Jarrett Lusso Date: Thu, 12 Nov 2020 13:29:01 -0500 Subject: [PATCH 4/5] Updated translations to not be namedspaced and also made it so we send accept_all to the API only when necessary. --- config/locales/en.yml | 19 +++++++++---------- lib/blazeverify/email_validator.rb | 11 ++++++----- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/config/locales/en.yml b/config/locales/en.yml index 1125191..10ac7e8 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1,11 +1,10 @@ en: - blazeverify: - validations: - email: - undeliverable: is undeliverable - risky: is risky - unknown: is unknown - free: is a free address - role: is a role address - disposable: is a disposable address - accept_all: is an accept-all address + errors: + messages: + undeliverable: is undeliverable + risky: is risky + unknown: is unknown + free: is a free address + role: is a role address + disposable: is a disposable address + accept_all: is an accept-all address diff --git a/lib/blazeverify/email_validator.rb b/lib/blazeverify/email_validator.rb index 86df1b4..c7a2597 100644 --- a/lib/blazeverify/email_validator.rb +++ b/lib/blazeverify/email_validator.rb @@ -25,7 +25,7 @@ def validate_each(record, attribute, value) free = boolean_option_or_raise_error(:free, true) role = boolean_option_or_raise_error(:role, true) disposable = boolean_option_or_raise_error(:disposable, false) - accept_all = boolean_option_or_raise_error(:accept_all, false) + accept_all = boolean_option_or_raise_error(:accept_all, true) timeout = options.fetch(:timeout, 3) unless timeout.is_a?(Integer) && timeout > 1 @@ -36,7 +36,9 @@ def validate_each(record, attribute, value) return unless value.present? return unless record.changes[attribute].present? - ev = BlazeVerify.verify(value, timeout: timeout, smtp: smtp) + api_options = { timeout: timeout, smtp: smtp } + api_options[:accept_all] = true unless accept_all + ev = BlazeVerify.verify(value, api_options) result_accessor = "#{attribute}_verification_result" if record.respond_to?(result_accessor) @@ -46,14 +48,13 @@ def validate_each(record, attribute, value) # if response is taking too long return unless ev.respond_to?(:state) - error ||= ev.state unless states.include?(ev.state.to_sym) + error ||= ev.state.to_sym unless states.include?(ev.state.to_sym) error ||= :free if ev.free? && !free error ||= :role if ev.role? && !role error ||= :disposable if ev.disposable? && !disposable error ||= :accept_all if ev.accept_all? && !accept_all - translation = I18n.t(error, :scope => 'blazeverify.validations.email') - record.errors.add(attribute, translation) if error + record.errors.add(attribute, error) if error rescue BlazeVerify::Error # silence errors end From df8a5b1a432a23fc83d6a37f413149b025b723a2 Mon Sep 17 00:00:00 2001 From: Jarrett Lusso Date: Fri, 13 Nov 2020 18:31:00 -0500 Subject: [PATCH 5/5] Updated readme, changed slow requests to return a BlazeVerify::Timeout error. --- README.md | 20 ++++++++------------ lib/blazeverify.rb | 23 ++++++++++++++++++++++- lib/blazeverify/client.rb | 17 ----------------- lib/blazeverify/email_validator.rb | 3 --- lib/blazeverify/version.rb | 2 +- test/blazeverify_test.rb | 6 ++++++ 6 files changed, 37 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 0276022..7e1a3d9 100644 --- a/README.md +++ b/README.md @@ -49,18 +49,14 @@ BlazeVerify.verify('jarrett@blazeverify.com') #### Slow Email Server Handling -Some email servers are slow to respond. As a result the timeout may be reached +Some email servers are slow to respond. As a result, the timeout may be reached before we are able to complete the verification process. If this happens, the -verification will continue in the background on our servers. We recommend -sleeping for at least one second and trying your request again. Re-requesting -the same verification with the same options will not impact your credit -allocation within a 5 minute window. - -```ruby -{ - "message" => "Your request is taking longer than normal. Please send your request again." -} -``` +verification will continue in the background on our servers, and a +`BlazeVerify::TimeoutError` will be raised. We recommend sleeping for at least +one second and trying your request again. Re-requesting the same verification +with the same options will not impact your credit allocation within a 5 minute +window. You can test this behavior using a test key and the special +email `slow@example.com`. ### Batch Verification @@ -108,7 +104,7 @@ It'll validate the attribute only when it's present and has changed. #### Options * `smtp`, `timeout`: Passed directly to API as options. -* `states`: An array of states you'd like to be valid. +* `states`: An array of states you'd like to be considered valid. * `free`, `role`, `disposable`, `accept_all`: If you'd like any of these to be valid. ```ruby diff --git a/lib/blazeverify.rb b/lib/blazeverify.rb index e4aa4c4..520c846 100644 --- a/lib/blazeverify.rb +++ b/lib/blazeverify.rb @@ -30,7 +30,9 @@ def verify(email, smtp: nil, accept_all: nil, timeout: nil) response = client.request(:get, 'verify', opts) if response.status == 249 - response.body + raise BlazeVerify::TimeoutError.new( + code: response.status, message: response.body + ) else Verification.new(response.body) end @@ -41,4 +43,23 @@ def account response = client.request(:get, 'account') Account.new(response.body) end + + + class Error < StandardError + attr_accessor :code, :message + + def initialize(code: nil, message: nil) + @code = code + @message = message + end + end + class BadRequestError < Error; end + class UnauthorizedError < Error; end + class PaymentRequiredError < Error; end + class ForbiddenError < Error; end + class NotFoundError < Error; end + class TooManyRequestsError < Error; end + class InternalServerError < Error; end + class ServiceUnavailableError < Error; end + class TimeoutError < Error; end end diff --git a/lib/blazeverify/client.rb b/lib/blazeverify/client.rb index 07c94db..a67e5d2 100644 --- a/lib/blazeverify/client.rb +++ b/lib/blazeverify/client.rb @@ -67,21 +67,4 @@ def self.should_retry?(error, num_retries) false end end - - class Error < StandardError - attr_accessor :code, :message - - def initialize(code: nil, message: nil) - @code = code - @message = message - end - end - class BadRequestError < Error; end - class UnauthorizedError < Error; end - class PaymentRequiredError < Error; end - class ForbiddenError < Error; end - class NotFoundError < Error; end - class TooManyRequestsError < Error; end - class InternalServerError < Error; end - class ServiceUnavailableError < Error; end end diff --git a/lib/blazeverify/email_validator.rb b/lib/blazeverify/email_validator.rb index c7a2597..326e961 100644 --- a/lib/blazeverify/email_validator.rb +++ b/lib/blazeverify/email_validator.rb @@ -45,9 +45,6 @@ def validate_each(record, attribute, value) record.instance_variable_set("@#{result_accessor}", ev) end - # if response is taking too long - return unless ev.respond_to?(:state) - error ||= ev.state.to_sym unless states.include?(ev.state.to_sym) error ||= :free if ev.free? && !free error ||= :role if ev.role? && !role diff --git a/lib/blazeverify/version.rb b/lib/blazeverify/version.rb index 2e1fd79..ac8ad68 100644 --- a/lib/blazeverify/version.rb +++ b/lib/blazeverify/version.rb @@ -1,3 +1,3 @@ module BlazeVerify - VERSION = '1.3.2' + VERSION = '2.0.0' end diff --git a/test/blazeverify_test.rb b/test/blazeverify_test.rb index 7d2455c..9c9ef84 100644 --- a/test/blazeverify_test.rb +++ b/test/blazeverify_test.rb @@ -61,4 +61,10 @@ def test_name_and_gender end end + def test_slow_verification + assert_raises(BlazeVerify::TimeoutError) do + BlazeVerify.verify('slow@example.com') + end + end + end