facebook_ads 0.1.5 → 0.1.7

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 (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