facebook_ads 0.1.5 → 0.1.7

Sign up to get free protection for your applications and to get access to all the features.
Files changed (72) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +2 -0
  3. data/.rubocop.yml +23 -0
  4. data/.travis.yml +1 -1
  5. data/Gemfile +4 -5
  6. data/Gemfile.lock +34 -82
  7. data/{README.markdown → README.md} +43 -10
  8. data/Rakefile +6 -0
  9. data/bin/console +15 -1
  10. data/facebook_ads.gemspec +11 -13
  11. data/lib/facebook_ads.rb +11 -7
  12. data/lib/facebook_ads/ad.rb +6 -8
  13. data/lib/facebook_ads/ad_account.rb +65 -54
  14. data/lib/facebook_ads/ad_audience.rb +22 -0
  15. data/lib/facebook_ads/ad_campaign.rb +21 -17
  16. data/lib/facebook_ads/ad_creative.rb +7 -9
  17. data/lib/facebook_ads/ad_image.rb +3 -5
  18. data/lib/facebook_ads/ad_insight.rb +4 -5
  19. data/lib/facebook_ads/ad_product.rb +6 -0
  20. data/lib/facebook_ads/ad_product_catalog.rb +60 -0
  21. data/lib/facebook_ads/ad_product_feed.rb +6 -0
  22. data/lib/facebook_ads/ad_product_set.rb +6 -0
  23. data/lib/facebook_ads/ad_set.rb +9 -11
  24. data/lib/facebook_ads/ad_targeting.rb +19 -21
  25. data/lib/facebook_ads/base.rb +107 -62
  26. data/test/ad_account_test.rb +27 -0
  27. data/test/ad_campaign_test.rb +32 -0
  28. data/test/ad_creative_test.rb +7 -0
  29. data/test/ad_image_test.rb +27 -0
  30. data/test/ad_insight_test.rb +7 -0
  31. data/test/ad_product_catalog_test.rb +28 -0
  32. data/test/ad_product_feed_test.rb +7 -0
  33. data/test/ad_product_set_test.rb +7 -0
  34. data/test/ad_product_test.rb +22 -0
  35. data/test/ad_set_test.rb +7 -0
  36. data/test/ad_targeting_test.rb +7 -0
  37. data/test/ad_test.rb +7 -0
  38. data/test/facebook_ads_test.rb +8 -0
  39. data/test/test_helper.rb +64 -0
  40. data/test/vcr_cassettes/AdAccountTest-test_all.yml +70 -0
  41. data/test/vcr_cassettes/AdAccountTest-test_applications.yml +130 -0
  42. data/test/vcr_cassettes/AdAccountTest-test_find_by.yml +71 -0
  43. data/test/vcr_cassettes/AdCampaignTest-test_create.yml +295 -0
  44. data/test/vcr_cassettes/AdCampaignTest-test_list.yml +133 -0
  45. data/test/vcr_cassettes/AdImageTest-test_create.yml +2963 -0
  46. data/test/vcr_cassettes/AdImageTest-test_list.yml +137 -0
  47. data/test/vcr_cassettes/AdProductCatalogTest-test_all.yml +60 -0
  48. data/test/vcr_cassettes/AdProductCatalogTest-test_create.yml +256 -0
  49. data/test/vcr_cassettes/AdProductTest-test_list.yml +130 -0
  50. metadata +44 -49
  51. data/spec/ad_account_spec.rb +0 -78
  52. data/spec/ad_campaign_spec.rb +0 -13
  53. data/spec/ad_creative_spec.rb +0 -14
  54. data/spec/ad_image_spec.rb +0 -11
  55. data/spec/ad_insight_spec.rb +0 -11
  56. data/spec/ad_set_spec.rb +0 -13
  57. data/spec/ad_spec.rb +0 -13
  58. data/spec/ad_targeting_spec.rb +0 -4
  59. data/spec/spec_helper.rb +0 -15
  60. data/spec/support/fixtures.sh +0 -17
  61. data/spec/support/fixtures/6057330925170.json +0 -1
  62. data/spec/support/fixtures/6057810634370.json +0 -1
  63. data/spec/support/fixtures/6057810946970.json +0 -1
  64. data/spec/support/fixtures/6057824295570.json +0 -1
  65. data/spec/support/fixtures/act_861827983860489.json +0 -1
  66. data/spec/support/fixtures/act_861827983860489/adcreatives.json +0 -1
  67. data/spec/support/fixtures/act_861827983860489/adimages.json +0 -1
  68. data/spec/support/fixtures/act_861827983860489/ads.json +0 -1
  69. data/spec/support/fixtures/act_861827983860489/adsets.json +0 -1
  70. data/spec/support/fixtures/act_861827983860489/campaigns.json +0 -1
  71. data/spec/support/fixtures/me/adaccounts.json +0 -1
  72. data/spec/support/rack_facebook.rb +0 -22
