signal_api 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. data/.gitignore +19 -0
  2. data/.travis.yml +5 -0
  3. data/Gemfile +4 -0
  4. data/LICENSE +22 -0
  5. data/README.md +43 -0
  6. data/Rakefile +49 -0
  7. data/lib/signal_api/carrier.rb +43 -0
  8. data/lib/signal_api/contact.rb +60 -0
  9. data/lib/signal_api/core_ext/array.rb +7 -0
  10. data/lib/signal_api/core_ext/hash.rb +7 -0
  11. data/lib/signal_api/core_ext/nil_class.rb +7 -0
  12. data/lib/signal_api/core_ext/string.rb +7 -0
  13. data/lib/signal_api/coupon_group.rb +47 -0
  14. data/lib/signal_api/deliver_sms.rb +54 -0
  15. data/lib/signal_api/exceptions.rb +19 -0
  16. data/lib/signal_api/list.rb +224 -0
  17. data/lib/signal_api/mocks/api_mock.rb +70 -0
  18. data/lib/signal_api/mocks/contact.rb +13 -0
  19. data/lib/signal_api/mocks/deliver_sms.rb +14 -0
  20. data/lib/signal_api/mocks/list.rb +19 -0
  21. data/lib/signal_api/mocks/short_url.rb +9 -0
  22. data/lib/signal_api/segment.rb +161 -0
  23. data/lib/signal_api/short_url.rb +56 -0
  24. data/lib/signal_api/signal_http_api.rb +49 -0
  25. data/lib/signal_api/util/email_address.rb +10 -0
  26. data/lib/signal_api/util/phone.rb +37 -0
  27. data/lib/signal_api/version.rb +3 -0
  28. data/lib/signal_api.rb +114 -0
  29. data/signal_api.gemspec +27 -0
  30. data/test/api/carrier_test.rb +43 -0
  31. data/test/api/contact_test.rb +93 -0
  32. data/test/api/coupon_group_test.rb +36 -0
  33. data/test/api/deliver_sms_test.rb +66 -0
  34. data/test/api/general_test.rb +26 -0
  35. data/test/api/list_test.rb +261 -0
  36. data/test/api/segment_test.rb +144 -0
  37. data/test/api/short_url_test.rb +50 -0
  38. data/test/mocks/contact_mock_test.rb +24 -0
  39. data/test/mocks/deliver_sms_mock_test.rb +21 -0
  40. data/test/mocks/list_mock_test.rb +33 -0
  41. data/test/mocks/short_url_mock_test.rb +17 -0
  42. data/test/test_helper.rb +20 -0
  43. metadata +248 -0
