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.
- checksums.yaml +7 -0
- data/Gemfile +8 -0
- data/Gemfile.lock +54 -0
- data/MIT-LICENSE +20 -0
- data/README.rdoc +35 -0
- data/Rakefile +34 -0
- data/lib/data/ca-certificates.crt +0 -0
- data/lib/paid/account.rb +4 -0
- data/lib/paid/api_operations/create.rb +17 -0
- data/lib/paid/api_operations/delete.rb +11 -0
- data/lib/paid/api_operations/list.rb +17 -0
- data/lib/paid/api_operations/update.rb +57 -0
- data/lib/paid/api_resource.rb +32 -0
- data/lib/paid/certificate_blacklist.rb +55 -0
- data/lib/paid/customer.rb +16 -0
- data/lib/paid/errors/api_connection_error.rb +4 -0
- data/lib/paid/errors/api_error.rb +4 -0
- data/lib/paid/errors/authentication_error.rb +4 -0
- data/lib/paid/errors/invalid_request_error.rb +10 -0
- data/lib/paid/errors/paid_error.rb +20 -0
- data/lib/paid/event.rb +5 -0
- data/lib/paid/invoice.rb +7 -0
- data/lib/paid/list_object.rb +37 -0
- data/lib/paid/paid_object.rb +187 -0
- data/lib/paid/singleton_api_resource.rb +20 -0
- data/lib/paid/transaction.rb +7 -0
- data/lib/paid/util.rb +127 -0
- data/lib/paid/version.rb +3 -0
- data/lib/paid.rb +280 -0
- data/lib/tasks/paid_tasks.rake +4 -0
- data/paid.gemspec +30 -0
- data/test/paid/account_test.rb +12 -0
- data/test/paid/api_resource_test.rb +361 -0
- data/test/paid/certificate_blacklist_test.rb +18 -0
- data/test/paid/customer_test.rb +35 -0
- data/test/paid/invoice_test.rb +26 -0
- data/test/paid/list_object_test.rb +16 -0
- data/test/paid/metadata_test.rb +104 -0
- data/test/paid/paid_object_test.rb +27 -0
- data/test/paid/transaction_test.rb +49 -0
- data/test/paid/util_test.rb +59 -0
- data/test/test_data.rb +106 -0
- data/test/test_helper.rb +41 -0
- 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
|
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
|
data/lib/paid/version.rb
ADDED
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
|
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
|