@@ -2,23 +2,22 @@ module FacebookAds
2
2
  # Ad insights exist for ad accounts, ad campaigns, ad sets, and ads.
3
3
  # A lot more can be done here.
4
4
  # https://developers.facebook.com/docs/marketing-api/insights/overview
5
+ # https://developers.facebook.com/docs/marketing-api/insights/fields/v2.8
5
6
  class AdInsight < Base
6
-
7
- FIELDS = %w(ad_id objective impressions unique_actions cost_per_unique_action_type clicks cpc cpm ctr spend)
7
+ FIELDS = %w(account_id campaign_id adset_id ad_id objective impressions unique_actions cost_per_unique_action_type clicks cpc cpm cpp ctr spend reach relevance_score).freeze
8
8
 
9
9
  class << self
10
- def find(id)
10
+ def find(_id)
11
11
  raise Exception, 'NOT IMPLEMENTED'
12
12
  end
13
13
  end
14
14
 
15
- def update(data)
15
+ def update(_data)
16
16
  raise Exception, 'NOT IMPLEMENTED'
17
17
  end
18
18
 
19
19
  def destroy
20
20
  raise Exception, 'NOT IMPLEMENTED'
21
21
  end
22
-
23
22
  end
24
23
  end
@@ -0,0 +1,6 @@
1
+ module FacebookAds
2
+ # https://developers.facebook.com/docs/marketing-api/reference/product-item
3
+ class AdProduct < Base
4
+ FIELDS = %w(id retailer_id name description brand category currency price image_url url).freeze
5
+ end
6
+ end
@@ -0,0 +1,60 @@
1
+ module FacebookAds
2
+ # https://developers.facebook.com/docs/marketing-api/reference/product-catalog
3
+ class AdProductCatalog < Base
4
+ FIELDS = %w(id name vertical product_count feed_count).freeze
5
+
6
+ class << self
7
+ def all
8
+ get("/#{FacebookAds.business_id}/product_catalogs", objectify: true)
9
+ end
10
+
11
+ def find_by(conditions)
12
+ all.detect do |object|
13
+ conditions.all? do |key, value|
14
+ object.send(key) == value
15
+ end
16
+ end
17
+ end
18
+
19
+ def create(name:)
20
+ catalog = post("/#{FacebookAds.business_id}/product_catalogs", query: { name: name }, objectify: true)
21
+ find(catalog.id)
22
+ end
23
+ end
24
+
25
+ # has_many ad_product_feeds
26
+
27
+ def ad_product_feeds
28
+ AdProductFeed.paginate("/#{id}/product_feeds")
29
+ end
30
+
31
+ # catalog.create_ad_product_feed(name: 'Test', schedule: { url: 'https://tophatter.com/admin/ad_automation/ad_product_feeds/1.csv', interval: 'HOURLY' })
32
+ def create_ad_product_feed(name:, schedule:)
33
+ feed = AdProductCatalog.post("/#{id}/product_feeds", query: { name: name, schedule: schedule }, objectify: true)
34
+ AdProductFeed.find(feed.id)
35
+ end
36
+
37
+ # has_many product_groups
38
+
39
+ # def ad_product_groups
40
+ # AdProductGroup.paginate("/#{id}/product_groups")
41
+ # end
42
+
43
+ # has_many product_sets
44
+
45
+ # def ad_product_sets
46
+ # AdProductSet.paginate("/#{id}/product_sets")
47
+ # end
48
+
49
+ # has_many ad_products
50
+
51
+ def ad_products
52
+ AdProduct.paginate("/#{id}/products")
53
+ end
54
+
55
+ def create_ad_product(data)
56
+ product = AdProductCatalog.post("/#{id}/products", query: data, objectify: true)
57
+ AdProduct.find(product.id)
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,6 @@
1
+ module FacebookAds
2
+ # https://developers.facebook.com/docs/marketing-api/reference/product-feed
3
+ class AdProductFeed < Base
4
+ FIELDS = %w(id country created_time default_currency deletion_enabled delimiter encoding file_name latest_upload name product_count quoted_fields_mode schedule).freeze
5
+ end
6
+ end
@@ -0,0 +1,6 @@
1
+ module FacebookAds
2
+ # https://developers.facebook.com/docs/marketing-api/reference/product-set
3
+ class AdProductSet < Base
4
+ FIELDS = %w(id auto_creation_url filter name product_catalog product_count).freeze
5
+ end
6
+ end
@@ -2,34 +2,32 @@ module FacebookAds
2
2
  # An ad set belongs to a campaign and has many ads.
