paid 0.0.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.
Files changed (44) hide show
  1. checksums.yaml +7 -0
  2. data/Gemfile +8 -0
  3. data/Gemfile.lock +54 -0
  4. data/MIT-LICENSE +20 -0
  5. data/README.rdoc +35 -0
  6. data/Rakefile +34 -0
  7. data/lib/data/ca-certificates.crt +0 -0
  8. data/lib/paid/account.rb +4 -0
  9. data/lib/paid/api_operations/create.rb +17 -0
  10. data/lib/paid/api_operations/delete.rb +11 -0
  11. data/lib/paid/api_operations/list.rb +17 -0
  12. data/lib/paid/api_operations/update.rb +57 -0
  13. data/lib/paid/api_resource.rb +32 -0
  14. data/lib/paid/certificate_blacklist.rb +55 -0
  15. data/lib/paid/customer.rb +16 -0
  16. data/lib/paid/errors/api_connection_error.rb +4 -0
  17. data/lib/paid/errors/api_error.rb +4 -0
  18. data/lib/paid/errors/authentication_error.rb +4 -0
  19. data/lib/paid/errors/invalid_request_error.rb +10 -0
  20. data/lib/paid/errors/paid_error.rb +20 -0
  21. data/lib/paid/event.rb +5 -0
  22. data/lib/paid/invoice.rb +7 -0
  23. data/lib/paid/list_object.rb +37 -0
  24. data/lib/paid/paid_object.rb +187 -0
  25. data/lib/paid/singleton_api_resource.rb +20 -0
  26. data/lib/paid/transaction.rb +7 -0
  27. data/lib/paid/util.rb +127 -0
  28. data/lib/paid/version.rb +3 -0
  29. data/lib/paid.rb +280 -0
  30. data/lib/tasks/paid_tasks.rake +4 -0
  31. data/paid.gemspec +30 -0
  32. data/test/paid/account_test.rb +12 -0
  33. data/test/paid/api_resource_test.rb +361 -0
  34. data/test/paid/certificate_blacklist_test.rb +18 -0
  35. data/test/paid/customer_test.rb +35 -0
  36. data/test/paid/invoice_test.rb +26 -0
  37. data/test/paid/list_object_test.rb +16 -0
  38. data/test/paid/metadata_test.rb +104 -0
  39. data/test/paid/paid_object_test.rb +27 -0
  40. data/test/paid/transaction_test.rb +49 -0
  41. data/test/paid/util_test.rb +59 -0
  42. data/test/test_data.rb +106 -0
  43. data/test/test_helper.rb +41 -0
  44. metadata +203 -0