@@ -0,0 +1,70 @@
1
+ module SignalApi
2
+ module ApiMock
3
+
4
+ def self.included(base)
5
+ base.extend(ClassMethods)
6
+ end
7
+
8
+ module ClassMethods
9
+ @@mock_method_definitions = {}
10
+ @@mock_method_calls = {}
11
+
12
+ def mock_method_calls
13
+ @@mock_method_calls
14
+ end
15
+
16
+ def mock_method_definitions
17
+ @@mock_method_definitions
18
+ end
19
+
20
+ def mock_method(method, *parameter_names)
21
+ @@mock_method_definitions[method] = parameter_names
22
+ @@mock_method_calls[method] = []
23
+
24
+ define_method method do |*args|
25
+ method_args = self.class.mock_method_definitions[method]
26
+
27
+ called_args = {}
28
+ method_args.each_with_index do |method_arg, i|
29
+ called_args[method_arg] = args[i]
30
+ end
31
+
32
+ additional_info_method = method.to_s + "_additional_info"
33
+ if self.class.method_defined?(additional_info_method)
34
+ called_args.merge!(send(additional_info_method))
35
+ end
36
+
37
+ self.class.mock_method_calls[method] << called_args
38
+ end
39
+ end
40
+
41
+ def mock_class_method(method, *parameter_names)
42
+ @@mock_method_definitions[method] = parameter_names
43
+ @@mock_method_calls[method] = []
44
+
45
+ (class << self; self; end).instance_eval do
46
+ define_method method do |*args|
47
+ method_args = mock_method_definitions[method]
48
+
49
+ called_args = {}
50
+ method_args.each_with_index do |method_arg, i|
51
+ called_args[method_arg] = args[i]
52
+ end
53
+
54
+ additional_info_method = method.to_s + "_additional_info"
55
+ if method_defined?(additional_info_method)
56
+ called_args.merge!(send(additional_info_method))
57
+ end
58
+
59
+ mock_method_calls[method] << called_args
60
+ end
61
+ end
62
+ end
63
+
64
+ def clear_mock_data
65
+ @@mock_method_calls.keys.each { |k| @@mock_method_calls[k] = [] }
66
+ end
67
+ end
68
+
69
+ end
70
+ end
@@ -0,0 +1,13 @@
1
+ require 'signal_api/mocks/api_mock'
2
+
3
+ module SignalApi
4
+ class Contact
5
+ include ApiMock
6
+
7
+ mock_method(:save)
8
+
9
+ def save_additional_info
10
+ { :attributes => @attributes }
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,14 @@
1
+ require 'signal_api/mocks/api_mock'
2
+
3
+ module SignalApi
4
+ class DeliverSms
5
+ include ApiMock
6
+
7
+ mock_method(:deliver, :mobile_phone, :message)
8
+
9
+ def deliver_additional_info
10
+ { :user_name => @username }
11
+ end
12
+
13
+ end
14
+ end
@@ -0,0 +1,19 @@
1
+ require 'signal_api/mocks/api_mock'
2
+
3
+ module SignalApi
4
+ class List
5
+ include ApiMock
6
+
7
+ mock_method(:create_subscription, :subscription_type, :contact, :options)
8
+
9
+ def create_subscription_additional_info
10
+ { :list_id => @list_id }
11
+ end
12
+
13
+ mock_method(:destroy_subscription, :subscription_type, :contact)
14
+
15
+ def destroy_subscription_additional_info
16
+ { :list_id => @list_id }
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,9 @@
1
+ require 'signal_api/mocks/api_mock'
2
+
3
+ module SignalApi
4
+ class ShortUrl
5
+ include ApiMock
6
+
7
+ mock_class_method(:create, :target, :domain)
8
+ end
9
+ end
@@ -0,0 +1,161 @@
1
+ module SignalApi
2
+
3
+ # The type of segment
4
+ class SegmentType
5
+ DYNAMIC = "DYNAMIC"
6
+ STATIC = "SEGMENT"
7
+ end
8
+
9
+ # Represents a user to be added to a segment
10
+ class SegmentUser
11
+ attr_reader :mobile_phone, :email_address, :user_data
12
+
13
+ # Create a new segment on the Signal platform.
14
+ #
15
+ # @param [String] identifying_attribute The mobile phone or email address of the user
16
+ # @param [Hash] user_data <b>Optional</b> A collection of key/value pairs to store along
17
+ # with this user's segment record for later use
18
+ def initialize(identifying_attribute, user_data={})
19
+ if Phone.valid?(identifying_attribute)
20
+ @mobile_phone = identifying_attribute
21
+ elsif EmailAddress.valid?(identifying_attribute)
22
+ @email_address = identifying_attribute
23
+ else
24
+ raise InvalidParameterException.new("identifying_attribute must be a valid mobile phone number or email address")
25
+ end
26
+
27
+ @user_data = user_data unless user_data.empty?
28
+ end
29
+ end
30
+
31
+ # Create, manage, and add users to a segment.
32
+ class Segment < SignalHttpApi
33
+
34
+ # The name of the segment
35
+ attr_reader :name
36
+
37
+ # The description of the segment
38
+ attr_reader :description
39
+
40
+ # The ID of the segment
41
+ attr_reader :id
42
+
43
+ # The account_id of the segment
44
+ attr_reader :account_id
45
+
46
+ # Type type of the segment
47
+ attr_reader :segment_type
48
+
49
+
50
+ def initialize(id, name=nil, description=nil, segment_type=nil, account_id=nil)
51
+ @name = name
52
+ @description = description
53
+ @id = id
54
+ @account_id = account_id
55
+
56
+ if segment_type == "DYNAMIC"
57
+ @segment_type = SegmentType::DYNAMIC
58
+ elsif segment_type == "SEGMENT"
59
+ @segment_type = SegmentType::STATIC
60
+ end
61
+ end
62
+
63
+ # Create a new segment on the Signal platform.
64
+ #
65
+ # @param [String] name The name of the segment
66
+ # @param [String] description A description of the segment
67
+ # @param [SegmentType] segment_type The type of the segment
68
+ #
69
+ # @return [Segment] A Segment object representing the segment on the Signal platform
70
+ def self.create(name, description, segment_type)
71
+ if name.blank? || description.blank? || segment_type.blank?
72
+ raise InvalidParameterException.new("name, description, and segment_type are all required")
73
+ end
74
+
75
+ unless [SegmentType::DYNAMIC, SegmentType::STATIC].include?(segment_type)
76
+ raise InvalidParameterException.new("Invalid segment type")
77
+ end
78
+
79
+ builder = Builder::XmlMarkup.new
80
+ body = builder.filter_group do |filter_group|
81
+ filter_group.description(description)
82
+ filter_group.name(name)
83
+ filter_group.filter_group_type(segment_type)
84
+ end
85
+
86
+ SignalApi.logger.info "Attempting to create a segment: name => #{name}, description => \"#{description}\", type = #{segment_type}}"
87
+ SignalApi.logger.debug "Segment data: #{body}"
88
+ with_retries do
89
+ response = post("/api/filter_groups/create.xml",
90
+ :body => body,
91
+ :format => :xml,
92
+ :headers => common_headers)
93
+
94
+ if response.code == 200
95
+ data = response.parsed_response['subscription_list_filter_group']
96
+ new(data['id'], data['name'], data['description'], lookup_segment_type(data['filter_group_type_id']), data['account_id'])
97
+ else
98
+ handle_api_failure(response)
99
+ end
100
+ end
101
+ end
102
+
103
+ # Add mobile phone numbers to a segment.
104
+ #
105
+ # @param [Array<SegmentUser>] segment_users An array of SegmentUsers to add to the segment
106
+ # @return [Hash] A hash containing some stats regarding the operation
107
+ def add_users(segment_users)
108
+ if segment_users.blank?
109
+ raise InvalidParameterException.new("An array of SegmentUser objects must be provided")
110
+ end
111
+
112
+ builder = Builder::XmlMarkup.new
113
+ body = builder.users(:type => :array) do |users|
114
+ segment_users.each do |segment_user|
115
+ users.user do |user|
116
+ user.mobile_phone(segment_user.mobile_phone) if segment_user.mobile_phone
117
+ user.email(segment_user.email_address) if segment_user.email_address
118
+ user.user_data(segment_user.user_data) if segment_user.user_data
119
+ end
120
+ end
121
+ end
122
+
123
+ SignalApi.logger.info "Attempting to add users to segment #{@id}"
124
+ self.class.with_retries do
125
+ response = self.class.post("/api/filter_segments/#{@id}/update.xml",
126
+ :body => body,
127
+ :format => :xml,
128
+ :headers => self.class.common_headers)
129
+
130
+ if response.code == 200
131
+ data = response.parsed_response['subscription_list_segment_results']
132
+
133
+ if data['users_not_found'] && data['users_not_found']['user_not_found']
134
+ if data['users_not_found']['user_not_found'].respond_to?(:join)
135
+ SignalApi.logger.warn data['users_not_found']['user_not_found'].join(", ")
136
+ else
137
+ SignalApi.logger.warn data['users_not_found']['user_not_found']
138
+ end
139
+ end
140
+
141
+ { :total_users_processed => (data['total_users_processed'] || 0).to_i,
142
+ :total_users_added => (data['total_users_added'] || 0).to_i,
143
+ :total_users_not_found => (data['total_users_not_found'] || 0).to_i,
144
+ :total_duplicate_users => (data['total_duplicate_users'] || 0).to_i }
145
+ else
146
+ self.class.handle_api_failure(response)
147
+ end
148
+ end
149
+ end
150
+
151
+ private
152
+
153
+ def self.lookup_segment_type(segment_type_id)
154
+ case segment_type_id
155
+ when 1 then SegmentType::DYNAMIC
156
+ when 2 then SegmentType::STATIC
157
+ end
158
+ end
159
+
160
+ end
161
+ end
@@ -0,0 +1,56 @@
1
+ module SignalApi
2
+
3
+ # Manage short URLs via Signal's URL shortening service
4
+ class ShortUrl < SignalHttpApi
5
+
6
+ # The shortened URL
7
+ attr_reader :short_url
8
+
9
+ # The target URL that was shortened
10
+ attr_reader :target_url
11
+
12
+ # The ID of the shortend URL on the Signal platform
13
+ attr_reader :id
14
+
15
+ # The domain of the short URL
16
+ attr_reader :domain
17
+
18
+ def initialize(id, target_url, short_url, domain)
19
+ @id = id
20
+ @target_url = target_url
21
+ @short_url = short_url
22
+ @domain = domain
23
+ end
24
+
25
+ # Create a short URL for the provided target URL
26
+ #
27
+ # @param [String] target The target URL that is to be shortened
28
+ # @param [String] domain The short URL domain to use
29
+ #
30
+ # @return [ShortUrl] A ShortUrl object representing the short URL on the Signal platform
31
+ def self.create(target, domain)
32
+ body = <<-END
33
+ <short_url>
34
+ <target_url><![CDATA[#{target}]]></target_url>
35
+ <domain_id>1</domain_id>
36
+ </short_url>
37
+ END
38
+
39
+ SignalApi.logger.info "Attempting to create a short URL for #{target}"
40
+ with_retries do
41
+ response = post('/api/short_urls.xml',
42
+ :body => body,
43
+ :format => :xml,
44
+ :headers => common_headers)
45
+
46
+ if response.code == 201
47
+ data = response.parsed_response['short_url']
48
+ new(data['id'], data['target_url'], "http://#{domain}/#{data['slug']}", domain)
49
+ else
50
+ handle_api_failure(response)
51
+ end
52
+ end
53
+ end
54
+ end
55
+
56
+ end
@@ -0,0 +1,49 @@
1
+ module SignalApi
2
+ class SignalHttpApi
3
+ include HTTParty
4
+ base_uri SignalApi.base_uri
5
+ default_timeout SignalApi.timeout
6
+
7
+ protected
8
+
9
+ def self.with_retries
10
+ if SignalApi.retries <= 0
11
+ yield
12
+ else
13
+ retry_counter = 0
14
+ begin
15
+ yield
16
+ rescue NonRetryableException => e
17
+ SignalApi.logger.error "Non retryable exception: #{e.message}"
18
+ raise
19
+ rescue Exception => e
20
+ SignalApi.logger.error "Exception: #{e.message}"
21
+ sleep 1
22
+ retry_counter += 1
23
+
24
+ if retry_counter < SignalApi.retries
25
+ SignalApi.logger.warn "Re-trying..."
26
+ retry
27
+ else
28
+ SignalApi.logger.error "All retry attempts have failed."
29
+ raise
30
+ end
31
+ end
32
+ end
33
+ end
34
+
35
+ def self.handle_api_failure(response)
36
+ if response.code == 401
37
+ raise AuthFailedException.new("Authentication to the Signal platform failed. Make sure your API key is correct.")
38
+ else
39
+ message = "API request failed with a response code of #{response.code}. Respone body: #{response.body}"
40
+ SignalApi.logger.error message
41
+ raise ApiException.new(message)
42
+ end
43
+ end
44
+
45
+ def self.common_headers
46
+ { 'Content-Type' => 'application/xml', 'api_token' => SignalApi.api_key }
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,10 @@
1
+ module SignalApi
2
+ class EmailAddress
3
+
4
+ # Check to see if an email address is valid
5
+ def self.valid?(email)
6
+ email && !email.strip.empty? && email.strip =~ /^([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})$/i
7
+ end
8
+
9
+ end
10
+ end
@@ -0,0 +1,37 @@
1
+ module SignalApi
2
+ class Phone
3
+
4
+ # Clean up phone number by removing special characters and country codes (if provided)
5
+ def self.sanitize(phone_number)
6
+ return nil if phone_number.nil?
7
+
8
+ # Remove all non-numeric characters, ex - "=", "+", "(", ")", ".", "a", "A", " "
9
+ sanitized_phone_number = phone_number.gsub(/[^\d]/, '')
10
+
11
+ # Remove the US/Canadian country code (+1) if was provided
12
+ if sanitized_phone_number.length > 10 && sanitized_phone_number[0,1] == "1"
13
+ sanitized_phone_number = sanitized_phone_number[1, sanitized_phone_number.size]
14
+ end
15
+
16
+ sanitized_phone_number
17
+ end
18
+
19
+ def self.valid?(phone_number)
20
+ return false if phone_number.nil? || phone_number.strip.empty?
21
+ return false if self.sanitize(phone_number).size != 10
22
+ return true
23
+ end
24
+
25
+ def self.format(phone_number, international=false)
26
+ if Phone.valid?(phone_number)
27
+ phone_number = Phone.sanitize(phone_number)
28
+ unless international
29
+ "#{phone_number[0..2]}-#{phone_number[3..5]}-#{phone_number[6..9]}" # 312-343-1326
30
+ else
31
+ phone_number
32
+ end
33
+ end
34
+ end
35
+
36
+ end
37
+ end
@@ -0,0 +1,3 @@
1
+ module SignalApi
2
+ VERSION = "0.0.1"
3
+ end
data/lib/signal_api.rb ADDED
@@ -0,0 +1,114 @@
1
+ require "httparty"
2
+ require "builder"
3
+ require "logger"
4
+
5
+ module SignalApi
6
+
7
+ # Interact with the Signal platform via its published web API.
8
+ class << self
9
+
10
+ # Set your Signal API key.
11
+ #
12
+ # @param [String] api_key Your Signal API key
13
+ #
14
+ # @example
15
+ # SignalApi.api_key = 'foobar123456abcxyz77'
16
+ def api_key=(api_key)
17
+ @api_key = api_key
18
+ end
19
+
20
+ # Get your Signal API key.
21
+ def api_key
22
+ if @api_key.nil? || @api_key.strip == ""
23
+ raise InvalidApiKeyException.new("The api_key is blank or nil. Use SignalApi.api_key= to set it.")
24
+ else
25
+ @api_key
26
+ end
27
+ end
28
+
29
+ # Set the logger to be used by Signal.
30
+ #
31
+ # @param [Logger] logger The logger you would like Signal to use
32
+ #
33
+ # @example
34
+ # SignalApi.logger = Rails.logger
35
+ # SignalApi.logger = Logger.new(STDERR)
36
+ def logger=(logger)
37
+ @logger = logger
38
+ end
39
+
40
+ # Get the logger used by Signal.
41
+ def logger
42
+ @logger ||= Logger.new("/dev/null")
43
+ end
44
+
45
+ # Set the number of times failed API calls should be retried. Defaults to 0.
46
+ #
47
+ # @param [Fixnum] retries The number of times API calls should be retried
48
+ #
49
+ # @example
50
+ # SignalApi.retries = 3
51
+ def retries=(retries)
52
+ @retries = retries
53
+ end
54
+
55
+ # Get the number of times failed API calls should be retried.
56
+ def retries
57
+ @retries || 0
58
+ end
59
+
60
+ # Set the default timeout for API calls. Defaults to 15 seconds.
61
+ #
62
+ # @param [Fixnum] timeout The default timeout (in seconds) for API calls
63
+ #
64
+ # @example
65
+ # SignalApi.timeout = 5
66
+ def timeout=(timeout)
67
+ @timeout = timeout
68
+ api_classes.each { |clazz| clazz.default_timeout @timeout }
69
+ end
70
+
71
+ # Get the default timeout for API calls.
72
+ def timeout
73
+ @timeout || 15
74
+ end
75
+
76
+ # @private
77
+ def base_uri=(base_uri)
78
+ @base_uri = base_uri
79
+ api_classes.each { |clazz| clazz.base_uri @base_uri }
80
+ end
81
+
82
+ # @private
83
+ def base_uri
84
+ @base_uri || "https://app.signalhq.com"
85
+ end
86
+
87
+ private
88
+
89
+ def api_classes
90
+ [ List, Segment, ShortUrl ]
91
+ end
92
+ end
93
+
94
+ end
95
+
96
+ require "signal_api/core_ext/nil_class"
97
+ require "signal_api/core_ext/string"
98
+ require "signal_api/core_ext/array"
99
+ require "signal_api/core_ext/hash"
100
+
101
+ require "signal_api/util/phone"
102
+ require "signal_api/util/email_address"
103
+
104
+ require "signal_api/contact"
105
+ require "signal_api/exceptions"
106
+ require "signal_api/signal_http_api"
107
+
108
+ require "signal_api/deliver_sms"
109
+ require "signal_api/list"
110
+ require "signal_api/segment"
111
+ require "signal_api/short_url"
112
+ require "signal_api/coupon_group"
113
+ require "signal_api/carrier"
114
+
@@ -0,0 +1,27 @@
1
+ # -*- encoding: utf-8 -*-
2
+ require File.expand_path('../lib/signal_api/version', __FILE__)
3
+
4
+ Gem::Specification.new do |gem|
5
+ gem.authors = ["John Wood"]
6
+ gem.email = ["john@signalhq.com"]
7
+ gem.description = %q{Ruby implementation of the Signal API}
8
+ gem.summary = %q{Ruby implementation of the Signal API}
9
+ gem.homepage = "http://dev.signalhq.com"
10
+
11
+ gem.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
12
+ gem.files = `git ls-files`.split("\n")
13
+ gem.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
14
+ gem.name = "signal_api"
15
+ gem.require_paths = ["lib"]
16
+ gem.version = SignalApi::VERSION
17
+
18
+ gem.add_dependency('httparty', '~> 0.8.1')
19
+ gem.add_dependency('builder', '~> 3.0.0')
20
+
21
+ gem.add_development_dependency('fakeweb', '~> 1.3.0')
22
+ gem.add_development_dependency('shoulda', '~> 3.0.1')
23
+ gem.add_development_dependency('rake', '~> 0.9.2.2')
24
+ gem.add_development_dependency('yard', '~> 0.7.5')
25
+ gem.add_development_dependency('bluecloth', '~> 2.2.0')
26
+ gem.add_development_dependency('mocha', '~> 0.10.5')
27
+ end
@@ -0,0 +1,43 @@
1
+ require 'test_helper'
2
+
3
+ class CarrierTest < Test::Unit::TestCase
4
+
5
+ def setup
6
+ SignalApi.api_key = 'foobar'
7
+ FakeWeb.allow_net_connect = false
8
+ end
9
+
10
+ def teardown
11
+ FakeWeb.clean_registry
12
+ FakeWeb.allow_net_connect = true
13
+ end
14
+
15
+ should "be able to lookup a valid carrier" do
16
+ body = <<-END
17
+ <?xml version="1.0" encoding="UTF-8"?>
18
+ <carrier>
19
+ <name>AT&amp;T</name>
20
+ <id type="integer">6</id>
21
+ </carrier>
22
+ END
23
+
24
+ FakeWeb.register_uri(:get, SignalApi.base_uri + '/app/carriers/lookup/3125551212.xml', :content_type => 'application/xml', :status => ['200', 'Ok'], :body => body)
25
+ carrier = SignalApi::Carrier.lookup('3125551212')
26
+ assert_equal 6, carrier.id
27
+ assert_equal "AT&T", carrier.name
28
+ end
29
+
30
+ should "should throw exceptions for missing params" do
31
+ assert_raise SignalApi::InvalidParameterException do
32
+ SignalApi::Carrier.lookup(nil)
33
+ end
34
+ end
35
+
36
+ should "show throw and exception for mobile not found" do
37
+ FakeWeb.register_uri(:get, SignalApi.base_uri + '/app/carriers/lookup/3125551212.xml', :content_type => 'application/xml', :status => '404')
38
+ assert_raise SignalApi::InvalidMobilePhoneException do
39
+ SignalApi::Carrier.lookup('3125551212')
40
+ end
41
+ end
42
+
43
+ end