3
3
  # https://developers.facebook.com/docs/marketing-api/reference/ad-campaign
4
4
  class AdSet < Base
5
-
6
- FIELDS = %w(id account_id campaign_id adlabels adset_schedule bid_amount bid_info billing_event configured_status created_time creative_sequence effective_status end_time frequency_cap frequency_cap_reset_period frequency_control_specs is_autobid lifetime_frequency_cap lifetime_imps name optimization_goal promoted_object rf_prediction_id rtb_flag start_time targeting updated_time use_new_app_click pacing_type budget_remaining daily_budget lifetime_budget)
7
- STATUSES = %w(ACTIVE PAUSED DELETED PENDING_REVIEW DISAPPROVED PREAPPROVED PENDING_BILLING_INFO CAMPAIGN_PAUSED ARCHIVED ADSET_PAUSED)
8
- BILLING_EVENTS = %w(APP_INSTALLS IMPRESSIONS) # %w(CLICKS LINK_CLICKS OFFER_CLAIMS PAGE_LIKES POST_ENGAGEMENT VIDEO_VIEWS)
9
- OPTIMIZATION_GOALS = %w(APP_INSTALLS OFFSITE_CONVERSIONS) # %w(NONE BRAND_AWARENESS CLICKS ENGAGED_USERS EXTERNAL EVENT_RESPONSES IMPRESSIONS LEAD_GENERATION LINK_CLICKS OFFER_CLAIMS PAGE_ENGAGEMENT PAGE_LIKES POST_ENGAGEMENT REACH SOCIAL_IMPRESSIONS VIDEO_VIEWS)
5
+ FIELDS = %w(id account_id campaign_id adlabels adset_schedule bid_amount bid_info billing_event configured_status created_time creative_sequence effective_status end_time frequency_cap frequency_cap_reset_period frequency_control_specs is_autobid lifetime_frequency_cap lifetime_imps name optimization_goal promoted_object rf_prediction_id rtb_flag start_time targeting updated_time use_new_app_click pacing_type budget_remaining daily_budget lifetime_budget).freeze
6
+ STATUSES = %w(ACTIVE PAUSED DELETED PENDING_REVIEW DISAPPROVED PREAPPROVED PENDING_BILLING_INFO CAMPAIGN_PAUSED ARCHIVED ADSET_PAUSED).freeze
7
+ BILLING_EVENTS = %w(APP_INSTALLS IMPRESSIONS).freeze
8
+ OPTIMIZATION_GOALS = %w(APP_INSTALLS OFFSITE_CONVERSIONS).freeze
10
9
 
11
10
  # belongs_to ad_account
12
11
 
13
12
  def ad_account
14
- @ad_set ||= FacebookAds::AdAccount.find(account_id)
13
+ @ad_account ||= AdAccount.find("act_#{account_id}")
15
14
  end
16
15
 
17
16
  # belongs_to ad_campaign
18
17
 
19
18
  def ad_campaign
20
- @campaign ||= FacebookAds::AdCampaign.find(campaign_id)
19
+ @campaign ||= AdCampaign.find(campaign_id)
21
20
  end
22
21
 
23
22
  # has_many ads
24
23
 
25
24
  def ads(effective_status: ['ACTIVE'], limit: 100)
26
- FacebookAds::Ad.paginate("/#{id}/ads", query: { effective_status: effective_status, limit: limit })
25
+ Ad.paginate("/#{id}/ads", query: { effective_status: effective_status, limit: limit })
27
26
  end
28
27
 
29
28
  def create_ad(name:, creative_id:)
30
- ad = FacebookAds::Ad.post("/act_#{account_id}/ads", query: { name: name, adset_id: id, creative: { creative_id: creative_id }.to_json }, objectify: true) # Returns a FacebookAds::Ad instance.
31
- FacebookAds::Ad.find(ad.id)
29
+ ad = Ad.post("/act_#{account_id}/ads", query: { name: name, adset_id: id, creative: { creative_id: creative_id }.to_json }, objectify: true) # Returns an Ad instance.
30
+ Ad.find(ad.id)
32
31
  end