@@ -0,0 +1,20 @@
1
+ module Paid
2
+ class SingletonAPIResource < APIResource
3
+ def self.url
4
+ if self == SingletonAPIResource
5
+ raise NotImplementedError.new('SingletonAPIResource is an abstract class. You should perform actions on its subclasses (Account, etc.)')
6
+ end
7
+ "/v0/#{CGI.escape(class_name.downcase)}"
8
+ end
9
+
10
+ def url
11
+ self.class.url
12
+ end
13
+
14
+ def self.retrieve(api_key=nil)
15
+ instance = self.new(nil, api_key)
16
+ instance.refresh
17
+ instance
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,7 @@
1
+ module Paid
2
+ class Transaction < APIResource
3
+ include Paid::APIOperations::List
4
+ include Paid::APIOperations::Create
5
+ include Paid::APIOperations::Update
6
+ end
7
+ end
data/lib/paid/util.rb ADDED
@@ -0,0 +1,127 @@
1
+ module Paid
2
+ module Util
3
+ def self.objects_to_ids(h)
4
+ case h
5
+ when APIResource
6
+ h.id
7
+ when Hash
8
+ res = {}
9
+ h.each { |k, v| res[k] = objects_to_ids(v) unless v.nil? }
10
+ res
11
+ when Array
12
+ h.map { |v| objects_to_ids(v) }
13
+ else
14
+ h
15
+ end
16
+ end
17
+
18
+ def self.object_classes
19
+ @object_classes ||= {
20
+ # data structures
21
+ 'list' => ListObject,
22
+
23
+ # business objects
24
+ 'transaction' => Transaction,
25
+ 'customer' => Customer,
26
+ 'event' => Event,
27
+ 'invoice' => Invoice
28
+ }
29
+ end
30
+
31
+ def self.convert_to_paid_object(resp, api_key)
32
+ case resp
33
+ when Array
34
+ resp.map { |i| convert_to_paid_object(i, api_key) }
35
+ when Hash
36
+ # Try converting to a known object class. If none available, fall back to generic PaidObject
37
+ object_classes.fetch(resp[:object], PaidObject).construct_from(resp, api_key)
38
+ else
39
+ resp
40
+ end
41
+ end
42
+
43
+ def self.file_readable(file)
44
+ # This is nominally equivalent to File.readable?, but that can
45
+ # report incorrect results on some more oddball filesystems
46
+ # (such as AFS)
47
+ begin
48
+ File.open(file) { |f| }
49
+ rescue
50
+ false
51
+ else
52
+ true
53
+ end
54
+ end
55
+
56
+ def self.symbolize_names(object)
57
+ case object
58
+ when Hash
59
+ new_hash = {}
60
+ object.each do |key, value|
61
+ key = (key.to_sym rescue key) || key
62
+ new_hash[key] = symbolize_names(value)
63
+ end
64
+ new_hash
65
+ when Array
66
+ object.map { |value| symbolize_names(value) }
67
+ else
68
+ object
69
+ end
70
+ end
71
+
72
+ def self.url_encode(key)
73
+ URI.escape(key.to_s, Regexp.new("[^#{URI::PATTERN::UNRESERVED}]"))
74
+ end
75
+
76
+ def self.flatten_params(params, parent_key=nil)
77
+ result = []
78
+ params.each do |key, value|
79
+ calculated_key = parent_key ? "#{parent_key}[#{url_encode(key)}]" : url_encode(key)
80
+ if value.is_a?(Hash)
81
+ result += flatten_params(value, calculated_key)
82
+ elsif value.is_a?(Array)
83
+ result += flatten_params_array(value, calculated_key)
84
+ else
85
+ result << [calculated_key, value]
86
+ end
87
+ end
88
+ result
89
+ end
90
+
91
+ def self.flatten_params_array(value, calculated_key)
92
+ result = []
93
+ value.each do |elem|
94
+ if elem.is_a?(Hash)
95
+ result += flatten_params(elem, calculated_key)
96
+ elsif elem.is_a?(Array)
97
+ result += flatten_params_array(elem, calculated_key)
98
+ else
99
+ result << ["#{calculated_key}[]", elem]
100
+ end
101
+ end
102
+ result
103
+ end
104
+
105
+ # The secondary opts argument can either be a string or hash
106
+ # Turn this value into an api_key and a set of headers
107
+ def self.parse_opts(opts)
108
+ case opts
109
+ when NilClass
110
+ return nil, {}
111
+ when String
112
+ return opts, {}
113
+ when Hash
114
+ headers = {}
115
+ if opts[:idempotency_key]
116
+ headers[:idempotency_key] = opts[:idempotency_key]
117
+ end
118
+ if opts[:paid_account]
119
+ headers[:paid_account] = opts[:paid_account]
120
+ end
121
+ return opts[:api_key], headers
122
+ else
123
+ raise TypeError.new("parse_opts expects a string or a hash")
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,3 @@
1
+ module Paid
2
+ VERSION = "0.0.1"
3
+ end
data/lib/paid.rb ADDED
@@ -0,0 +1,280 @@
1
+ # Paid Ruby bindings
2
+ # API spec at https://docs.paidapi.com
3
+ require 'cgi'
4
+ require 'set'
5
+ require 'openssl'
6
+ require 'rest_client'
7
+ require 'json'
8
+
9
+ # Version
10
+ require 'paid/version'
11
+
12
+ # API operations
13
+ require 'paid/api_operations/create'
14
+ require 'paid/api_operations/update'
15
+ require 'paid/api_operations/delete'
16
+ require 'paid/api_operations/list'
17
+
18
+ # Resources
19
+ require 'paid/util'
20
+ require 'paid/paid_object'
21
+ require 'paid/api_resource'
22
+ require 'paid/singleton_api_resource'
23
+ require 'paid/list_object'
24
+ require 'paid/account'
25
+ require 'paid/customer'
26
+ require 'paid/certificate_blacklist'
27
+ require 'paid/invoice'
28
+ require 'paid/transaction'
29
+ require 'paid/event'
30
+
31
+ # Errors
32
+ require 'paid/errors/paid_error'
33
+ require 'paid/errors/api_error'
34
+ require 'paid/errors/api_connection_error'
35
+ require 'paid/errors/invalid_request_error'
36
+ require 'paid/errors/authentication_error'
37
+
38
+ module Paid
39
+ DEFAULT_CA_BUNDLE_PATH = File.dirname(__FILE__) + '/data/ca-certificates.crt'
40
+ @api_base = 'https://api.paidapi.com'
41
+
42
+ @ssl_bundle_path = DEFAULT_CA_BUNDLE_PATH
43
+ @verify_ssl_certs = false
44
+ @CERTIFICATE_VERIFIED = false
45
+
46
+
47
+ class << self
48
+ attr_accessor :api_key, :api_base, :verify_ssl_certs, :api_version
49
+ end
50
+
51
+ def self.api_url(url='', api_base_url=nil)
52
+ (api_base_url || @api_base) + url
53
+ end
54
+
55
+ def self.request(method, url, api_key, params={}, headers={}, api_base_url=nil)
56
+ api_base_url = api_base_url || @api_base
57
+
58
+
59
+ unless api_key ||= @api_key
60
+ raise AuthenticationError.new('No API key provided. ' +
61
+ 'Set your API key using "Paid.api_key = <API-KEY>". ' +
62
+ 'You can generate API keys from the Paid web interface. ' +
63
+ 'See https://paidapi.com/api for details, or email hello@paidapi.com ' +
64
+ 'if you have any questions.')
65
+ end
66
+
67
+ if api_key =~ /\s/
68
+ raise AuthenticationError.new('Your API key is invalid, as it contains ' +
69
+ 'whitespace. (HINT: You can double-check your API key from the ' +
70
+ 'Paid web interface. See https://paidapi.com/api for details, or ' +
71
+ 'email hello@paidapi.com if you have any questions.)')
72
+ end
73
+
74
+
75
+ request_opts = { :verify_ssl => false }
76
+
77
+ if ssl_preflight_passed?
78
+ request_opts.update(:verify_ssl => OpenSSL::SSL::VERIFY_PEER,
79
+ :ssl_ca_file => @ssl_bundle_path)
80
+ end
81
+
82
+ if @verify_ssl_certs and !@CERTIFICATE_VERIFIED
83
+ @CERTIFICATE_VERIFIED = CertificateBlacklist.check_ssl_cert(api_base_url, @ssl_bundle_path)
84
+ end
85
+
86
+ params = Util.objects_to_ids(params)
87
+ url = api_url(url, api_base_url)
88
+
89
+ case method.to_s.downcase.to_sym
90
+ when :get, :head, :delete
91
+ # Make params into GET parameters
92
+ url += "#{URI.parse(url).query ? '&' : '?'}#{uri_encode(params)}" if params && params.any?
93
+ payload = nil
94
+ else
95
+ if headers[:content_type] && headers[:content_type] == "multipart/form-data"
96
+ payload = params
97
+ else
98
+ payload = uri_encode(params)
99
+ end
100
+ end
101
+
102
+ request_opts.update(:headers => request_headers(api_key).update(headers),
103
+ :method => method, :open_timeout => 30,
104
+ :payload => payload, :url => url, :timeout => 80)
105
+ begin
106
+ response = execute_request(request_opts)
107
+ rescue SocketError => e
108
+ handle_restclient_error(e, api_base_url)
109
+ rescue NoMethodError => e
110
+ # Work around RestClient bug
111
+ if e.message =~ /\WRequestFailed\W/
112
+ e = APIConnectionError.new('Unexpected HTTP response code')
113
+ handle_restclient_error(e, api_base_url)
114
+ else
115
+ raise
116
+ end
117
+ rescue RestClient::ExceptionWithResponse => e
118
+ if rcode = e.http_code and rbody = e.http_body
119
+ handle_api_error(rcode, rbody)
120
+ else
121
+ handle_restclient_error(e, api_base_url)
122
+ end
123
+ rescue RestClient::Exception, Errno::ECONNREFUSED => e
124
+ handle_restclient_error(e, api_base_url)
125
+ rescue Exception => e
126
+ end
127
+
128
+
129
+ [parse(response), api_key]
130
+ end
131
+
132
+ private
133
+
134
+ def self.ssl_preflight_passed?
135
+ if !verify_ssl_certs && !@no_verify
136
+ "Execute 'Paid.verify_ssl_certs = true' to enable verification."
137
+
138
+ @no_verify = true
139
+
140
+ elsif !Util.file_readable(@ssl_bundle_path) && !@no_bundle
141
+ "because #{@ssl_bundle_path} isn't readable"
142
+
143
+ @no_bundle = true
144
+ end
145
+
146
+ !(@no_verify || @no_bundle)
147
+ end
148
+
149
+ def self.user_agent
150
+ @uname ||= get_uname
151
+ lang_version = "#{RUBY_VERSION} p#{RUBY_PATCHLEVEL} (#{RUBY_RELEASE_DATE})"
152
+
153
+ {
154
+ :bindings_version => Paid::VERSION,
155
+ :lang => 'ruby',
156
+ :lang_version => lang_version,
157
+ :platform => RUBY_PLATFORM,
158
+ :publisher => 'paid',
159
+ :uname => @uname
160
+ }
161
+
162
+ end
163
+
164
+ def self.get_uname
165
+ `uname -a 2>/dev/null`.strip if RUBY_PLATFORM =~ /linux|darwin/i
166
+ rescue Errno::ENOMEM => ex # couldn't create subprocess
167
+ "uname lookup failed"
168
+ end
169
+
170
+ def self.uri_encode(params)
171
+ Util.flatten_params(params).
172
+ map { |k,v| "#{k}=#{Util.url_encode(v)}" }.join('&')
173
+ end
174
+
175
+ def self.request_headers(api_key)
176
+ headers = {
177
+ :user_agent => "Paid/v0 RubyBindings/#{Paid::VERSION}",
178
+ :authorization => "Bearer #{api_key}",
179
+ :content_type => 'application/x-www-form-urlencoded'
180
+ }
181
+
182
+ headers[:paid_version] = api_version if api_version
183
+
184
+ begin
185
+ headers.update(:x_paid_client_user_agent => JSON.generate(user_agent))
186
+ rescue => e
187
+ headers.update(:x_paid_client_raw_user_agent => user_agent.inspect,
188
+ :error => "#{e} (#{e.class})")
189
+ end
190
+ end
191
+
192
+ def self.execute_request(opts)
193
+ RestClient::Request.execute(opts)
194
+ end
195
+
196
+ def self.parse(response)
197
+ begin
198
+ # Would use :symbolize_names => true, but apparently there is
199
+ # some library out there that makes symbolize_names not work.
200
+ response = JSON.parse(response.body)
201
+ rescue JSON::ParserError
202
+ raise general_api_error(response.code, response.body)
203
+ end
204
+
205
+ Util.symbolize_names(response)
206
+ end
207
+
208
+ def self.general_api_error(rcode, rbody)
209
+ APIError.new("Invalid response object from API: #{rbody.inspect} " +
210
+ "(HTTP response code was #{rcode})", rcode, rbody)
211
+ end
212
+
213
+ def self.handle_api_error(rcode, rbody)
214
+ begin
215
+ error_obj = JSON.parse(rbody)
216
+ error_obj = Util.symbolize_names(error_obj)
217
+ error = error_obj[:error] or raise PaidError.new # escape from parsing
218
+
219
+ rescue JSON::ParserError, PaidError
220
+ raise general_api_error(rcode, rbody)
221
+ end
222
+
223
+ case rcode
224
+ when 400, 404
225
+ raise invalid_request_error error, rcode, rbody, error_obj
226
+ when 401
227
+ raise authentication_error error, rcode, rbody, error_obj
228
+ else
229
+ raise api_error error, rcode, rbody, error_obj
230
+ end
231
+
232
+ end
233
+
234
+ def self.invalid_request_error(error, rcode, rbody, error_obj)
235
+ InvalidRequestError.new(error[:message], error[:param], rcode,
236
+ rbody, error_obj)
237
+ end
238
+
239
+ def self.authentication_error(error, rcode, rbody, error_obj)
240
+ AuthenticationError.new(error[:message], rcode, rbody, error_obj)
241
+ end
242
+
243
+ def self.api_error(error, rcode, rbody, error_obj)
244
+ APIError.new(error[:message], rcode, rbody, error_obj)
245
+ end
246
+
247
+ def self.handle_restclient_error(e, api_base_url=nil)
248
+ api_base_url = @api_base unless api_base_url
249
+ connection_message = "Please check your internet connection and try again. " \
250
+ "If this problem persists, you should check Paid's service status at " \
251
+ "https://twitter.com/paidstatus, or let us know at hello@paidapi.com."
252
+
253
+ case e
254
+ when RestClient::RequestTimeout
255
+ message = "Could not connect to Paid (#{api_base_url}). #{connection_message}"
256
+
257
+ when RestClient::ServerBrokeConnection
258
+ message = "The connection to the server (#{api_base_url}) broke before the " \
259
+ "request completed. #{connection_message}"
260
+
261
+ when RestClient::SSLCertificateNotVerified
262
+ message = "Could not verify Paid's SSL certificate. " \
263
+ "Please make sure that your network is not intercepting certificates. " \
264
+ "(Try going to https://api.paidapi.com/v0 in your browser.) " \
265
+ "If this problem persists, let us know at hello@paidapi.com."
266
+
267
+ when SocketError
268
+ message = "Unexpected error communicating when trying to connect to Paid. " \
269
+ "You may be seeing this message because your DNS is not working. " \
270
+ "To check, try running 'host paidapi.com' from the command line."
271
+
272
+ else
273
+ message = "Unexpected error communicating with Paid. " \
274
+ "If this problem persists, let us know at hello@paidapi.com."
275
+
276
+ end
277
+
278
+ raise APIConnectionError.new(message + "\n\n(Network error: #{e.message})")
279
+ end
280
+ end
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :paid do
3
+ # # Task goes here
4
+ # end
data/paid.gemspec ADDED
@@ -0,0 +1,30 @@
1
+ $:.unshift(File.join(File.dirname(__FILE__), 'lib'))
2
+
3
+ # Maintain your gem's version:
4
+ require "paid/version"
5
+
6
+ # Describe your gem and declare its dependencies:
7
+ Gem::Specification.new do |s|
8
+ s.name = 'paid'
9
+ s.version = Paid::VERSION
10
+ s.authors = ['Ryan Jackson']
11
+ s.email = ['ryan@paidapi.com']
12
+ s.homepage = 'https://docs.paidapi.com'
13
+ s.summary = 'Ruby bindings for Paid API'
14
+ s.description = 'Paid is the programmatic way to manage payments. See https://paidapi.com for details.'
15
+ s.license = 'MIT'
16
+
17
+ s.add_dependency('rest-client', '~> 1.4')
18
+ s.add_dependency('mime-types', '>= 1.25', '< 3.0')
19
+ s.add_dependency('json', '~> 1.8.1')
20
+
21
+ s.add_development_dependency('mocha', '~> 0.13.2')
22
+ s.add_development_dependency('shoulda', '~> 3.4.0')
23
+ s.add_development_dependency('test-unit')
24
+ s.add_development_dependency('rake')
25
+
26
+ s.files = `git ls-files`.split("\n")
27
+ s.test_files = `git ls-files -- test/*`.split("\n")
28
+ s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
29
+ s.require_paths = ['lib']
30
+ end
@@ -0,0 +1,12 @@
1
+ require File.expand_path('../../test_helper', __FILE__)
2
+
3
+ module Paid
4
+ class AccountTest < Test::Unit::TestCase
5
+ should "account should be retrievable" do
6
+ resp = {:email => "test+bindings@paidapi.com"}
7
+ @mock.expects(:get).once.returns(test_response(resp))
8
+ a = Paid::Account.retrieve
9
+ assert_equal "test+bindings@paidapi.com", a.email
10
+ end
11
+ end
12
+ end