SFEley-acts_as_icontact 0.1.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.
@@ -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,19 @@
1
+ require 'rubygems'
2
+ require 'rest_client'
3
+ require 'json'
4
+
5
+ $LOAD_PATH.unshift(File.dirname(__FILE__))
6
+ require 'acts_as_icontact/exceptions'
7
+ require 'acts_as_icontact/config'
8
+ require 'acts_as_icontact/connection'
9
+ require 'acts_as_icontact/resource'
10
+ require 'acts_as_icontact/resource_collection'
11
+
12
+ # Load all of our resource files
13
+ Dir[File.join(File.dirname(__FILE__), 'acts_as_icontact', 'resources', '*.rb')].sort.each do |path|
14
+ filename = File.basename(path, '.rb')
15
+ require "acts_as_icontact/resources/#{filename}"
16
+ end
17
+
18
+ module ActsAsIcontact
19
+ 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
@@ -0,0 +1,36 @@
1
+ require File.expand_path(File.dirname(__FILE__) + '/spec_helper')
2
+
3
+ describe ActsAsIcontact, "connection method" do
4
+ it "returns a RestClient resource" do
5
+ ActsAsIcontact.connection.should be_a_kind_of(RestClient::Resource)
6
+ end
7
+
8
+ it "throws an error if no username is given" do
9
+ ActsAsIcontact::Config.expects(:username).returns(nil)
10
+ lambda{ActsAsIcontact.connection}.should raise_error(ActsAsIcontact::ConfigError, "Username is required")
11
+ end
12
+
13
+ it "throws an error if no password is given" do
14
+ ActsAsIcontact::Config.expects(:password).returns(nil)
15
+ lambda{ActsAsIcontact.connection}.should raise_error(ActsAsIcontact::ConfigError, "Password is required")
16
+ end
17
+
18
+ it "can be cleared with the reset_client! method" do
19
+ RestClient::Resource.expects(:new).returns(true)
20
+ ActsAsIcontact.reset_connection!
21
+ ActsAsIcontact.connection.should_not be_nil
22
+ end
23
+
24
+ it "resets the account when reset_client! is called" do
25
+ ActsAsIcontact.expects(:reset_account!).at_least_once.returns(nil)
26
+ ActsAsIcontact.reset_connection!
27
+ end
28
+
29
+ it "can be used to make calls to the iContact server" do
30
+ ActsAsIcontact.connection['time'].get.should =~ /"timestamp":\d+/
31
+ end
32
+
33
+ after(:each) do
34
+ ActsAsIcontact.reset_connection!
35
+ end
36
+ end
@@ -0,0 +1,23 @@
1
+ require File.expand_path(File.dirname(__FILE__) + '/spec_helper')
2
+
3
+ describe ActsAsIcontact::ResourceCollection do
4
+ before(:each) do
5
+ @dummy = {"total"=>2, "resources"=>[{"foo"=>"bar"}, {"yoo"=>"yar"}], "limit"=>20, "offset"=>0}
6
+ @this = ActsAsIcontact::ResourceCollection.new(ActsAsIcontact::Resource, @dummy)
7
+ end
8
+
9
+ it "takes a resource class and a parsed JSON collection" do
10
+ @this.should be_a_kind_of(ActsAsIcontact::ResourceCollection)
11
+ end
12
+
13
+ it "returns an object of the resource class for each element" do
14
+ @this.each do |element|
15
+ element.should be_a_kind_of(ActsAsIcontact::Resource)
16
+ end
17
+ end
18
+
19
+ it "can return an element at a specified index" do
20
+ @this[1].yoo.should == "yar"
21
+ end
22
+
23
+ end