33
-
34
32
  end
35
33
  end
@@ -1,35 +1,34 @@
1
- # https://developers.facebook.com/docs/marketing-api/targeting-specs
2
1
  module FacebookAds
2
+ # https://developers.facebook.com/docs/marketing-api/targeting-specs
3
3
  class AdTargeting
4
-
5
4
  MEN = 1
6
5
  WOMEN = 2
7
- GENDERS = [MEN, WOMEN]
8
- ANDROID_OS = 'Android'
9
- APPLE_OS = 'iOS'
10
- OSES = [ANDROID_OS, APPLE_OS]
11
- ANDROID_DEVICES = %w(Android_Smartphone Android_Tablet)
12
- APPLE_DEVICES = %w(iPhone iPad iPod)
6
+ GENDERS = [MEN, WOMEN].freeze
7
+ ANDROID_OS = 'Android'.freeze
8
+ APPLE_OS = 'iOS'.freeze
9
+ OSES = [ANDROID_OS, APPLE_OS].freeze
10
+ ANDROID_DEVICES = %w(Android_Smartphone Android_Tablet).freeze
11
+ APPLE_DEVICES = %w(iPhone iPad iPod).freeze
13
12
  DEVICES = ANDROID_DEVICES + APPLE_DEVICES
14
- INSTALLED = 'installed'
15
- NOT_INSTALLED = 'not_installed'
16
- APP_INSTALL_STATES = [INSTALLED, NOT_INSTALLED]
13
+ INSTALLED = 'installed'.freeze
14
+ NOT_INSTALLED = 'not_installed'.freeze
15
+ APP_INSTALL_STATES = [INSTALLED, NOT_INSTALLED].freeze
17
16
 
18
17
  attr_accessor :genders, :age_min, :age_max, :countries, :user_os, :user_device, :app_install_state
19
18
 
20
19
  def initialize
21
- # self.genders = [WOMEN] # If nil, defaults to all genders.
22
- # self.age_min = 18 # If nil, defaults to 18.
23
- # self.age_max = 65 # If nil, defaults to 65+.
24
- self.countries = ['US']
25
- # self.user_os = [ANDROID_OS]
26
- # self.user_device = ANDROID_DEVICES
20
+ # self.genders = [WOMEN] # If nil, defaults to all genders.
21
+ # self.age_min = 18 # If nil, defaults to 18.
22
+ # self.age_max = 65 # If nil, defaults to 65+.
23
+ self.countries = ['US']
24
+ # self.user_os = [ANDROID_OS]
25
+ # self.user_device = ANDROID_DEVICES
27
26
  # self.app_install_state = NOT_INSTALLED
28
27
  end
29
28
 
30
29
  def validate!
31
30
  { gender: genders, countries: countries, user_os: user_os, user_device: user_device }.each_pair do |key, array|
32
- if array.present? && !array.is_a?(Array)
31
+ if !array.nil? && !array.is_a?(Array)
33
32
  raise Exception, "#{self.class.name}: #{key} must be an array"
34
33
  end
35
34
  end
@@ -37,8 +36,8 @@ module FacebookAds
37
36
  { genders: [genders, GENDERS], user_os: [user_os, OSES], user_device: [user_device, DEVICES] }.each_pair do |key, provided_and_acceptable|
38
37
  provided, acceptable = provided_and_acceptable
39
38
 
40
- if provided.present? && (invalid = provided.detect { |value| !acceptable.include?(value) }).present?
41
- raise Exception, "#{self.class.name}: #{bad} is an invalid #{key}"
39
+ if !provided.nil? && !(invalid = provided.detect { |value| !acceptable.include?(value) }).nil?
40
+ raise Exception, "#{self.class.name}: #{invalid} is an invalid #{key}"
42
41
  end
43
42
  end
44
43
 
@@ -56,6 +55,5 @@ module FacebookAds
56
55
  app_install_state: app_install_state
57
56
  }.compact
58
57
  end
59
-
60
58
  end
61
59
  end
@@ -1,47 +1,70 @@
1
1
  module FacebookAds
2
+ # The base class for all ads objects.
2
3
  class Base < Hashie::Mash
3
-
4
4
  class << self
5
-
6
5
  def find(id)
7
6
  get("/#{id}", objectify: true)
8
7
  end
9
8
 
10
9
  def get(path, query: {}, objectify:)
