recurly 0.4.16 → 2.0.0
Sign up to get free protection for your applications and to get access to all the features.
Potentially problematic release.
This version of recurly might be problematic. Click here for more details.
- data/README.markdown +118 -0
- data/bin/recurly +78 -0
- data/lib/rails/generators/recurly/config_generator.rb +16 -0
- data/lib/rails/recurly.rb +13 -0
- data/lib/recurly.rb +64 -139
- data/lib/recurly/account.rb +52 -111
- data/lib/recurly/add_on.rb +20 -0
- data/lib/recurly/adjustment.rb +51 -0
- data/lib/recurly/api.rb +73 -0
- data/lib/recurly/api/errors.rb +205 -0
- data/lib/recurly/api/net_http.rb +77 -0
- data/lib/recurly/billing_info.rb +45 -42
- data/lib/recurly/coupon.rb +63 -8
- data/lib/recurly/helper.rb +39 -0
- data/lib/recurly/invoice.rb +38 -16
- data/lib/recurly/js.rb +113 -0
- data/lib/recurly/money.rb +105 -0
- data/lib/recurly/plan.rb +26 -15
- data/lib/recurly/redemption.rb +34 -0
- data/lib/recurly/resource.rb +925 -0
- data/lib/recurly/resource/pager.rb +210 -0
- data/lib/recurly/subscription.rb +90 -67
- data/lib/recurly/subscription/add_ons.rb +73 -0
- data/lib/recurly/transaction.rb +65 -53
- data/lib/recurly/transaction/errors.rb +98 -0
- data/lib/recurly/version.rb +16 -2
- data/lib/recurly/xml.rb +85 -0
- data/lib/recurly/xml/nokogiri.rb +49 -0
- data/lib/recurly/xml/rexml.rb +50 -0
- metadata +76 -165
- data/LICENSE +0 -21
- data/README.md +0 -104
- data/init.rb +0 -1
- data/lib/patches/rails2/active_resource/base.rb +0 -35
- data/lib/patches/rails2/active_resource/connection.rb +0 -10
- data/lib/patches/rails3/active_model/serializers/xml.rb +0 -28
- data/lib/patches/rails3/active_resource/connection.rb +0 -10
- data/lib/recurly/account_base.rb +0 -35
- data/lib/recurly/base.rb +0 -195
- data/lib/recurly/charge.rb +0 -39
- data/lib/recurly/config_parser.rb +0 -31
- data/lib/recurly/credit.rb +0 -28
- data/lib/recurly/exceptions.rb +0 -32
- data/lib/recurly/formats/xml_with_errors.rb +0 -132
- data/lib/recurly/formats/xml_with_pagination.rb +0 -47
- data/lib/recurly/rails2/compatibility.rb +0 -8
- data/lib/recurly/rails3/railtie.rb +0 -21
- data/lib/recurly/rails3/recurly.rake +0 -28
- data/lib/recurly/transparent.rb +0 -148
- data/lib/recurly/verification.rb +0 -83
- data/spec/config/recurly.yml +0 -6
- data/spec/config/test1.yml +0 -4
- data/spec/config/test2.yml +0 -7
- data/spec/integration/account_spec.rb +0 -286
- data/spec/integration/add_on_spec.rb +0 -84
- data/spec/integration/billing_info_spec.rb +0 -148
- data/spec/integration/charge_spec.rb +0 -176
- data/spec/integration/coupon_spec.rb +0 -49
- data/spec/integration/credit_spec.rb +0 -106
- data/spec/integration/invoice_spec.rb +0 -86
- data/spec/integration/plan_spec.rb +0 -87
- data/spec/integration/subscription_spec.rb +0 -221
- data/spec/integration/transaction_spec.rb +0 -154
- data/spec/integration/transparent_spec.rb +0 -99
- data/spec/spec_helper.rb +0 -34
- data/spec/support/factory.rb +0 -211
- data/spec/support/vcr.rb +0 -11
- data/spec/unit/account_spec.rb +0 -19
- data/spec/unit/billing_info_spec.rb +0 -39
- data/spec/unit/charge_spec.rb +0 -20
- data/spec/unit/config_spec.rb +0 -42
- data/spec/unit/coupon_spec.rb +0 -13
- data/spec/unit/credit_spec.rb +0 -20
- data/spec/unit/plan_spec.rb +0 -18
- data/spec/unit/subscription_spec.rb +0 -25
- data/spec/unit/transaction_spec.rb +0 -32
- data/spec/unit/transparent_spec.rb +0 -152
- data/spec/unit/verification_spec.rb +0 -82
@@ -0,0 +1,925 @@
|
|
1
|
+
require 'date'
|
2
|
+
|
3
|
+
module Recurly
|
4
|
+
# The base class for all Recurly resources (e.g. {Account}, {Subscription},
|
5
|
+
# {Transaction}).
|
6
|
+
#
|
7
|
+
# Resources behave much like
|
8
|
+
# {ActiveModel}[http://rubydoc.info/gems/activemodel] classes, especially
|
9
|
+
# like {ActiveRecord}[http://rubydoc.info/gems/activerecord].
|
10
|
+
#
|
11
|
+
# == Life Cycle
|
12
|
+
#
|
13
|
+
# To take you through the typical life cycle of a resource, we'll use
|
14
|
+
# {Recurly::Account} as an example.
|
15
|
+
#
|
16
|
+
# === Creating a Record
|
17
|
+
#
|
18
|
+
# You can instantiate a record before attempting to save it.
|
19
|
+
#
|
20
|
+
# account = Recurly::Account.new :first_name => 'Walter'
|
21
|
+
#
|
22
|
+
# Once instantiated, you can assign and reassign any attribute.
|
23
|
+
#
|
24
|
+
# account.first_name = 'Walt'
|
25
|
+
# account.last_name = 'White'
|
26
|
+
#
|
27
|
+
# When you're ready to save, do so.
|
28
|
+
#
|
29
|
+
# account.save # => false
|
30
|
+
#
|
31
|
+
# If save returns +false+, validation likely failed. You can check the record
|
32
|
+
# for errors.
|
33
|
+
#
|
34
|
+
# account.errors # => {"account_code"=>["can't be blank"]}
|
35
|
+
#
|
36
|
+
# Once the errors are fixed, you can try again.
|
37
|
+
#
|
38
|
+
# account.account_code = 'heisenberg'
|
39
|
+
# account.save # => true
|
40
|
+
#
|
41
|
+
# The object will be updated with any information provided by the server
|
42
|
+
# (including any UUIDs set).
|
43
|
+
#
|
44
|
+
# account.created_at # => 2011-04-30 07:13:35 -0700
|
45
|
+
#
|
46
|
+
# You can also create accounts in one fell swoop.
|
47
|
+
#
|
48
|
+
# Recurly::Account.create(
|
49
|
+
# :first_name => 'Jesse'
|
50
|
+
# :last_name => 'Pinkman'
|
51
|
+
# :account_code => 'capn_cook'
|
52
|
+
# )
|
53
|
+
# # => #<Recurly::Account account_code: "capn_cook" ...>
|
54
|
+
#
|
55
|
+
# You can use alternative "bang" methods for exception control. If the record
|
56
|
+
# fails to save, a Recurly::Resource::Invalid exception will be raised.
|
57
|
+
#
|
58
|
+
# begin
|
59
|
+
# account = Recurly::Account.new :first_name => 'Junior'
|
60
|
+
# account.save!
|
61
|
+
# rescue Recurly::Resource::Invalid
|
62
|
+
# p account.errors
|
63
|
+
# end
|
64
|
+
#
|
65
|
+
# You can access the invalid record from the exception itself (if, for
|
66
|
+
# example, you use the <tt>create!</tt> method).
|
67
|
+
#
|
68
|
+
# begin
|
69
|
+
# Recurly::Account.create! :first_name => 'Skylar', :last_name => 'White'
|
70
|
+
# rescue Recurly::Resource::Invalid => e
|
71
|
+
# p e.record.errors
|
72
|
+
# end
|
73
|
+
#
|
74
|
+
# === Fetching a Record
|
75
|
+
#
|
76
|
+
# Records are fetched by their unique identifiers.
|
77
|
+
#
|
78
|
+
# account = Recurly::Account.find 'better_call_saul'
|
79
|
+
# # => #<Recurly::Account account_code: "better_call_saul" ...>
|
80
|
+
#
|
81
|
+
# If the record doesn't exist, a Recurly::Resource::NotFound exception will
|
82
|
+
# be raised.
|
83
|
+
#
|
84
|
+
# === Updating a Record
|
85
|
+
#
|
86
|
+
# Once fetched, a record can be updated with a hash of attributes.
|
87
|
+
#
|
88
|
+
# account.update_attributes :first_name => 'Saul', :last_name => 'Goodman'
|
89
|
+
# # => true
|
90
|
+
#
|
91
|
+
# (A bang method, update_attributes!, will raise Recurly::Resource::Invalid.)
|
92
|
+
#
|
93
|
+
# You can also update a record by setting attributes and calling save.
|
94
|
+
#
|
95
|
+
# account.last_name = 'McGill'
|
96
|
+
# account.save # Alternatively, call save!
|
97
|
+
#
|
98
|
+
# === Deleting a Record
|
99
|
+
#
|
100
|
+
# To delete (deactivate, close, etc.) a fetched record, merely call destroy
|
101
|
+
# on it.
|
102
|
+
#
|
103
|
+
# account.destroy # => true
|
104
|
+
#
|
105
|
+
# === Fetching a List of Records
|
106
|
+
#
|
107
|
+
# If you want to iterate over a list of accounts, you can use a Pager.
|
108
|
+
#
|
109
|
+
# pager = Account.paginate :per_page => 50
|
110
|
+
#
|
111
|
+
# If you want to iterate over _every_ record, a convenience method will
|
112
|
+
# automatically paginate:
|
113
|
+
#
|
114
|
+
# Account.find_each { |account| p account }
|
115
|
+
class Resource
|
116
|
+
autoload :Pager, 'recurly/resource/pager'
|
117
|
+
|
118
|
+
# Raised when a record cannot be found.
|
119
|
+
#
|
120
|
+
# @example
|
121
|
+
# begin
|
122
|
+
# Recurly::Account.find 'tortuga'
|
123
|
+
# rescue Recurly::Resource::NotFound => e
|
124
|
+
# e.message # => "Can't find Account with account_code = tortuga"
|
125
|
+
# end
|
126
|
+
class NotFound < API::NotFound
|
127
|
+
def initialize message
|
128
|
+
set_message message
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
# Raised when a record is invalid.
|
133
|
+
#
|
134
|
+
# @example
|
135
|
+
# begin
|
136
|
+
# Recurly::Account.create! :first_name => "Flynn"
|
137
|
+
# rescue Recurly::Resource::Invalid => e
|
138
|
+
# e.record.errors # => errors: {"account_code"=>["can't be blank"]}>
|
139
|
+
# end
|
140
|
+
class Invalid < API::UnprocessableEntity
|
141
|
+
# @return [Resource, nil] The invalid record.
|
142
|
+
attr_reader :record
|
143
|
+
|
144
|
+
def initialize record_or_message
|
145
|
+
set_message case record_or_message
|
146
|
+
when Resource
|
147
|
+
@record = record_or_message
|
148
|
+
record_or_message.errors.map { |k, v| "#{k} #{v * ', '}" }.join '; '
|
149
|
+
else
|
150
|
+
record_or_message
|
151
|
+
end
|
152
|
+
end
|
153
|
+
end
|
154
|
+
|
155
|
+
class << self
|
156
|
+
# @return [String] The demodulized name of the resource class.
|
157
|
+
# @example
|
158
|
+
# Recurly::Account.name # => "Account"
|
159
|
+
def resource_name
|
160
|
+
Helper.demodulize name
|
161
|
+
end
|
162
|
+
|
163
|
+
# @return [String] The underscored, pluralized name of the resource
|
164
|
+
# class.
|
165
|
+
# @example
|
166
|
+
# Recurly::Account.collection_name # => "accounts"
|
167
|
+
def collection_name
|
168
|
+
Helper.pluralize Helper.underscore(resource_name)
|
169
|
+
end
|
170
|
+
alias collection_path collection_name
|
171
|
+
|
172
|
+
# @return [String] The underscored name of the resource class.
|
173
|
+
# @example
|
174
|
+
# Recurly::Account.member_name # => "account"
|
175
|
+
def member_name
|
176
|
+
Helper.underscore resource_name
|
177
|
+
end
|
178
|
+
|
179
|
+
# @return [String] The relative path to a resource's identifier from the
|
180
|
+
# API's base URI.
|
181
|
+
# @param uuid [String, nil]
|
182
|
+
# @example
|
183
|
+
# Recurly::Account.member_path "code" # => "accounts/code"
|
184
|
+
# Recurly::Account.member_path nil # => "accounts"
|
185
|
+
def member_path uuid
|
186
|
+
[collection_path, uuid].compact.join '/'
|
187
|
+
end
|
188
|
+
|
189
|
+
# @return [Array] Per attribute, defines readers, writers, boolean and
|
190
|
+
# change-tracking methods.
|
191
|
+
# @param attribute_names [Array] An array of attribute names.
|
192
|
+
# @example
|
193
|
+
# class Account < Resource
|
194
|
+
# define_attribute_methods [:name]
|
195
|
+
# end
|
196
|
+
#
|
197
|
+
# a = Account.new
|
198
|
+
# a.name? # => false
|
199
|
+
# a.name # => nil
|
200
|
+
# a.name = "Stephen"
|
201
|
+
# a.name? # => true
|
202
|
+
# a.name # => "Stephen"
|
203
|
+
# a.name_changed? # => true
|
204
|
+
# a.name_was # => nil
|
205
|
+
# a.name_change # => [nil, "Stephen"]
|
206
|
+
def define_attribute_methods attribute_names
|
207
|
+
@attribute_names = attribute_names.map! { |m| m.to_s }.sort!.freeze
|
208
|
+
remove_const :AttributeMethods if const_defined? :AttributeMethods
|
209
|
+
include const_set :AttributeMethods, Module.new {
|
210
|
+
attribute_names.each do |name|
|
211
|
+
define_method(name) { self[name] } # Get.
|
212
|
+
define_method("#{name}=") { |value| self[name] = value } # Set.
|
213
|
+
define_method("#{name}?") { !!self[name] } # Present.
|
214
|
+
define_method("#{name}_change") { changes[name] } # Dirt...
|
215
|
+
define_method("#{name}_changed?") { changed_attributes.key? name }
|
216
|
+
define_method("#{name}_was") { changed_attributes[name] }
|
217
|
+
define_method("#{name}_previously_changed?") {
|
218
|
+
previous_changes.key? name
|
219
|
+
}
|
220
|
+
define_method("#{name}_previously_was") {
|
221
|
+
previous_changes[name].first if previous_changes.key? name
|
222
|
+
}
|
223
|
+
end
|
224
|
+
}
|
225
|
+
end
|
226
|
+
|
227
|
+
# @return [Array, nil] The list of attribute names defined for the
|
228
|
+
# resource class.
|
229
|
+
attr_reader :attribute_names
|
230
|
+
|
231
|
+
# @return [Pager] A pager with an iterable collection of records
|
232
|
+
# @param options [Hash] A hash of pagination options
|
233
|
+
# @option options [Integer] :per_page The number of records returned per
|
234
|
+
# page
|
235
|
+
# @option options [DateTime, Time, Integer] :cursor A timestamp that the
|
236
|
+
# pager will skim back to and return records created before it
|
237
|
+
# @option options [String] :etag When set, will raise
|
238
|
+
# {Recurly::API::NotModified} if the pager's loaded page content has
|
239
|
+
# not changed
|
240
|
+
# @example Fetch 50 records and iterate over them
|
241
|
+
# Recurly::Account.paginate(:per_page => 50).each { |a| p a }
|
242
|
+
# @example Fetch records before January 1, 2011
|
243
|
+
# Recurly::Account.paginate(:cursor => Time.new(2011, 1, 1))
|
244
|
+
def paginate options = {}
|
245
|
+
Pager.new self, options
|
246
|
+
end
|
247
|
+
alias scoped paginate
|
248
|
+
alias where paginate
|
249
|
+
|
250
|
+
def all options = {}
|
251
|
+
paginate(options).to_a
|
252
|
+
end
|
253
|
+
|
254
|
+
# @return [Hash] Defined scopes per resource.
|
255
|
+
def scopes
|
256
|
+
@scopes ||= Recurly::Helper.hash_with_indifferent_read_access
|
257
|
+
end
|
258
|
+
|
259
|
+
# Defines a new resource scope.
|
260
|
+
#
|
261
|
+
# @return [Proc]
|
262
|
+
# @param [Symbol] name the scope name
|
263
|
+
# @param [Hash] params the scope params
|
264
|
+
def scope name, params = {}
|
265
|
+
scopes[name = name.to_s] = params
|
266
|
+
extend const_set :Scopes, Module.new unless const_defined? :Scopes
|
267
|
+
self::Scopes.send(:define_method, name) { paginate scopes[name] }
|
268
|
+
end
|
269
|
+
|
270
|
+
# Iterates through every record by automatically paging.
|
271
|
+
#
|
272
|
+
# @return [nil]
|
273
|
+
# @param [Integer] per_page The number of records returned per request.
|
274
|
+
# @yield [record]
|
275
|
+
# @see Pager#find_each
|
276
|
+
# @example
|
277
|
+
# Recurly::Account.find_each { |a| p a }
|
278
|
+
def find_each per_page = 50
|
279
|
+
paginate(:per_page => per_page).find_each(&Proc.new)
|
280
|
+
end
|
281
|
+
|
282
|
+
# @return [Integer] The total record count of the resource in question.
|
283
|
+
# @see Pager#count
|
284
|
+
# @example
|
285
|
+
# Recurly::Account.count # => 42
|
286
|
+
def count
|
287
|
+
paginate.count
|
288
|
+
end
|
289
|
+
|
290
|
+
# @api internal
|
291
|
+
# @return [Resource, nil]
|
292
|
+
def first
|
293
|
+
paginate(:per_page => 1).first
|
294
|
+
end
|
295
|
+
|
296
|
+
# @return [Resource] A record matching the designated unique identifier.
|
297
|
+
# @param [String] uuid The unique identifier of the resource to be
|
298
|
+
# retrieved.
|
299
|
+
# @param [Hash] options A hash of options.
|
300
|
+
# @option options [String] :etag When set, will raise {API::NotModified}
|
301
|
+
# if the record content has not changed.
|
302
|
+
# @raise [Error] If the resource has no identifier (and thus cannot be
|
303
|
+
# retrieved).
|
304
|
+
# @raise [NotFound] If no resource can be found for the supplied
|
305
|
+
# identifier (or the supplied identifier is +nil+).
|
306
|
+
# @raise [API::NotModified] If the <tt>:etag</tt> option is set and
|
307
|
+
# matches the server's.
|
308
|
+
# @example
|
309
|
+
# Recurly::Account.find "heisenberg"
|
310
|
+
# # => #<Recurly::Account account_code: "heisenberg", ...>
|
311
|
+
def find uuid, options = {}
|
312
|
+
if uuid.nil?
|
313
|
+
# Should we raise an ArgumentError, instead?
|
314
|
+
raise NotFound, "can't find a record with nil identifier"
|
315
|
+
end
|
316
|
+
|
317
|
+
request_options = {}
|
318
|
+
if etag = options[:etag]
|
319
|
+
request_options[:head] = { 'If-None-Match' => etag }
|
320
|
+
end
|
321
|
+
|
322
|
+
uri = uuid =~ /^http/ ? uuid : member_path(uuid)
|
323
|
+
begin
|
324
|
+
from_response API.get(uri, {}, request_options)
|
325
|
+
rescue API::NotFound => e
|
326
|
+
raise NotFound, e.description
|
327
|
+
end
|
328
|
+
end
|
329
|
+
|
330
|
+
# Instantiates and attempts to save a record.
|
331
|
+
#
|
332
|
+
# @return [Resource] The record.
|
333
|
+
# @raise [Transaction::Error] A monetary transaction failed.
|
334
|
+
# @see create!
|
335
|
+
def create attributes = {}
|
336
|
+
new(attributes) { |record| record.save }
|
337
|
+
end
|
338
|
+
|
339
|
+
# Instantiates and attempts to save a record.
|
340
|
+
#
|
341
|
+
# @return [Resource] The saved record.
|
342
|
+
# @raise [Invalid] The record is invalid.
|
343
|
+
# @raise [Transaction::Error] A monetary transaction failed.
|
344
|
+
# @see create
|
345
|
+
def create! attributes = {}
|
346
|
+
new(attributes) { |record| record.save! }
|
347
|
+
end
|
348
|
+
|
349
|
+
# Instantiates a record from an HTTP response, setting the record's
|
350
|
+
# response attribute in the process.
|
351
|
+
#
|
352
|
+
# @return [Resource]
|
353
|
+
# @param response [Net::HTTPResponse]
|
354
|
+
def from_response response
|
355
|
+
record = from_xml response.body
|
356
|
+
record.instance_eval { @etag, @response = response['ETag'], response }
|
357
|
+
record
|
358
|
+
end
|
359
|
+
|
360
|
+
# Instantiates a record from an XML blob: either a String or XML element.
|
361
|
+
#
|
362
|
+
# Assuming the record is from an API response, the record is flagged as
|
363
|
+
# persisted.
|
364
|
+
#
|
365
|
+
# @return [Resource]
|
366
|
+
# @param xml [String, REXML::Element, Nokogiri::XML::Node]
|
367
|
+
# @see from_response
|
368
|
+
def from_xml xml
|
369
|
+
xml = XML.new xml
|
370
|
+
if xml.name == member_name
|
371
|
+
record = new
|
372
|
+
elsif Recurly.const_defined?(class_name = Helper.classify(xml.name))
|
373
|
+
record = Recurly.const_get(class_name).new
|
374
|
+
elsif root = xml.root and root.elements.empty?
|
375
|
+
return XML.cast root
|
376
|
+
else
|
377
|
+
record = {}
|
378
|
+
end
|
379
|
+
|
380
|
+
xml.root.attributes.each do |name, value|
|
381
|
+
record.instance_variable_set "@#{name}", value.to_s
|
382
|
+
end
|
383
|
+
|
384
|
+
xml.each_element do |el|
|
385
|
+
if el.name == 'a'
|
386
|
+
name, uri = el.attribute('name').value, el.attribute('href').value
|
387
|
+
record[name] = case el.attribute('method').to_s
|
388
|
+
when 'get', '' then proc { |*opts| API.get uri, {}, *opts }
|
389
|
+
when 'post' then proc { |*opts| API.post uri, nil, *opts }
|
390
|
+
when 'put' then proc { |*opts| API.put uri, nil, *opts }
|
391
|
+
when 'delete' then proc { |*opts| API.delete uri, *opts }
|
392
|
+
end
|
393
|
+
next
|
394
|
+
end
|
395
|
+
|
396
|
+
if el.children.empty? && href = el.attribute('href')
|
397
|
+
resource_class = Recurly.const_get(
|
398
|
+
Helper.classify(el.attribute('type') || el.name)
|
399
|
+
)
|
400
|
+
record[el.name] = case el.name
|
401
|
+
when *associations[:has_many]
|
402
|
+
Pager.new resource_class, :uri => href.value, :parent => record
|
403
|
+
when *(associations[:has_one] + associations[:belongs_to])
|
404
|
+
lambda {
|
405
|
+
begin
|
406
|
+
relation = resource_class.from_response API.get(href.value)
|
407
|
+
relation.attributes[member_name] = record
|
408
|
+
relation
|
409
|
+
rescue Recurly::API::NotFound
|
410
|
+
end
|
411
|
+
}
|
412
|
+
end
|
413
|
+
else
|
414
|
+
record[el.name] = XML.cast el
|
415
|
+
end
|
416
|
+
end
|
417
|
+
|
418
|
+
record.persist! if record.respond_to? :persist!
|
419
|
+
record
|
420
|
+
end
|
421
|
+
|
422
|
+
# @return [Hash] A list of association names for the current class.
|
423
|
+
def associations
|
424
|
+
@associations ||= begin
|
425
|
+
unless const_defined? :Associations
|
426
|
+
include const_set :Associations, Module.new
|
427
|
+
end
|
428
|
+
|
429
|
+
{ :has_many => [], :has_one => [], :belongs_to => [] }
|
430
|
+
end
|
431
|
+
end
|
432
|
+
|
433
|
+
# Establishes a has_many association.
|
434
|
+
#
|
435
|
+
# @return [Proc, nil]
|
436
|
+
# @param collection_name [Symbol] Association name.
|
437
|
+
# @param options [Hash] A hash of association options.
|
438
|
+
# @option options [true, false] :readonly Don't define a setter.
|
439
|
+
def has_many collection_name, options = {}
|
440
|
+
associations[:has_many] << collection_name.to_s
|
441
|
+
self::Associations.module_eval {
|
442
|
+
define_method(collection_name) {
|
443
|
+
self[collection_name] ||= []
|
444
|
+
}
|
445
|
+
if options.key?(:readonly) && options[:readonly] == false
|
446
|
+
define_method("#{collection_name}=") { |collection|
|
447
|
+
self[collection_name] = collection
|
448
|
+
}
|
449
|
+
end
|
450
|
+
}
|
451
|
+
end
|
452
|
+
|
453
|
+
# Establishes a has_one association.
|
454
|
+
#
|
455
|
+
# @return [Proc, nil]
|
456
|
+
# @param member_name [Symbol] Association name.
|
457
|
+
# @param options [Hash] A hash of association options.
|
458
|
+
# @option options [true, false] :readonly Don't define a setter.
|
459
|
+
def has_one member_name, options = {}
|
460
|
+
associations[:has_one] << member_name.to_s
|
461
|
+
self::Associations.module_eval {
|
462
|
+
define_method(member_name) { self[member_name] }
|
463
|
+
if options.key?(:readonly) && options[:readonly] == false
|
464
|
+
associated = Recurly.const_get Helper.classify(member_name)
|
465
|
+
define_method("#{member_name}=") { |member|
|
466
|
+
associated_uri = "#{path}/#{member_name}"
|
467
|
+
self[member_name] = case member
|
468
|
+
when Hash
|
469
|
+
associated.send :new, member.merge(:uri => associated_uri)
|
470
|
+
when associated_class
|
471
|
+
member.uri = associated_uri and member
|
472
|
+
else
|
473
|
+
raise ArgumentError, "expected #{associated_class}"
|
474
|
+
end
|
475
|
+
}
|
476
|
+
define_method("build_#{member_name}") { |*args|
|
477
|
+
attributes = args.shift || {}
|
478
|
+
self[member_name] = associated.send(
|
479
|
+
:new, attributes.merge(:uri => "#{path}/#{member_name}")
|
480
|
+
)
|
481
|
+
}
|
482
|
+
define_method("create_#{member_name}") { |*args|
|
483
|
+
send("build_#{member_name}", *args).tap { |child| child.save }
|
484
|
+
}
|
485
|
+
end
|
486
|
+
}
|
487
|
+
end
|
488
|
+
|
489
|
+
# Establishes a belongs_to association.
|
490
|
+
#
|
491
|
+
# @return [Proc]
|
492
|
+
def belongs_to parent_name, options = {}
|
493
|
+
associations[:belongs_to] << parent_name.to_s
|
494
|
+
self::Associations.module_eval {
|
495
|
+
define_method(parent_name) { self[parent_name] }
|
496
|
+
if options.key?(:readonly) && options[:readonly] == false
|
497
|
+
define_method("#{parent_name}=") { |parent|
|
498
|
+
self[parent_name] = parent
|
499
|
+
}
|
500
|
+
end
|
501
|
+
}
|
502
|
+
end
|
503
|
+
|
504
|
+
# @return [:has_many, :has_one, :belongs_to, nil] An association type.
|
505
|
+
def reflect_on_association name
|
506
|
+
a = associations.find { |k, v| v.include? name.to_s } and a.first
|
507
|
+
end
|
508
|
+
|
509
|
+
def embedded!
|
510
|
+
private_class_method(*%w(
|
511
|
+
new create create! paginate find_each scoped where all
|
512
|
+
))
|
513
|
+
private :initialize
|
514
|
+
end
|
515
|
+
end
|
516
|
+
|
517
|
+
# @return [Hash] The raw hash of record attributes.
|
518
|
+
attr_reader :attributes
|
519
|
+
|
520
|
+
# @return [Net::HTTPResponse, nil] The most recent response object for the
|
521
|
+
# record (updated during {#save} and {#destroy}).
|
522
|
+
attr_reader :response
|
523
|
+
|
524
|
+
# @return [String, nil] An ETag for the current record.
|
525
|
+
attr_reader :etag
|
526
|
+
|
527
|
+
# @return [String, nil] A writer to override the URI the record saves to.
|
528
|
+
attr_writer :uri
|
529
|
+
|
530
|
+
# @return [Resource] A new resource instance.
|
531
|
+
# @param attributes [Hash] A hash of attributes.
|
532
|
+
def initialize attributes = {}
|
533
|
+
if instance_of? Resource
|
534
|
+
raise Error,
|
535
|
+
"#{self.class} is an abstract class and cannot be instantiated"
|
536
|
+
end
|
537
|
+
|
538
|
+
@attributes, @new_record, @destroyed, @uri, @href = {}, true, false
|
539
|
+
self.attributes = attributes
|
540
|
+
yield self if block_given?
|
541
|
+
end
|
542
|
+
|
543
|
+
def to_param
|
544
|
+
self[self.class.param_name]
|
545
|
+
end
|
546
|
+
|
547
|
+
# @return [self] Reloads the record from the server.
|
548
|
+
def reload response = nil
|
549
|
+
if response
|
550
|
+
return if response.body.length.zero?
|
551
|
+
fresh = self.class.from_response response
|
552
|
+
else
|
553
|
+
fresh = self.class.find(
|
554
|
+
@href || to_param, :etag => (etag unless changed?)
|
555
|
+
)
|
556
|
+
end
|
557
|
+
fresh and copy_from fresh
|
558
|
+
persist! true
|
559
|
+
self
|
560
|
+
rescue API::NotModified
|
561
|
+
self
|
562
|
+
end
|
563
|
+
|
564
|
+
# @return [Hash] Hash of changed attributes.
|
565
|
+
# @see #changes
|
566
|
+
def changed_attributes
|
567
|
+
@changed_attributes ||= {}
|
568
|
+
end
|
569
|
+
|
570
|
+
# @return [Array] A list of changed attribute keys.
|
571
|
+
def changed
|
572
|
+
changed_attributes.keys
|
573
|
+
end
|
574
|
+
|
575
|
+
# Do any attributes have unsaved changes?
|
576
|
+
# @return [true, false]
|
577
|
+
def changed?
|
578
|
+
!changed_attributes.empty?
|
579
|
+
end
|
580
|
+
|
581
|
+
# @return [Hash] Map of changed attributes to original value and new value.
|
582
|
+
def changes
|
583
|
+
changed_attributes.inject({}) { |changes, (key, original_value)|
|
584
|
+
changes[key] = [original_value, self[key]] and changes
|
585
|
+
}
|
586
|
+
end
|
587
|
+
|
588
|
+
# @return [Hash] Previously-changed attributes.
|
589
|
+
# @see #changes
|
590
|
+
def previous_changes
|
591
|
+
@previous_changes ||= {}
|
592
|
+
end
|
593
|
+
|
594
|
+
# Is the record new (i.e., not saved on Recurly's servers)?
|
595
|
+
#
|
596
|
+
# @return [true, false]
|
597
|
+
# @see #persisted?
|
598
|
+
# @see #destroyed?
|
599
|
+
def new_record?
|
600
|
+
@new_record
|
601
|
+
end
|
602
|
+
|
603
|
+
# Has the record been destroyed? (Set +true+ after a successful destroy.)
|
604
|
+
# @return [true, false]
|
605
|
+
# @see #new_record?
|
606
|
+
# @see #persisted?
|
607
|
+
def destroyed?
|
608
|
+
@destroyed
|
609
|
+
end
|
610
|
+
|
611
|
+
# Has the record persisted (i.e., saved on Recurly's servers)?
|
612
|
+
#
|
613
|
+
# @return [true, false]
|
614
|
+
# @see #new_record?
|
615
|
+
# @see #destroyed?
|
616
|
+
def persisted?
|
617
|
+
!(new_record? || destroyed?)
|
618
|
+
end
|
619
|
+
|
620
|
+
# The value of a specified attribute, lazily fetching any defined
|
621
|
+
# association.
|
622
|
+
#
|
623
|
+
# @param key [Symbol, String] The name of the attribute to be fetched.
|
624
|
+
# @example
|
625
|
+
# account.read_attribute :first_name # => "Ted"
|
626
|
+
# account[:last_name] # => "Beneke"
|
627
|
+
# @see #write_attribute
|
628
|
+
def read_attribute key
|
629
|
+
value = attributes[key = key.to_s]
|
630
|
+
if value.respond_to?(:call) && self.class.reflect_on_association(key)
|
631
|
+
value = attributes[key] = value.call
|
632
|
+
end
|
633
|
+
value
|
634
|
+
end
|
635
|
+
alias [] read_attribute
|
636
|
+
|
637
|
+
# Sets the value of a specified attribute.
|
638
|
+
#
|
639
|
+
# @param key [Symbol, String] The name of the attribute to be set.
|
640
|
+
# @param value [Object] The value the attribute will be set to.
|
641
|
+
# @example
|
642
|
+
# account.write_attribute :first_name, 'Gus'
|
643
|
+
# account[:company_name] = 'Los Pollos Hermanos'
|
644
|
+
# @see #read_attribute
|
645
|
+
def write_attribute key, value
|
646
|
+
if changed_attributes.key?(key = key.to_s)
|
647
|
+
changed_attributes.delete key if changed_attributes[key] == value
|
648
|
+
elsif self[key] != value
|
649
|
+
changed_attributes[key] = self[key]
|
650
|
+
end
|
651
|
+
|
652
|
+
if self.class.associations.values.flatten.include? key
|
653
|
+
value = fetch_association key, value
|
654
|
+
# FIXME: More explicit; less magic.
|
655
|
+
elsif key.end_with?('_in_cents') && !respond_to?(:currency)
|
656
|
+
value = Money.new value unless value.is_a? Money
|
657
|
+
end
|
658
|
+
|
659
|
+
attributes[key] = value
|
660
|
+
end
|
661
|
+
alias []= write_attribute
|
662
|
+
|
663
|
+
# Apply a given hash of attributes to a record.
|
664
|
+
#
|
665
|
+
# @return [Hash]
|
666
|
+
# @param attributes [Hash] A hash of attributes.
|
667
|
+
def attributes= attributes = {}
|
668
|
+
attributes.each_pair { |k, v|
|
669
|
+
respond_to?(name = "#{k}=") and send(name, v) or self[k] = v
|
670
|
+
}
|
671
|
+
end
|
672
|
+
|
673
|
+
# Serializes the record to XML.
|
674
|
+
#
|
675
|
+
# @return [String] An XML string.
|
676
|
+
# @param options [Hash] A hash of XML options.
|
677
|
+
# @example
|
678
|
+
# Recurly::Account.new(:account_code => 'code').to_xml
|
679
|
+
# # => "<account><account_code>code</account_code></account>"
|
680
|
+
def to_xml options = {}
|
681
|
+
builder = options[:builder] || XML.new("<#{self.class.member_name}/>")
|
682
|
+
xml_keys.each { |key|
|
683
|
+
value = respond_to?(key) ? send(key) : self[key]
|
684
|
+
node = builder.add_element key
|
685
|
+
|
686
|
+
if value.respond_to? :to_xml
|
687
|
+
value.to_xml options.merge(:builder => node)
|
688
|
+
elsif value.respond_to? :each_pair
|
689
|
+
value.each_pair { |k, v| node.add_element k.to_s, v }
|
690
|
+
elsif value.respond_to? :each
|
691
|
+
value.each { |e| node.add_element Helper.singularize(key), e }
|
692
|
+
else
|
693
|
+
node.text = value
|
694
|
+
end
|
695
|
+
}
|
696
|
+
builder.to_s
|
697
|
+
end
|
698
|
+
|
699
|
+
# Attempts to save the record, returning the success of the request.
|
700
|
+
#
|
701
|
+
# @return [true, false]
|
702
|
+
# @raise [Transaction::Error] A monetary transaction failed.
|
703
|
+
# @example
|
704
|
+
# account = Recurly::Account.new
|
705
|
+
# account.save # => false
|
706
|
+
# account.account_code = 'account_code'
|
707
|
+
# account.save # => true
|
708
|
+
# @see #save!
|
709
|
+
def save
|
710
|
+
if new_record? || changed?
|
711
|
+
clear_errors
|
712
|
+
@response = API.send(
|
713
|
+
persisted? ? :put : :post, path, to_xml(:delta => true)
|
714
|
+
)
|
715
|
+
reload response
|
716
|
+
persist! true
|
717
|
+
end
|
718
|
+
true
|
719
|
+
rescue API::UnprocessableEntity => e
|
720
|
+
apply_errors e
|
721
|
+
Transaction::Error.validate! e, (self if is_a? Transaction)
|
722
|
+
false
|
723
|
+
end
|
724
|
+
|
725
|
+
# Attempts to save the record, returning +true+ if the record was saved and
|
726
|
+
# raising {Invalid} otherwise.
|
727
|
+
#
|
728
|
+
# @return [true]
|
729
|
+
# @raise [Invalid] The record was invalid.
|
730
|
+
# @raise [Transaction::Error] A monetary transaction failed.
|
731
|
+
# @example
|
732
|
+
# account = Recurly::Account.new
|
733
|
+
# account.save! # raises Recurly::Resource::Invalid
|
734
|
+
# account.account_code = 'account_code'
|
735
|
+
# account.save! # => true
|
736
|
+
# @see #save
|
737
|
+
def save!
|
738
|
+
save || raise(Invalid.new(self))
|
739
|
+
end
|
740
|
+
|
741
|
+
# @return [true, false, nil] The validity of the record: +true+ if the
|
742
|
+
# record was successfully saved (or persisted and unchanged), +false+ if
|
743
|
+
# the record was not successfully saved, or +nil+ for a record with an
|
744
|
+
# unknown state (i.e. (i.e. new records that haven't been saved and
|
745
|
+
# persisted records with changed attributes).
|
746
|
+
# @example
|
747
|
+
# account = Recurly::Account.new
|
748
|
+
# account.valid? # => nil
|
749
|
+
# account.save # => false
|
750
|
+
# account.valid? # => false
|
751
|
+
# account.account_code = 'account_code'
|
752
|
+
# account.save # => true
|
753
|
+
# account.valid? # => true
|
754
|
+
def valid?
|
755
|
+
return true if persisted? && changed_attributes.empty?
|
756
|
+
return if response.nil? || (errors.empty? && changed_attributes?)
|
757
|
+
errors.empty?
|
758
|
+
end
|
759
|
+
|
760
|
+
# Update a record with a given hash of attributes.
|
761
|
+
#
|
762
|
+
# @return [true, false] The success of the update.
|
763
|
+
# @param attributes [Hash] A hash of attributes.
|
764
|
+
# @raise [Transaction::Error] A monetary transaction failed.
|
765
|
+
# @example
|
766
|
+
# account = Account.find 'junior'
|
767
|
+
# account.update_attributes :account_code => 'flynn' # => true
|
768
|
+
# @see #update_attributes!
|
769
|
+
def update_attributes attributes = {}
|
770
|
+
self.attributes = attributes and save
|
771
|
+
end
|
772
|
+
|
773
|
+
# Update a record with a given hash of attributes.
|
774
|
+
#
|
775
|
+
# @return [true] The update was successful.
|
776
|
+
# @param attributes [Hash] A hash of attributes.
|
777
|
+
# @raise [Invalid] The record was invalid.
|
778
|
+
# @raise [Transaction::Error] A monetary transaction failed.
|
779
|
+
# @example
|
780
|
+
# account = Account.find 'gale_boetticher'
|
781
|
+
# account.update_attributes! :account_code => nil # Raises an exception.
|
782
|
+
# @see #update_attributes
|
783
|
+
def update_attributes! attributes = {}
|
784
|
+
self.attributes = attributes and save!
|
785
|
+
end
|
786
|
+
|
787
|
+
# @return [Hash] A hash with indifferent read access containing any
|
788
|
+
# validation errors where the key is the attribute name and the value is
|
789
|
+
# an array of error messages.
|
790
|
+
# @example
|
791
|
+
# account.errors # => {"account_code"=>["can't be blank"]}
|
792
|
+
# account.errors[:account_code] # => ["can't be blank"]
|
793
|
+
def errors
|
794
|
+
@errors ||= Recurly::Helper.hash_with_indifferent_read_access
|
795
|
+
end
|
796
|
+
|
797
|
+
# Marks a record as persisted, i.e. not a new or deleted record, resetting
|
798
|
+
# any tracked attribute changes in the process. (This is an internal method
|
799
|
+
# and should probably not be called unless you know what you're doing.)
|
800
|
+
#
|
801
|
+
# @api internal
|
802
|
+
# @return [true]
|
803
|
+
def persist! saved = false
|
804
|
+
@new_record, @uri = false
|
805
|
+
if changed?
|
806
|
+
@previous_changes = changes if saved
|
807
|
+
changed_attributes.clear
|
808
|
+
end
|
809
|
+
true
|
810
|
+
end
|
811
|
+
|
812
|
+
# @return [String, nil] The unique resource identifier (URI) of the record
|
813
|
+
# (if persisted).
|
814
|
+
# @example
|
815
|
+
# Recurly::Account.new(:account_code => "account_code").uri # => nil
|
816
|
+
# Recurly::Account.find("account_code").uri
|
817
|
+
# # => "https://api.recurly.com/v2/accounts/account_code"
|
818
|
+
def uri
|
819
|
+
@href ||= ((API.base_uri + path).to_s if persisted?)
|
820
|
+
end
|
821
|
+
|
822
|
+
# Attempts to destroy the record.
|
823
|
+
#
|
824
|
+
# @return [true, false] +true+ if successful, +false+ if unable to destroy
|
825
|
+
# (if the record does not persist on Recurly).
|
826
|
+
# @raise [NotFound] The record cannot be found.
|
827
|
+
# @example
|
828
|
+
# account = Recurly::Account.find account_code
|
829
|
+
# race_condition = Recurly::Account.find account_code
|
830
|
+
# account.destroy # => true
|
831
|
+
# account.destroy # => false (already destroyed)
|
832
|
+
# race_condition.destroy # raises Recurly::Resource::NotFound
|
833
|
+
def destroy
|
834
|
+
return false unless persisted?
|
835
|
+
@response = API.delete uri
|
836
|
+
@destroyed = true
|
837
|
+
rescue API::NotFound => e
|
838
|
+
raise NotFound, e.description
|
839
|
+
end
|
840
|
+
|
841
|
+
def == other
|
842
|
+
other.is_a?(self.class) && other.to_s == to_s
|
843
|
+
end
|
844
|
+
|
845
|
+
# @return [String]
|
846
|
+
def inspect attributes = self.class.attribute_names.to_a
|
847
|
+
string = "#<#{self.class}"
|
848
|
+
string << "##@type" if instance_variable_defined? :@type
|
849
|
+
attributes += %w(errors) if errors.any?
|
850
|
+
string << " %s" % attributes.map { |k|
|
851
|
+
"#{k}: #{self.send(k).inspect}"
|
852
|
+
}.join(', ')
|
853
|
+
string << '>'
|
854
|
+
end
|
855
|
+
alias to_s inspect
|
856
|
+
|
857
|
+
protected
|
858
|
+
|
859
|
+
def path
|
860
|
+
@href or @uri or if persisted?
|
861
|
+
self.class.member_path to_param
|
862
|
+
else
|
863
|
+
self.class.collection_path
|
864
|
+
end
|
865
|
+
end
|
866
|
+
|
867
|
+
def invalid! attribute_path, error
|
868
|
+
if attribute_path.length == 1
|
869
|
+
(errors[attribute_path[0]] ||= []) << error
|
870
|
+
else
|
871
|
+
child, k, v = attribute_path.shift.scan(/[^\[\]=]+/)
|
872
|
+
if c = k ? self[child].find { |d| d[k] == v } : self[child]
|
873
|
+
c.invalid! attribute_path, error
|
874
|
+
(e = errors[child] ||= []) << 'is invalid' and e.uniq!
|
875
|
+
end
|
876
|
+
end
|
877
|
+
end
|
878
|
+
|
879
|
+
def clear_errors
|
880
|
+
errors.clear
|
881
|
+
self.class.associations.each_value do |associations|
|
882
|
+
associations.each do |association|
|
883
|
+
next unless respond_to? "#{association}=" # Clear writable only.
|
884
|
+
[*self[association]].each do |associated|
|
885
|
+
associated.clear_errors if associated.respond_to? :clear_errors
|
886
|
+
end
|
887
|
+
end
|
888
|
+
end
|
889
|
+
end
|
890
|
+
|
891
|
+
def copy_from other
|
892
|
+
other.instance_variables.each do |ivar|
|
893
|
+
instance_variable_set ivar, other.instance_variable_get(ivar)
|
894
|
+
end
|
895
|
+
end
|
896
|
+
|
897
|
+
def apply_errors exception
|
898
|
+
@response = exception.response
|
899
|
+
document = XML.new exception.response.body
|
900
|
+
document.each_element 'error' do |el|
|
901
|
+
attribute_path = el.attribute('field').value.split '.'
|
902
|
+
invalid! attribute_path[1, attribute_path.length], el.text
|
903
|
+
end
|
904
|
+
end
|
905
|
+
|
906
|
+
private
|
907
|
+
|
908
|
+
def fetch_association name, value
|
909
|
+
case value
|
910
|
+
when Array
|
911
|
+
value.map { |each| fetch_association Helper.singularize(name), each }
|
912
|
+
when Hash
|
913
|
+
Recurly.const_get(Helper.classify(name)).send :new, value
|
914
|
+
when Proc, Resource, Resource::Pager, nil
|
915
|
+
value
|
916
|
+
else
|
917
|
+
raise "unexpected association #{name.inspect}=#{value.inspect}"
|
918
|
+
end
|
919
|
+
end
|
920
|
+
|
921
|
+
def xml_keys
|
922
|
+
changed_attributes.keys.sort
|
923
|
+
end
|
924
|
+
end
|
925
|
+
end
|