acts_as_icontact 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,32 @@
1
+ module ActsAsIcontact
2
+ # Returns an instance of RestClient::Resource with iContact authentication headers. All other
3
+ # resource classes build from this method. Throws an ActsAsIcontact::ConfigError if the username
4
+ # or password are missing from configuration.
5
+ #
6
+ # (Author's Note: I'm not especially thrilled with this name, since it implies some sort of keepalive
7
+ # or other persistent state. I'd rather call this 'client' -- but that's already a bit overloaded
8
+ # within iContact's nomenclature. And calling it 'server' got _me_ confused. This is the best of a
9
+ # motley array of possibilities.)
10
+ def self.connection
11
+ @connection or begin
12
+ raise ConfigError, "Username is required" unless Config.username
13
+ raise ConfigError, "Password is required" unless Config.password
14
+ @connection = RestClient::Resource.new(Config.url, :headers => {
15
+ :accept => Config.content_type,
16
+ :content_type => Config.content_type,
17
+ :api_version => Config.api_version,
18
+ :api_appid => Config.app_id,
19
+ :api_username => Config.username,
20
+ :api_password => Config.password
21
+ })
22
+ end
23
+ end
24
+
25
+ # Clears the connection object from memory. This will force a reload the next time it's accessed.
26
+ # Because nothing is directly cached within the client, the only likely reason to do this is if
27
+ # the username and password are changed. Also resets the account subresource.
28
+ def self.reset_connection!
29
+ reset_account!
30
+ @connection = nil
31
+ end
32
+ end
@@ -0,0 +1,19 @@
1
+ module ActsAsIcontact
2
+ # Thrown when a configuration value isn't provided or is invalid.
3
+ class ConfigError < StandardError; end
4
+
5
+ # Thrown when a resource calls save! and fails. Contains the +.errors+ array from
6
+ # the resource.
7
+ class RecordNotSaved < StandardError
8
+ attr_reader :errors
9
+
10
+ def initialize(errors = [])
11
+ @errors = errors
12
+ end
13
+
14
+ def message
15
+ errors.first
16
+ end
17
+ alias_method :error, :message
18
+ end
19
+ end
@@ -0,0 +1,204 @@
1
+ require 'activesupport'
2
+ require 'uri'
3
+
4
+ module ActsAsIcontact
5
+ # Base class for shared functionality between iContact resources. Supports getting, finding, saving,
6
+ # all that fun stuff.
7
+ class Resource
8
+
9
+ # Creates a new resource object from a values hash. (Which is passed to us via the magic of JSON.)
10
+ def initialize(properties={})
11
+ @properties = properties
12
+ @new_record = !@properties.has_key?(self.class.primary_key)
13
+ # Initialize other useful attributes
14
+ @errors = []
15
+
16
+ end
17
+
18
+ # Returns the primary key ID for an existing resource. Returns nil if the resource is a new record.
19
+ def id
20
+ @properties[self.class.primary_key].to_i unless new_record?
21
+ end
22
+
23
+ # Returns the specific RestClient connection for an existing resource. (E.g., the connection
24
+ # to "http://api.icontact.com/icp/a/12345" for account 12345.) Returns nil if the resource
25
+ # is a new record.
26
+ def connection
27
+ self.class.connection[id] unless new_record?
28
+ end
29
+
30
+ # Enables keys from the iContact resource to act as attribute methods.
31
+ def method_missing(method, *params)
32
+ property = method.to_s
33
+ if property =~ /(.*)=$/ # It's a value assignment
34
+ @newvalues ||= []
35
+ @newvalues << $1
36
+ @properties[$1] = params[0]
37
+ else
38
+ if @properties.has_key?(property)
39
+ @properties[property]
40
+ else
41
+ super
42
+ end
43
+ end
44
+ end
45
+
46
+ # Returns true if the resource object did not originate from iContact. We determine this
47
+ # by the rather naive method of checking upon creation whether one of the properties passed
48
+ # was the primary key.
49
+ def new_record?
50
+ @new_record
51
+ end
52
+
53
+ # Sends changes to iContact. Returns true if the save was successful (i.e. we receive
54
+ # an updated object back from them); if it was not, returns false and populates the
55
+ # +errors+ array with the warnings iContact sends to us. If iContact returns an HTTP
56
+ # error, raises an exception with it.
57
+ def save
58
+ if new_record?
59
+ result_type = self.class.collection_name
60
+ payload = {self.class.collection_name => [create_fields]}
61
+ response = self.class.connection.post(payload.to_json)
62
+ else
63
+ result_type = self.class.resource_name
64
+ response = connection.post(update_fields.to_json)
65
+ end
66
+ parsed = JSON.parse(response)
67
+ if parsed[result_type].empty?
68
+ @errors = parsed["warnings"]
69
+ false
70
+ else
71
+ @properties = (new_record? ? parsed[result_type].first : parsed[result_type])
72
+ @new_record = false
73
+ @errors = []
74
+ true
75
+ end
76
+ rescue RestClient::RequestFailed => e
77
+ response = e.response.body
78
+ parsed = JSON.parse(response)
79
+ @errors = parsed["errors"] || [e.message]
80
+ false
81
+ end
82
+
83
+ # Like +save+, but raises an ActsAsIcontact::RecordNotSaved exception if the save
84
+ # failed. The exception message contains the first error from iContact.
85
+ def save!
86
+ save or raise ActsAsIcontact::RecordNotSaved.new(errors)
87
+ end
88
+
89
+ # The first message from the +errors+ array.
90
+ def error
91
+ errors.first
92
+ end
93
+
94
+ # The warning messages sent back by iContact on a failed request.
95
+ def errors
96
+ @errors
97
+ end
98
+
99
+ # Returns an array of resources starting at the base.
100
+ def self.find(type, options={})
101
+ uri_extension = uri_component + build_query(options)
102
+ response = base[uri_extension].get
103
+ parsed = JSON.parse(response)
104
+ case type
105
+ when :first then
106
+ self.new(parsed[collection_name].first) if parsed[collection_name]
107
+ when :all then
108
+ ResourceCollection.new(self, parsed)
109
+ end
110
+ end
111
+
112
+ # Returns an array of resources starting at the base.
113
+ def self.all
114
+ find(:all)
115
+ end
116
+
117
+ # Returns the first account associated with this username.
118
+ def self.first
119
+ find(:first)
120
+ end
121
+
122
+ protected
123
+ # The minimum set of fields that must be sent back to iContact on an update.
124
+ # Includes any fields that changed or were added, the primary key, and anything
125
+ # else from the "required_on_update" set from the class definition. It excludes
126
+ # anything from the "never_on_update" set.
127
+ def update_fields
128
+ fieldlist = self.class.required_on_update + @newvalues.to_a - self.class.never_on_update
129
+ @properties.select{|key, value| fieldlist.include?(key)}
130
+ end
131
+
132
+ # The minimum set of fields that must be sent back to iContact on a create.
133
+ # Includes any fields that were added and anything
134
+ # else from the "required_on_create" set from the class definition. It excludes
135
+ # anything from the "never_on_create" set.
136
+ def create_fields
137
+ self.class.required_on_create.each{|key| @properties[key] ||= ""} # Add required fields
138
+ self.class.never_on_create.each{|key| @properties.delete(key)} # Remove prohibited fields
139
+ @properties
140
+ end
141
+
142
+ # The base RestClient resource that this particular class nests from. Starts with
143
+ # the resource connection at 'https://api.icontact.com/icp/' and works its way up.
144
+ def self.base
145
+ ActsAsIcontact.connection
146
+ end
147
+
148
+ # The name of the singular resource type pulled from iContact. Defaults to the lowercase
149
+ # version of the class name.
150
+ def self.resource_name
151
+ name.demodulize.downcase
152
+ end
153
+
154
+ # The name of the resource collection pulled from iContact. Defaults to the lowercase
155
+ # pluralized version of the class name.
156
+ def self.collection_name
157
+ resource_name.pluralize
158
+ end
159
+
160
+ # The URI component name corresponding to this resource type. In many cases it's the same as the
161
+ # collection name; exceptions include accounts ('a') and clientFolders ('c').
162
+ def self.uri_component
163
+ collection_name
164
+ end
165
+
166
+ # The RestClient resource object for this resource class. Its own find/update methods
167
+ # will call on this, and singular objects will derive from it.
168
+ def self.connection
169
+ base[uri_component]
170
+ end
171
+
172
+ # The primary key field for this resource. Used on updates.
173
+ def self.primary_key
174
+ resource_name + "Id"
175
+ end
176
+
177
+ # Fields that _must_ be included for this resource upon creation.
178
+ def self.required_on_create
179
+ []
180
+ end
181
+
182
+ # Fields that _must_ be included for this resource upon updating.
183
+ def self.required_on_update
184
+ [primary_key]
185
+ end
186
+
187
+ # Fields that _cannot_ be included for this resource upon creation.
188
+ def self.never_on_create
189
+ [primary_key]
190
+ end
191
+
192
+ # Fields that _cannot_ be included for this resource upon updating.
193
+ def self.never_on_update
194
+ []
195
+ end
196
+
197
+ private
198
+ def self.build_query(options={})
199
+ return "" if options.empty?
200
+ terms = options.collect{|k,v| "#{k}=#{URI.escape(v.to_s)}"}
201
+ build = "?" + terms.join('&')
202
+ end
203
+ end
204
+ end
@@ -0,0 +1,27 @@
1
+ module ActsAsIcontact
2
+ class ResourceCollection < Enumerator
3
+ attr_reader :total
4
+
5
+ def initialize(klass, collection)
6
+ @klass = klass
7
+ @collection = collection[klass.collection_name]
8
+ # Get number of elements
9
+ @total = @collection.size
10
+
11
+ enumcode = Proc.new do |yielder|
12
+ counter = 0
13
+ while counter < @total
14
+ yielder.yield klass.new(@collection[counter])
15
+ counter += 1
16
+ end
17
+ end
18
+
19
+ super(&enumcode)
20
+ end
21
+
22
+ def [](index)
23
+ @klass.new(@collection[index]) if @collection[index]
24
+ end
25
+
26
+ end
27
+ end
@@ -0,0 +1,38 @@
1
+ module ActsAsIcontact
2
+ # The top-level Accounts resource from iContact. Currently only supports retrieval -- and is
3
+ # highly targeted toward the _first_ account, since that seems to be the dominant use case.
4
+ class Account < Resource
5
+ def self.uri_component
6
+ 'a'
7
+ end
8
+
9
+ # Accounts can't pass back a userName or password on updating
10
+ def self.never_on_update
11
+ ['userName','password']
12
+ end
13
+ end
14
+
15
+ # The accountId retrieved from iContact. Can also be set manually for performance optimization,
16
+ # but remembers it so that it won't be pulled more than once anyway.
17
+ def self.account_id
18
+ @account_id ||= Account.first.accountId.to_i
19
+ end
20
+
21
+ # Manually sets the accountId used in subsequent calls. Setting this in your initializer will save
22
+ # at least one unnecessary request to the iContact server.
23
+ def self.account_id=(val)
24
+ @account_id = val
25
+ end
26
+
27
+ # RestClient subresource scoped to the specific account ID. Most other iContact calls will derive
28
+ # from this one.
29
+ def self.account
30
+ @account ||= connection["a/#{account_id}"]
31
+ end
32
+
33
+ # Clears the account resource from memory. Called by reset_connection! since the only likely reason
34
+ # to do this is connecting as a different user.
35
+ def self.reset_account!
36
+ @account = nil
37
+ end
38
+ end
@@ -0,0 +1,45 @@
1
+ module ActsAsIcontact
2
+ # The nested Client Folder resource from iContact. Currently only supports retrieval -- and is
3
+ # highly targeted toward the _first_ client folder, since that seems to be the dominant use case.
4
+ class Client < Resource
5
+ def self.resource_name
6
+ 'clientfolder'
7
+ end
8
+
9
+ def self.collection_name
10
+ 'clientfolders'
11
+ end
12
+
13
+ def self.uri_component
14
+ 'c'
15
+ end
16
+
17
+ def self.base
18
+ ActsAsIcontact.account
19
+ end
20
+ end
21
+
22
+ # The clientFolderId retrieved from iContact. Can also be set manually for performance
23
+ # optimization, but remembers it so that it won't be pulled more than once anyway.
24
+ def self.client_id
25
+ @client_id ||= Client.first.clientFolderId.to_i
26
+ end
27
+
28
+ # Manually sets the clientFolderId used in subsequent calls. Setting this in your
29
+ # initializer will save at least one unnecessary request to the iContact server.
30
+ def self.client_id=(val)
31
+ @client_id = val
32
+ end
33
+
34
+ # RestClient subresource scoped to the specific account ID. Most other iContact calls will derive
35
+ # from this one.
36
+ def self.client
37
+ @client ||= account["c/#{client_id}"]
38
+ end
39
+
40
+ # Clears the account resource from memory. Called by reset_connection! since the only likely reason
41
+ # to do this is connecting as a different user.
42
+ def self.reset_client!
43
+ @client = nil
44
+ end
45
+ end
@@ -0,0 +1,7 @@
1
+ module ActsAsIcontact
2
+ class Contact < Resource
3
+ def self.required_on_create
4
+ ['email']
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,181 @@
1
+ require File.expand_path(File.dirname(__FILE__) + '/spec_helper')
2
+
3
+ describe "Configuration" do
4
+ Rails = "dummy"
5
+ Rack = "dummy"
6
+ before(:all) do
7
+ # Copy our configuration to a safe place, then wipe it
8
+ @old_config = {}
9
+ ActsAsIcontact::Config.instance_variables.each do |v|
10
+ @old_config[v] = ActsAsIcontact::Config.instance_variable_get(v)
11
+ ActsAsIcontact::Config.instance_variable_set(v,nil)
12
+ end
13
+ end
14
+
15
+ context "mode" do
16
+ before(:all) do
17
+ @old_mode = ENV["ICONTACT_MODE"]
18
+ ENV["ICONTACT_MODE"] = nil
19
+ end
20
+ it "defaults to production if nothing else is set" do
21
+ ActsAsIcontact::Config.mode.should == :production
22
+ end
23
+
24
+ it "can set the mode attribute manually" do
25
+ ActsAsIcontact::Config.mode = :foo
26
+ ActsAsIcontact::Config.mode.should == :foo
27
+ end
28
+
29
+ it "reads the ICONTACT_MODE environment variable" do
30
+ ENV["ICONTACT_MODE"] = 'bar'
31
+ ActsAsIcontact::Config.mode.should == :bar
32
+ ENV["ICONTACT_MODE"] = @old_mode
33
+ end
34
+
35
+ context "within a Rails application" do
36
+ before(:all) do
37
+ @old_rails_env = ENV["RAILS_ENV"]
38
+ end
39
+
40
+ before(:each) do
41
+ Object.expects(:const_defined?).with(:Rails).returns(true)
42
+ end
43
+
44
+ it "is beta if RAILS_ENV is not production" do
45
+ ENV["RAILS_ENV"] = 'staging'
46
+ ActsAsIcontact::Config.mode.should == :beta
47
+ end
48
+
49
+ it "is production if RAILS_ENV is production" do
50
+ ENV["RAILS_ENV"] = 'production'
51
+ ActsAsIcontact::Config.mode.should == :production
52
+ end
53
+
54
+ after(:all) do
55
+ ENV["RAILS_ENV"] = @old_rails_env
56
+ end
57
+ end
58
+
59
+ context "within a Rack environment" do
60
+ before(:all) do
61
+ @old_rack_env = ENV["RACK_ENV"]
62
+ end
63
+ before(:each) do
64
+ Object.expects(:const_defined?).with(:Rails).returns(false)
65
+ Object.expects(:const_defined?).with(:Rack).returns(true)
66
+ end
67
+
68
+ it "is beta if RACK_ENV is not production" do
69
+ ENV["RACK_ENV"] = 'staging'
70
+ ActsAsIcontact::Config.mode.should == :beta
71
+ end
72
+
73
+ it "is production if RACK_ENV is production" do
74
+ ENV["RACK_ENV"] = 'production'
75
+ ActsAsIcontact::Config.mode.should == :production
76
+ end
77
+
78
+ after(:all) do
79
+ ENV["RACK_ENV"] = @old_rack_env
80
+ end
81
+ end
82
+
83
+
84
+ context ":beta" do
85
+ before(:each) do
86
+ ActsAsIcontact::Config.mode = :beta
87
+ end
88
+
89
+ it "returns the beta AppId" do
90
+ ActsAsIcontact::Config.app_id.should == "Ml5SnuFhnoOsuZeTOuZQnLUHTbzeUyhx"
91
+ end
92
+
93
+ it "returns the beta URL" do
94
+ ActsAsIcontact::Config.url.should == "https://app.beta.icontact.com/icp/"
95
+ end
96
+
97
+ end
98
+
99
+ context ":production" do
100
+ before(:each) do
101
+ ActsAsIcontact::Config.mode = :production
102
+ end
103
+
104
+ it "returns the production AppId" do
105
+ ActsAsIcontact::Config.app_id.should == "IYDOhgaZGUKNjih3hl1ItLln7zpAtWN2"
106
+ end
107
+
108
+ it "returns the production URL" do
109
+ ActsAsIcontact::Config.url.should == "https://app.icontact.com/icp/"
110
+ end
111
+
112
+ end
113
+
114
+ after(:each) do
115
+ ActsAsIcontact::Config.mode = nil
116
+ ENV["ICONTACT_MODE"] = nil
117
+ end
118
+
119
+ after(:all) do
120
+ ENV["ICONTACT_MODE"] = @old_mode
121
+ end
122
+
123
+ end
124
+
125
+ it "knows it's version 2.0" do
126
+ ActsAsIcontact::Config.api_version.should == 2.0
127
+ end
128
+
129
+ it "can set its URL base" do
130
+ ActsAsIcontact::Config.url = "https://blah.example.com/foo/bar/"
131
+ ActsAsIcontact::Config.url.should == "https://blah.example.com/foo/bar/"
132
+ end
133
+
134
+ it "knows its content type" do
135
+ ActsAsIcontact::Config.content_type.should == "application/json"
136
+ end
137
+
138
+ it "can set its content type to XML" do
139
+ ActsAsIcontact::Config.content_type = "text/xml"
140
+ ActsAsIcontact::Config.content_type.should == "text/xml"
141
+ end
142
+
143
+ it "throws an error if the content type is anything other than JSON or XML" do
144
+ lambda{ActsAsIcontact::Config.content_type = "text/plain"}.should raise_error(ActsAsIcontact::ConfigError, "Content Type must be application/json or text/xml")
145
+ end
146
+
147
+ it "knows the username you give it" do
148
+ ActsAsIcontact::Config.username = "johndoe"
149
+ ActsAsIcontact::Config.username.should == "johndoe"
150
+ end
151
+
152
+ it "gets the username from an environment variable if not supplied" do
153
+ old_env = ENV['ICONTACT_USERNAME']
154
+ ENV['ICONTACT_USERNAME'] = "bobdoe"
155
+ ActsAsIcontact::Config.username.should == "bobdoe"
156
+ # Set our environment back to the way we like it
157
+ ENV['ICONTACT_USERNAME'] = old_env if old_env
158
+ end
159
+
160
+ it "knows the password you give it" do
161
+ ActsAsIcontact::Config.password = "foobar"
162
+ ActsAsIcontact::Config.password.should == "foobar"
163
+ end
164
+
165
+ it "gets the username from an environment variable if not supplied" do
166
+ old_env = ENV['ICONTACT_PASSWORD']
167
+ ENV['ICONTACT_PASSWORD'] = "hoohar"
168
+ ActsAsIcontact::Config.password.should == "hoohar"
169
+ ENV['ICONTACT_PASSWORD'] = old_env if old_env
170
+ end
171
+
172
+ after(:each) do
173
+ # Clear any variables we might have set
174
+ ActsAsIcontact::Config.instance_variables.each{|v| ActsAsIcontact::Config.instance_variable_set(v,nil)}
175
+ end
176
+
177
+ after(:all) do
178
+ # Restore our saved configuration
179
+ @old_config.each_pair {|k, v| ActsAsIcontact::Config.instance_variable_set(k,v)}
180
+ end
181
+ end