11
10
  query = pack(query, objectify) # Adds access token, fields, etc.
12
- FacebookAds.logger.debug "GET #{FacebookAds.base_uri}#{path}?#{query.to_query}"
13
- response = HTTParty.get("#{FacebookAds.base_uri}#{path}", query: query).parsed_response
14
- response = unpack(response, objectify: objectify)
11
+ uri = "#{FacebookAds.base_uri}#{path}?" + build_nested_query(query)
12
+ FacebookAds.logger.debug "GET #{uri}"
13
+ response = begin
14
+ RestClient.get(uri)
15
+ rescue RestClient::Exception => e
16
+ exception(:get, path, e)
17
+ end
18
+ unpack(response, objectify: objectify)
15
19
  end
16
20
 
17
21
  def post(path, query: {}, objectify:)
18
22
  query = pack(query, objectify)
19
- FacebookAds.logger.debug "POST #{FacebookAds.base_uri}#{path}?#{query.to_query}"
20
- response = HTTMultiParty.post("#{FacebookAds.base_uri}#{path}", query: query).parsed_response
21
- response = unpack(response, objectify: objectify)
23
+ uri = "#{FacebookAds.base_uri}#{path}"
24
+ FacebookAds.logger.debug "POST #{uri} #{query}"
25
+ response = begin
26
+ RestClient.post(uri, query)
27
+ rescue RestClient::Exception => e
28
+ exception(:post, path, e)
29
+ end
30
+ unpack(response, objectify: objectify)
22
31
  end
23
32
 
24
33
  def delete(path, query: {})
25
34
  query = pack(query, false)
26
- FacebookAds.logger.debug "DELETE #{FacebookAds.base_uri}#{path}?#{query.to_query}"
27
- response = HTTParty.delete("#{FacebookAds.base_uri}#{path}", query: query).parsed_response
28
- response = unpack(response, objectify: false)
35
+ uri = "#{FacebookAds.base_uri}#{path}?" + build_nested_query(query)
36
+ FacebookAds.logger.debug "DELETE #{uri}"
37
+ response = begin
38
+ RestClient.delete(uri)
39
+ rescue RestClient::Exception => e
40
+ exception(:delete, path, e)
41
+ end
42
+ unpack(response, objectify: false)
29
43
  end
30
44
 
31
45
  def paginate(path, query: {})
46
+ query[:limit] ||= 100
47
+ limit = query[:limit]
32
48
  response = get(path, query: query.merge(fields: self::FIELDS.join(',')), objectify: false)
33
- data = response['data'].present? ? response['data'] : []
34
-
35
- while (paging = response['paging']).present? && (url = paging['next']).present?
36
- FacebookAds.logger.debug "GET #{url}"
37
- response = HTTParty.get(url).parsed_response # This should be raw since the URL has the host already.
38
- data += response['data'] if response['data'].present?
49
+ data = response['data'].nil? ? [] : response['data']
50
+
51
+ if data.length == limit
52
+ while !(paging = response['paging']).nil? && !(url = paging['next']).nil?
53
+ FacebookAds.logger.debug "GET #{url}"
54
+ response = begin
55
+ RestClient.get(url)
56
+ rescue RestClient::Exception => e
57
+ exception(:get, url, e)
58
+ end
59
+ response = unpack(response, objectify: false)
60
+ data += response['data'] unless response['data'].nil?
61
+ end
39
62
  end
40
63
 
41
- if data.present?
42
- data.map { |hash| instantiate(hash) }
43
- else
64
+ if data.nil?
44
65
  []
66
+ else
67
+ data.map { |hash| instantiate(hash) }
45
68
  end
46
69
  end
47
70
 
@@ -61,75 +84,98 @@ module FacebookAds
61
84
  def pack(hash, objectify)
62
85
  hash = hash.merge(access_token: FacebookAds.access_token)
63
86
  hash = hash.merge(fields: self::FIELDS.join(',')) if objectify
64
- hash.compact
87
+ hash.delete_if { |_k, v| v.nil? }
65
88
  end
66
89
 
67
90
  def unpack(response, objectify:)
68
- if response.nil? || !response.is_a?(Hash)
69
- raise Exception, "Invalid response: #{response.inspect}"
91
+ raise Exception, 'Invalid nil response' if response.nil?
92
+ response = response.body if response.is_a?(RestClient::Response)
93
+
94
+ if response.is_a?(String)
95
+ response = begin
96
+ JSON.parse(response)
97
+ rescue JSON::ParserError
98
+ raise Exception, "Invalid JSON response: #{response.inspect}"
99
+ end
70
100
  end
