increase 0.1.0 → 0.1.2

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d9243d27fe1bd52cec9e7de6d0f9437258c21fd6d3ca2b87d3aecaa72cb94b5f
4
- data.tar.gz: a3074a286a31dcb1803edf08a06b1a8fe22df790c09adeeb8ab9546dc993d5c6
3
+ metadata.gz: c94e77f42a4d8c725f5518af02301564c6cbff71f90015063313e983aeac5124
4
+ data.tar.gz: 1d5ec565ec368dd713b47f18a41d5eb1363e624ef6792c05b87952f9fe368695
5
5
  SHA512:
6
- metadata.gz: 68e401c32b3ccc3a4dcc782b9d46cc2cce6fd7cee70a04581f78fbdb9be8a278bde79feaa1b58b9ebe54e00bf80e0361564eebc4370e538f96029106664041b0
7
- data.tar.gz: ef900afd66bea27938146e787a7e14679d230d1a05ccee069699277101d4af659a15de0eb091719a1040f9c50f754f7782a1435a83f6611c81104611f06495f8
6
+ metadata.gz: 4dd0a71c6eac5742ca4f63237eb8bbccffc8eefe625ce63e345b97cb4431b3cba04744e9acd426665757ab0d1240637a4fb02311cda84cbe7dc8fa1f2657c7bf
7
+ data.tar.gz: cba66503cbbeaf4fd7bdc6541899d033126a2efbc20a7ce70cb8b14f42a78a30628b3ea40b3da1b7b176e4d8219291d800846061f3004c70c2d0e96f4b548a64
data/Gemfile CHANGED
@@ -4,9 +4,3 @@ source "https://rubygems.org"
4
4
 
5
5
  # Specify your gem's dependencies in increase.gemspec
6
6
  gemspec
7
-
8
- gem "rake", "~> 13.0"
9
-
10
- gem "rspec", "~> 3.0"
11
-
12
- gem "standard", "~> 1.3"
data/README.md CHANGED
@@ -1,35 +1,200 @@
1
1
  # Increase
2
2
 