71
101
 
72
- if response['error'].present?
73
- # Let's have different Exception subclasses for different error codes.
74
- raise Exception, "#{response['error']['code']}: #{response['error']['message']} | #{response.inspect}"
75
- end
102
+ raise Exception, "Invalid response: #{response.inspect}" unless response.is_a?(Hash)
103
+ raise Exception, "[#{response['error']['code']}] #{response['error']['message']} - raw response: #{response.inspect}" unless response['error'].nil?
104
+ return response unless objectify
76
105
 
77
- if objectify
78
- if response.key?('data') && (data = response['data']).is_a?(Array)
79
- data.map { |hash| instantiate(hash) }
80
- else
81
- instantiate(response)
82
- end
106
+ if response.key?('data') && (data = response['data']).is_a?(Array)
107
+ data.map { |hash| instantiate(hash) }
83
108
  else
84
- response
109
+ instantiate(response)
85
110
  end
86
111
  end
87
112
 
88
- end
89
-
90
- def update(data)
91
- if data.present?
92
- response = self.class.post("/#{id}", query: data, objectify: false)
113
+ def escape(s)
114
+ URI.encode_www_form_component(s)
115
+ end
93
116
 
94
- if response.is_a?(Hash) && response.key?('success')
95
- response['success']
117
+ # https://github.com/rack/rack/blob/master/lib/rack/utils.rb
118
+ def build_nested_query(value, prefix = nil)
119
+ case value
120
+ when Array
121
+ value.map { |v| build_nested_query(v, "#{prefix}[]") }.join('&')
122
+ when Hash
123
+ value.map { |k, v| build_nested_query(v, prefix ? "#{prefix}[#{escape(k)}]" : escape(k)) }.reject(&:empty?).join('&')
124
+ when nil
125
+ prefix
96
126
  else
97
- raise Exception, "Invalid response from update: #{response.inspect}"
127
+ raise ArgumentError, 'value must be a Hash' if prefix.nil?
128
+ "#{prefix}=#{escape(value)}"
98
129
  end
99
- else
100
- false
101
130
  end
102
- end
103
131
 
104
- def save
105
- if changes.present?
106
- data = {}
107
- changes.keys.each { |key| data[key] = self[key] }
108
-
109
- if update(data)
110
- self.class.find(id)
132
+ def exception(verb, path, e)
133
+ if e.response.is_a?(String)
134
+ begin
135
+ hash = JSON.parse(e.response)
136
+ error = hash['error']
137
+ message = error.nil? ? hash.inspect : "#{error['type']} code=#{error['code']} message=#{error['message']}"
138
+ rescue JSON::ParserError
139
+ message = e.response.first(100)
140
+ end
111
141
  else
112
- nil
142
+ message = e.response.inspect
113
143
  end
114
- else
115
- nil
144
+
145
+ FacebookAds.logger.error "#{verb.upcase} #{path} [#{e.message}] #{message}"
146
+ raise e
116
147
  end
117
148
  end
118
149
 
150
+ def update(data)
151
+ return false if data.nil?
152
+ response = self.class.post("/#{id}", query: data, objectify: false)
153
+ raise Exception, "Invalid response from update: #{response.inspect}" unless @response.is_a?(Hash) && response.key?('success')
154
+ response['success']
155
+ end
156
+
157
+ def save
158
+ return nil if changes.nil? || changes.length.zero?
159
+ data = {}
160
+ changes.keys.each { |key| data[key] = self[key] }
161
+ return nil unless update(data)
162
+ self.class.find(id)
163
+ end
164
+
119
165
  def destroy(path: nil, query: {})
120
166
  response = self.class.delete(path || "/#{id}", query: query)
121
-
122
- if response.key?('success')
123
- response['success']
124
- else
125
- raise Exception, "Invalid response from destroy: #{response.inspect}"
126
- end
167
+ raise Exception, "Invalid response from destroy: #{response.inspect}" unless response.key?('success')
168
+ response['success']
127
169
  end
128
170
 
129
171
  protected
130
172
 
131
173
  attr_accessor :changes
132
174
 
175
+ def persisted?
176
+ !id.nil?
177
+ end
178
+
133
179
  private
134
180
 
135
181
  def []=(key, value)
@@ -146,6 +192,5 @@ module FacebookAds
146
192
 
147
193
  new_values
148
194
  end
149
-
150
195
  end
151
196
  end