3
- TODO: Delete this and the text below, and describe your gem
4
-
5
- Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/increase`. To experiment with that code, run `bin/console` for an interactive prompt.
3
+ A Ruby API client for [Increase](https://increase.com/), a platform for Bare-Metal Banking APIs!
6
4
 
7
5
  ## Installation
8
6
 
9
- TODO: Replace `UPDATE_WITH_YOUR_GEM_NAME_PRIOR_TO_RELEASE_TO_RUBYGEMS_ORG` with your gem name right after releasing it to RubyGems.org. Please do not do it earlier due to security reasons. Alternatively, replace this section with instructions to install your gem from git if you don't plan to release to RubyGems.org.
10
-
11
7
  Install the gem and add to the application's Gemfile by executing:
12
8
 
13
- $ bundle add UPDATE_WITH_YOUR_GEM_NAME_PRIOR_TO_RELEASE_TO_RUBYGEMS_ORG
9
+ ```sh
10
+ $ bundle add increase
11
+ ```
14
12
 
15
13
  If bundler is not being used to manage dependencies, install the gem by executing:
16
14
 
17
- $ gem install UPDATE_WITH_YOUR_GEM_NAME_PRIOR_TO_RELEASE_TO_RUBYGEMS_ORG
15
+ ```sh
16
+ $ gem install increase
17
+ ```
18
18
 
19
19
  ## Usage
20
20
 
21
- TODO: Write usage instructions here
21
+ ```ruby
22
+ require 'increase'
23
+
24
+ # Grab your API key from https://dashboard.increase.com/developers/api_keys
25
+ Increase.api_key = 'my_api_key'
26
+ Increase.base_url = 'https://api.increase.com'
27
+
28
+ # List transactions
29
+ Increase::Transactions.list
30
+
31
+ # Retrieve a transaction
32
+ Increase::Transactions.retrieve('transaction_1234abcd')
33
+
34
+ # Create an ACH Transfer
35
+ Increase::AchTransfers.create(
36
+ account_id: 'account_1234abcd',
37
+ amount: 100_00, # 10,000 cents ($100 dollars)
38
+ routing_number: '123456789',
39
+ account_number: '9876543210',
40
+ statement_descriptor: 'broke the bank for some retail therapy'
41
+ )
42
+ ```
43
+
44
+ ### Per-request Configuration
45
+
46
+ By default, the client will use the global API key and configurations. However, you can define a custom client to be
47
+ used for per-request configuration.
48
+
49
+ For example, you may want to have access to production and sandbox data at the same.
50
+
51
+ ```ruby
52
+ sandbox = Increase::Client.new(
53
+ api_key: 'time_is_money',
54
+ base_url: 'https://sandbox.increase.com'
55
+ )
56
+
57
+ # This request will use the `sandbox` client and its configurations
58
+ Increase::Transactions.with_config(sandbox).list
59
+ # => [{some sandbox transactions here}, {transaction}, {transaction}]
60
+
61
+ # This request will still use the global configurations (where the API key is a production key)
62
+ Increase::Transactions.list
63
+ # => [{some production transactions here}, {transaction}, {transaction}]
64
+ ```
65
+
66
+ See the [Configuration](#configuration) section for more information on the available configurations.
67
+
68
+ ### Pagination
69
+
70
+ When listing resources (e.g. transactions), **Increase** limits the number of results per page to 100. Luckily, the
71
+ client will automatically paginate through all the results for you!
72
+
73
+ ```ruby
74
+ Increase::Transactions.list(limit: :all) do |transactions|
75
+ # This block will be called once for each page of results
76
+ puts "I got #{transactions.count} transactions!"
77
+ end
78
+
79
+ # Or, if you'd like a gargantuan array of all the transactions
80
+ Increase::Transactions.list(limit: :all)
81
+ Increase::Transactions.list(limit: 2_000)
82
+ ```
83
+
84
+ Watch out for the rate limit!
85
+
86
+ ### Error Handling
87
+
88
+ Whenever you make an oopsies, the client will raise an error! Errors originating from the API will be a subclass
89
+ of `Increase::ApiError`.
90
+
91
+ ```ruby
92
+
93
+ begin
94
+ Increase::Transactions.retrieve('transaction_1234abcd')
95
+ rescue Increase::ApiError => e
96
+ puts e.message # "[404: object_not_found_error] Could not find the specified object. No resource of type ..."
97
+ puts e.title # "Could not find the specified object."
98
+ puts e.detail # "No resource of type transaction was found with ID transaction_1234abcd."
99
+ puts e.status # 404
100
+
101
+ puts e.response # This contains the full response from the API, including headers! (its a Faraday::Env object)
102
+
103
+ puts e.class # Increase::ObjectNotFoundError (it's a subclass of Increase::ApiError!)
104
+ end
105
+ ```
106
+
107
+ To disable this behavior, set `Increase.raise_api_errors = false`. Errors will then be returned as a normal response.
108
+
109
+ ```ruby
110
+ Increase.raise_api_errors = false # Default: true
111
+
112
+ Increase::Transactions.retrieve('transaction_1234abcd')
113
+ # => {"status"=>404, "type"=>"object_not_found_error", ... }
114
+ ```
115
+
116
+ ### Configuration
117
+
118
+ | Name | Description | Default |
119
+ |----------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|------------------------------|
120
+ | **api_key** | Your Increase API Key. Grab it from https://dashboard.increase.com/developers/api_keys | `nil` |
121
+ | **base_url** | The base URL for Increase's API. You can use `:production` (https://api.increase.com), `:sandbox` (https://sandbox.increase.com), or set an actual URL | `"https://api.increase.com"` |
122
+ | **raise_api_errors** | Whether to raise an error when the API returns a non-2XX status. Learn more about Increase's errors [here](https://increase.com/documentation/api#errors). See error classes [here](https://github.com/garyhtou/increase-ruby/blob/main/lib/increase/errors.rb) | `true` |
123
+
124
+ There are multiple syntaxes for configuring the client. Choose your favorite!
125
+
126
+ ```ruby
127
+ # Set the configurations directly
128
+ Increase.api_key = 'terabytes_of_cash' # Default: nil (you'll need one tho!)
129
+ Increase.base_url = :production # Default: :production
130
+ Increase.raise_api_errors = true # Default: true
131
+
132
+ # Or, you can use a block
133
+ Increase.configure do |config|
134
+ config.api_key = 'digital_dough'
135
+ config.base_url = :sandbox
136
+ config.raise_api_errors = false
137
+ end
138
+
139
+ # Or, you can pass in a hash
140
+ Increase.configure(api_key: 'just_my_two_cents')
141
+ ```
142
+
143
+ ### Idempotency
144
+
145
+ **Increase** supports [idempotent requests](https://increase.com/documentation/api#idempotency) to allow for safely
146
+ retrying requests without accidentally performing the same operation twice.
147
+
148
+ ```ruby
149
+ card = Increase::Cards.create(
150
+ {
151
+ # Card parameters
152
+ account_id: 'account_1234abcd',
153
+ description: 'My Chipotle card'
154
+ },
155
+ {
156
+ # Request headers
157
+ 'Idempotency-Key': 'use a V4 UUID here'
158
+ }
159
+ )
160
+ # => {"id"=>"card_1234abcd", "type"=>"card", ... }
161
+
162
+ idempotent_replayed = card.response.headers['Idempotent-Replayed']
163
+ # => "false"
164
+ ```
165
+
166
+ Reusing the key in subsequent requests will return the same response code and body as the original request along with an
167
+ additional HTTP header (Idempotent-Replayed: true). This applies to both success and error responses. In situations
168
+ where your request results in a validation error, you'll need to update your request and retry with a new idempotency
169
+ key.
22
170
 
23
171
  ## Development
24
172
 
25
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
173
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can
174
+ also run `bin/console` for an interactive prompt that will allow you to experiment.
26
175
 
27
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
176
+ You can also run `INCREASE_API_KEY=my_key_here INCREASE_BASE_URL=https://sandbox.increase.com bin/console` to run the
177
+ console with your Increase sandbox API key pre-filled.
178
+
179
+ To install this gem onto your local machine, run `bundle exec rake install`.
180
+
181
+ To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will
182
+ create a git tag for the version, push git commits and the created tag, and push the `.gem` file
183
+ to [rubygems.org](https://rubygems.org).
184
+
185
+ Alternatively, use [`gem-release`](https://github.com/svenfuchs/gem-release) and
186
+ run `gem bump --version patch|minor|major`. Then release the gem by running `bundle exec rake release`.
28
187
 
29
188
  ## Contributing
30
189
 
31
- Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/increase.
190
+ Bug reports and pull requests are welcome on GitHub at https://github.com/garyhtou/increase.
32
191
 
33
192
  ## License
34
193
 
35
- The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
194
+ The gem is available as open source under the terms of
195
+ the [MIT License](https://github.com/garyhtou/increase-ruby/blob/main/LICENSE.txt).
196
+
197
+ ---
198
+
199
+ Please note that this is not an official library written by **Increase**. Its written and maintained
200
+ by [Gary Tou](https://garytou.com/) who just uses Increase at work!
@@ -0,0 +1,11 @@
1
+ require "faraday"
2
+
3
+ module FaradayMiddleware
4
+ class RaiseIncreaseApiError < Faraday::Middleware
5
+ def on_complete(env)
6
+ return if env[:status] < 400
7
+
8
+ raise Increase::ApiError.from_response(env)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "increase/configuration"
4
+
5
+ require "faraday"
6
+ require "faraday/follow_redirects"
7
+
8
+ # Custom Faraday Middleware to handle raising errors
9
+ require "faraday/raise_increase_api_error"
10
+
11
+ module Increase
12
+ class Client
13
+ extend Forwardable
14
+
15
+ attr_accessor :configuration
16
+ def_delegators :configuration, :configure
17
+
18
+ def_delegators :configuration, :base_url, :base_url=
19
+ def_delegators :configuration, :api_key, :api_key=
20
+ def_delegators :configuration, :raise_api_errors, :raise_api_errors=
21
+
22
+ def initialize(config = nil)
23
+ @configuration = config.is_a?(Configuration) ? config : Configuration.new(config)
24
+ end
25
+
26
+ def connection
27
+ Faraday.new(
28
+ url: @configuration.base_url,
29
+ headers: {
30
+ Authorization: "Bearer #{@configuration.api_key}",
31
+ "User-Agent": "Increase Ruby Gem v#{Increase::VERSION} (https://github.com/garyhtou/increase-ruby)"
32
+ }
33
+ ) do |f|
34
+ f.request :json
35
+
36
+ if @configuration.raise_api_errors
37
+ # This custom middleware for raising Increase API errors must be
38
+ # located before the JSON response middleware.
39
+ f.use FaradayMiddleware::RaiseIncreaseApiError
40
+ end
41
+
42
+ f.response :json
43
+ f.response :follow_redirects
44
+ f.adapter Faraday.default_adapter
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Increase
4
+ class Configuration
5
+ attr_reader :base_url
6
+ attr_accessor :api_key
7
+ attr_accessor :raise_api_errors
8
+ # TODO: support Faraday config
9
+
10
+ def initialize(config = nil)
11
+ reset
12
+ configure(config) if config
13
+ end
14
+
15
+ def reset
16
+ @base_url = ENV["INCREASE_BASE_URL"] || Increase::PRODUCTION_URL
17
+ @api_key = ENV["INCREASE_API_KEY"]
18
+ @raise_api_errors = true
19
+ end
20
+
21
+ def configure(config = nil)
22
+ if config.is_a?(Hash)
23
+ config.each do |key, value|
24
+ unless respond_to?("#{key}=")
25
+ raise Error, "Invalid configuration key: #{key}"
26
+ end
27
+ public_send("#{key}=", value)
28
+ end
29
+ end
30
+
31
+ if block_given?
32
+ yield self
33
+ end
34
+
35
+ self
36
+ end
37
+
38
+ def base_url=(url)
39
+ url = PRODUCTION_URL if url == :production
40
+ url = SANDBOX_URL if [:sandbox, :development].include?(url)
41
+
42
+ # Validate url
43
+ unless url&.match?(URI::DEFAULT_PARSER.make_regexp(%w[http https]))
44
+ raise ArgumentError, "Invalid url: #{url}"
45
+ end
46
+
47
+ @base_url = url
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Increase
4
+ class Error < StandardError; end
5
+
6
+ class ApiError < Error
7
+ attr_reader :response
8
+
9
+ attr_reader :detail
10
+ attr_reader :status
11
+ attr_reader :title
12
+ attr_reader :type
13
+
14
+ def initialize(message, response: nil, detail: nil, status: nil, title: nil, type: nil)
15
+ @response = response
16
+
17
+ @detail = detail || response.body["detail"]
18
+ @status = status || response.body["status"]
19
+ @title = title || response.body["title"]
20
+ @type = type || response.body["type"]
21
+
22
+ super(message)
23
+ end
24
+
25
+ class << self
26
+ def from_response(response)
27
+ type = response.body["type"]
28
+ klass = ERROR_TYPES[type]
29
+
30
+ # Fallback in case of really bad 5xx error
31
+ klass ||= InternalServerError if (500..599).cover?(response.status)
32
+
33
+ # Handle case of an unknown error
34
+ klass ||= ApiError
35
+
36
+ code = [response.body["status"] || response.status, type].compact.join(": ") || "Error"
37
+ message = [response.body["title"], response.body["detail"]].compact.join(" ") || "Increase API Error"
38
+
39
+ klass.new("[#{code}] #{message}", response: response)
40
+ end
41
+ end
42
+ end
43
+
44
+ class ApiMethodNotFoundError < ApiError; end
45
+
46
+ class EnvironmentMismatchError < ApiError; end
47
+
48
+ class IdempotencyConflictError < ApiError; end
49
+
50
+ class IdempotencyUnprocessableError < ApiError; end
51
+
52
+ class InsufficientPermissionsError < ApiError; end
53
+
54
+ class InternalServerError < ApiError; end
55
+
56
+ class InvalidApiKeyError < ApiError; end
57
+
58
+ class InvalidOperationError < ApiError; end
59
+
60
+ class InvalidParametersError < ApiError; end
61
+
62
+ class MalformedRequestError < ApiError; end
63
+
64
+ class ObjectNotFoundError < ApiError; end
65
+
66
+ class PrivateFeatureError < ApiError; end
67
+
68
+ class RateLimitedError < ApiError; end
69
+
70
+ ERROR_TYPES = {
71
+ "api_method_not_found_error" => ApiMethodNotFoundError,
72
+ "environment_mismatch_error" => EnvironmentMismatchError,
73
+ "idempotency_conflict_error" => IdempotencyConflictError,
74
+ "idempotency_unprocessable_error" => IdempotencyUnprocessableError,
75
+ "insufficient_permissions_error" => InsufficientPermissionsError,
76
+ "internal_server_error" => InternalServerError,
77
+ "invalid_api_key_error" => InvalidApiKeyError,
78
+ "invalid_operation_error" => InvalidOperationError,
79
+ "invalid_parameters_error" => InvalidParametersError,
80
+ "malformed_request_error" => MalformedRequestError,
81
+ "object_not_found_error" => ObjectNotFoundError,
82
+ "private_feature_error" => PrivateFeatureError,
83
+ "rate_limited_error" => RateLimitedError
84
+ }
85
+
86
+ # WebhookSignatureVerificationError is raised when a received webhook's
87
+ # signature is invalid.
88
+ class WebhookSignatureVerificationError < Error
89
+ attr_reader :signature_header
90
+ attr_reader :payload
91
+
92
+ def initialize(message = "Increase webhook signature verification failed", signature_header: nil, payload: nil)
93
+ @signature_header = signature_header
94
+ @payload = payload
95
+
96
+ super(message)
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,239 @@
1
+ require "increase/response_hash"
2
+
3
+ module Increase
4
+ class Resource
5
+ def initialize(client: nil)
6
+ if instance_of?(Resource)
7
+ raise NotImplementedError, "Resource is an abstract class. You should perform actions on its subclasses (Accounts, Transactions, Card, etc.)"
8
+ end
9
+ @client = client || Increase.default_client
10
+ end
11
+
12
+ def self.with_config(config)
13
+ if config.is_a?(Client)
14
+ new(client: config)
15
+ else
16
+ new(client: Client.new(config))
17
+ end
18
+ end
19
+
20
+ def self.resource_url
21
+ "/#{resource_name.downcase.tr(" ", "_")}"
22
+ end
23
+
24
+ def self.resource_name
25
+ if self == Resource
26
+ raise NotImplementedError, "Resource is an abstract class. You should perform actions on its subclasses (Accounts, Transactions, Card, etc.)"
27
+ end
28
+
29
+ name.split("::").last.gsub(/[A-Z]/, ' \0').strip
30
+ end
31
+
32
+ def self.endpoint(name, http_method, to: :same_as_name, with: nil)
33
+ to = nil if to == :root
34
+ to = name.to_s if to == :same_as_name
35
+ to = [to].flatten.compact
36
+ with = [with].flatten.compact
37
+
38
+ raise Error, "Invalid `to`. Max of 2 elements allowed" if to.size > 2
39
+ raise Error, "Only one `to` allowed when not `with` an `id`" if to.size > 1 && !with.include?(:id)
40
+
41
+ request_method = :request
42
+ request_method = :paginated_request if with.include?(:pagination)
43
+
44
+ method =
45
+ if with.include?(:id)
46
+ # Method signature with a required `id` param
47
+ ->(id, params = nil, headers = nil, &block) do
48
+ url = self.class.resource_url
49
+ url +=
50
+ if to.size == 2
51
+ "/#{to[0]}/#{id}/#{to[1]}"
52
+ elsif to.size == 1
53
+ # Default to id first
54
+ "/#{id}/#{to[0]}"
55
+ else
56
+ "/#{id}"
57
+ end
58
+
59
+ send(request_method, http_method, url, params, headers, &block)
60
+ end
61
+ else
62
+ # Method signature without a required `id` param
63
+ ->(params = nil, headers = nil, &block) do
64
+ url = self.class.resource_url
65
+ url += "/#{to[0]}" if to.size == 1
66
+
67
+ send(request_method, http_method, url, params, headers, &block)
68
+ end
69
+ end
70
+
71
+ # Define instance method
72
+ define_method(name, &method)
73
+
74
+ # Define class method (uses default config by calling `new`)
75
+ define_singleton_method(name) do |*args, &block|
76
+ new.send(name, *args, &block)
77
+ end
78
+ end
79
+
80
+ private_class_method :endpoint
81
+
82
+ class << self
83
+ private
84
+
85
+ # These methods here are shortcuts for the `endpoint` method. They define
86
+ # commonly used endpoints. For example, nearly all resources have a `list`
87
+ # endpoint which is a `GET` request to the resource's root URL.
88
+
89
+ def create
90
+ endpoint :create, :post, to: :root
91
+ end
92
+
93
+ def list
94
+ endpoint :list, :get, to: :root, with: :pagination
95
+ end
96
+
97
+ def update
98
+ endpoint :update, :patch, to: :root, with: :id
99
+ end
100
+
101
+ def retrieve
102
+ endpoint :retrieve, :get, to: :root, with: :id
103
+ end
104
+ end
105
+
106
+ # def self.endpoint_action(method, http_method)
107
+ # define_singleton_method(method) do |*args, &block|
108
+ # new.send(:action, method, http_method, *args, &block)
109
+ # end
110
+ #
111
+ # define_method(method) do |*args, &block|
112
+ # new.send(:action, method, http_method, *args, &block)
113
+ # end
114
+ # end
115
+ #
116
+ # private_class_method :endpoint_action
117
+ #
118
+ # private
119
+ #
120
+ # def create(params = nil, headers = nil)
121
+ # request(:post, self.class.resource_url, params, headers)
122
+ # end
123
+ #
124
+ # def list(params = nil, headers = nil, &block)
125
+ # results = []
126
+ # count = 0
127
+ # limit = params&.[](:limit) || params&.[]("limit")
128
+ # if limit == :all || limit&.>(100)
129
+ # params&.delete(:limit)
130
+ # params&.delete("limit")
131
+ # end
132
+ #
133
+ # loop do
134
+ # res = request(:get, self.class.resource_url, params, headers)
135
+ # data = res["data"]
136
+ # count += data.size
137
+ # if ![nil, :all].include?(limit) && count >= limit
138
+ # data = data[0..(limit - (count - data.size) - 1)]
139
+ # end
140
+ #
141
+ # if block
142
+ # block.call(data)
143
+ # else
144
+ # results += data
145
+ # end
146
+ #
147
+ # if limit.nil? || (limit != :all && count >= limit) || res["next_cursor"].nil?
148
+ # if block
149
+ # break
150
+ # else
151
+ # return results
152
+ # end
153
+ # end
154
+ #
155
+ # params = (params || {}).merge({ cursor: res["next_cursor"] })
156
+ # end
157
+ # end
158
+ #
159
+ # def update(id, params = nil, headers = nil)
160
+ # raise Error, "id must be a string" unless id.is_a?(String)
161
+ # path = "#{self.class.resource_url}/#{id}"
162
+ # request(:patch, path, params, headers)
163
+ # end
164
+ #
165
+ # def retrieve(id, params = nil, headers = nil)
166
+ # raise Error, "id must be a string" unless id.is_a?(String)
167
+ # path = "#{self.class.resource_url}/#{id}"
168
+ # request(:get, path, params, headers)
169
+ # end
170
+ #
171
+ # # Such as for "/accounts/{account_id}/close"
172
+ # # "close" is the action.
173
+ # def action(action, http_method, id, params = nil, headers = nil)
174
+ # raise Error, "id must be a string" unless id.is_a?(String)
175
+ # path = "#{self.class.resource_url}/#{id}/#{action}"
176
+ # request(http_method, path, params, headers)
177
+ # end
178
+
179
+ private
180
+
181
+ def request(method, path, params = nil, headers = nil, &block)
182
+ if block
183
+ # Assume the caller wants to automatically paginate
184
+ return paginated_request(method, path, params, headers, &block)
185
+ end
186
+
187
+ if method == :post
188
+ headers = {"Content-Type" => "application/json"}.merge!(headers || {})
189
+ end
190
+
191
+ response = @client.connection.send(method, path, params, headers)
192
+ ResponseHash.new(response.body, response: response)
193
+ end
194
+
195
+ def paginated_request(method, path, params = nil, headers = nil, &block)
196
+ results = []
197
+ count = 0
198
+ limit = params&.[](:limit) || params&.[]("limit")
199
+ if limit == :all || limit&.>(100)
200
+ params&.delete(:limit)
201
+ params&.delete("limit")
202
+ end
203
+
204
+ loop do
205
+ res = request(method, path, params, headers)
206
+ data = res["data"]
207
+
208
+ # Handle case where endpoint doesn't actually support pagination.
209
+ # For example, someone passes a block to `Account.create`
210
+ if data.nil?
211
+ # In this case, we'll both yield and return the response
212
+ yield res if block
213
+ return res
214
+ end
215
+
216
+ count += data.size
217
+ if ![nil, :all].include?(limit) && count >= limit
218
+ data = data[0..(limit - (count - data.size) - 1)]
219
+ end
220
+
221
+ if block
222
+ block.call(data)
223
+ else
224
+ results += data
225
+ end
226
+
227
+ if limit.nil? || (limit != :all && count >= limit) || res["next_cursor"].nil?
228
+ if block
229
+ break
230
+ else
231
+ return results
232
+ end
233
+ end
234
+
235
+ params = (params || {}).merge({cursor: res["next_cursor"]})
236
+ end
237
+ end
238
+ end
239
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "increase/resource"
4
+
5
+ module Increase
6
+ class AccountNumbers < Resource
7
+ create
8
+ list
9
+ update
10
+ retrieve
11
+ end
12
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "increase/resource"
4
+
5
+ module Increase
6
+ class AccountTransfers < Resource
7
+ create
8
+ list
9
+ retrieve
10
+ endpoint :approve, :post, with: :id
11
+ endpoint :cancel, :post, with: :id
12
+ end
13
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "increase/resource"
4
+
5
+ module Increase
6
+ class Accounts < Resource
7
+ create
8
+ list
9
+ update
10
+ retrieve
11
+ endpoint :close, :post, with: :id
12
+ end
13
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "increase/resource"
4
+
5
+ module Increase
6
+ class AchTransfers < Resource
7
+ create
8
+ list
9
+ retrieve
10
+ endpoint :approve, :post, with: :id
11
+ endpoint :cancel, :post, with: :id
12
+ end
13
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "increase/resource"
4
+
5
+ module Increase
6
+ class Cards < Resource
7
+ create
8
+ list
9
+ endpoint :details, :get, with: :id
10
+ update
11
+ retrieve
12
+ end
13
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "increase/resource"
4
+
5
+ module Increase
6
+ class Events < Resource
7
+ list
8
+ retrieve
9
+ end
10
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "increase/resource"
4
+
5
+ module Increase
6
+ class PendingTransactions < Resource
7
+ list
8
+ retrieve
9
+ end
10
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "increase/resource"
4
+
5
+ module Increase
6
+ class Transactions < Resource
7
+ list
8
+ retrieve
9
+ end
10
+ end
@@ -0,0 +1,4 @@
1
+ # Require all files in the lib/increase/resources directory
2
+ Dir[File.expand_path("resources/*.rb", __dir__)].sort.each do |file|
3
+ require file
4
+ end
@@ -0,0 +1,15 @@
1
+ module Increase
2
+ class ResponseHash < Hash
3
+ attr_reader :response
4
+
5
+ def initialize(hash, response: nil)
6
+ @response = response
7
+ merge!(hash)
8
+ end
9
+
10
+ # https://increase.com/documentation/api#idempotency
11
+ def idempotent_replayed
12
+ response.headers["Idempotent-Replayed"]
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,15 @@
1
+ module Increase
2
+ module Util
3
+ # Constant time string comparison to prevent timing attacks
4
+ # Code borrowed from `stripe-ruby`, which was borrowed from ActiveSupport
5
+ def self.secure_compare(a, b)
6
+ return false unless a.bytesize == b.bytesize
7
+
8
+ l = a.unpack "C#{a.bytesize}"
9
+
10
+ res = 0
11
+ b.each_byte { |byte| res |= byte ^ l.shift }
12
+ res.zero?
13
+ end
14
+ end
15
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Increase
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.2"
5
5
  end
@@ -0,0 +1,56 @@
1
+ require "increase/util"
2
+ require "increase/errors"
3
+
4
+ module Increase
5
+ # Keeping this module singular in case Increase adds a `webhooks` resource
6
+ module Webhook
7
+ module Signature
8
+ DEFAULT_TIME_TOLERANCE = 300 # 300 seconds (5 minutes)
9
+ DEFAULT_SCHEME = "v1"
10
+
11
+ def self.verify?(payload:, signature_header:, secret:, scheme: DEFAULT_SCHEME, time_tolerance: DEFAULT_TIME_TOLERANCE)
12
+ # Helper for raising errors with additional metadata
13
+ sig_error = ->(msg) do
14
+ WebhookSignatureVerificationError.new(msg, signature_header: signature_header, payload: payload)
15
+ end
16
+
17
+ # Parse header
18
+ sig_values = signature_header.split(",").map { |pair| pair.split("=") }.to_h
19
+
20
+ # Extract values
21
+ t = sig_values["t"] # Should be a string (ISO-8601 timestamp)
22
+ sig = sig_values[scheme]
23
+ raise sig_error.call("No timestamp found in signature header") if t.nil?
24
+ raise sig_error.call("No signature found with scheme #{scheme} in signature header") if sig.nil?
25
+
26
+ # Check signature
27
+ expected_sig = compute_signature(timestamp: t, payload: payload, secret: secret)
28
+ matches = Util.secure_compare(expected_sig, sig)
29
+ raise sig_error.call("Signature mismatch") unless matches
30
+
31
+ # Check timestamp tolerance to prevent timing attacks
32
+ if time_tolerance > 0
33
+ begin
34
+ timestamp = DateTime.parse(t)
35
+ now = DateTime.now
36
+ diff = (now - timestamp) * 24 * 60 * 60 # in seconds
37
+
38
+ # Don't allow timestamps in the future
39
+ if diff > time_tolerance || diff < 0
40
+ raise sig_error.call("Timestamp outside of the tolerance zone")
41
+ end
42
+ rescue Date::Error
43
+ raise sig_error.call("Invalid timestamp in signature header: #{t}")
44
+ end
45
+ end
46
+
47
+ true
48
+ end
49
+
50
+ def self.compute_signature(timestamp:, payload:, secret:)
51
+ signed_payload = timestamp.to_s + "." + payload.to_s
52
+ OpenSSL::HMAC.hexdigest("SHA256", secret, signed_payload)
53
+ end
54
+ end
55
+ end
56
+ end
data/lib/increase.rb CHANGED
@@ -1,8 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "increase/version"
3
+ require "increase/version"
4
+ require "increase/client"
5
+ require "increase/configuration"
6
+ require "increase/errors"
7
+ require "increase/resources"
8
+ require "increase/webhook/signature"
4
9
 
5
10
  module Increase
6
- class Error < StandardError; end
7
- # Your code goes here...
11
+ PRODUCTION_URL = "https://api.increase.com"
12
+ SANDBOX_URL = "https://sandbox.increase.com"
13
+
14
+ @default_client = Client.new
15
+
16
+ class << self
17
+ extend Forwardable
18
+
19
+ attr_accessor :default_client
20
+ def_delegators :default_client, :configure
21
+
22
+ def_delegators :default_client, :base_url, :base_url=
23
+ def_delegators :default_client, :api_key, :api_key=
24
+ def_delegators :default_client, :raise_api_errors, :raise_api_errors=
25
+ end
8
26
  end
metadata CHANGED
@@ -1,15 +1,113 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: increase
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gary Tou
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2023-03-14 00:00:00.000000000 Z
12
- dependencies: []
11
+ date: 2023-03-20 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: faraday
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2.7'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '2.7'
27
+ - !ruby/object:Gem::Dependency
28
+ name: faraday-follow_redirects
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - '='
32
+ - !ruby/object:Gem::Version
33
+ version: 0.3.0
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - '='
39
+ - !ruby/object:Gem::Version
40
+ version: 0.3.0
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '13.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '13.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rspec
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '3.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '3.0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: standard
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '1.3'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '1.3'
83
+ - !ruby/object:Gem::Dependency
84
+ name: webmock
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '3.0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '3.0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: pry
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '0.13'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '0.13'
13
111
  description: Ruby API client for Increase, a platform for Bare-Metal Banking APIs
14
112
  email:
15
113
  - gary@garytou.com
@@ -23,8 +121,25 @@ files:
23
121
  - LICENSE.txt
24
122
  - README.md
25
123
  - Rakefile
124
+ - lib/faraday/raise_increase_api_error.rb
26
125
  - lib/increase.rb
126
+ - lib/increase/client.rb
127
+ - lib/increase/configuration.rb
128
+ - lib/increase/errors.rb
129
+ - lib/increase/resource.rb
130
+ - lib/increase/resources.rb
131
+ - lib/increase/resources/account_numbers.rb
132
+ - lib/increase/resources/account_transfers.rb
133
+ - lib/increase/resources/accounts.rb
134
+ - lib/increase/resources/ach_transfers.rb
135
+ - lib/increase/resources/cards.rb
136
+ - lib/increase/resources/events.rb
137
+ - lib/increase/resources/pending_transactions.rb
138
+ - lib/increase/resources/transactions.rb
139
+ - lib/increase/response_hash.rb
140
+ - lib/increase/util.rb
27
141
  - lib/increase/version.rb
142
+ - lib/increase/webhook/signature.rb
28
143
  - sig/increase.rbs
29
144
  homepage: https://github.com/garyhtou/increase-ruby
30
145
  licenses:
@@ -41,7 +156,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
41
156
  requirements:
42
157
  - - ">="
43
158
  - !ruby/object:Gem::Version
44
- version: 2.6.0
159
+ version: 2.7.4
45
160
  required_rubygems_version: !ruby/object:Gem::Requirement
46
161
  requirements:
47
162
  - - ">="