Commit db76748d4eb8f231c0f8abed232c33598dd6ae86
1 parent
51fb5579
cop a bunch of Stripe Ruby architecture; working GetVersion route test
Showing
13 changed files
with
776 additions
and
8 deletions
Show diff stats
Gemfile.lock
@@ -7,10 +7,15 @@ PATH | @@ -7,10 +7,15 @@ PATH | ||
7 | GEM | 7 | GEM |
8 | remote: https://rubygems.org/ | 8 | remote: https://rubygems.org/ |
9 | specs: | 9 | specs: |
10 | + coderay (1.1.2) | ||
10 | faraday (0.14.0) | 11 | faraday (0.14.0) |
11 | multipart-post (>= 1.2, < 3) | 12 | multipart-post (>= 1.2, < 3) |
13 | + method_source (0.9.0) | ||
12 | minitest (5.11.3) | 14 | minitest (5.11.3) |
13 | multipart-post (2.0.0) | 15 | multipart-post (2.0.0) |
16 | + pry (0.11.3) | ||
17 | + coderay (~> 1.1.0) | ||
18 | + method_source (~> 0.9.0) | ||
14 | rake (10.5.0) | 19 | rake (10.5.0) |
15 | 20 | ||
16 | PLATFORMS | 21 | PLATFORMS |
@@ -19,6 +24,7 @@ PLATFORMS | @@ -19,6 +24,7 @@ PLATFORMS | ||
19 | DEPENDENCIES | 24 | DEPENDENCIES |
20 | bundler (~> 1.16) | 25 | bundler (~> 1.16) |
21 | minitest (~> 5.0) | 26 | minitest (~> 5.0) |
27 | + pry (~> 0.11) | ||
22 | rake (~> 10.0) | 28 | rake (~> 10.0) |
23 | syspro! | 29 | syspro! |
24 | 30 |
lib/syspro.rb
@@ -4,18 +4,93 @@ require "json" | @@ -4,18 +4,93 @@ require "json" | ||
4 | require "logger" | 4 | require "logger" |
5 | require "openssl" | 5 | require "openssl" |
6 | 6 | ||
7 | -require "syspro/version" | 7 | +require "syspro/api_resource" |
8 | require "syspro/syspro_client" | 8 | require "syspro/syspro_client" |
9 | +require "syspro/singleton_api_resource" | ||
10 | +require "syspro/syspro_object" | ||
11 | +require "syspro/syspro_response" | ||
12 | +require "syspro/util" | ||
13 | +require "syspro/version" | ||
14 | + | ||
15 | +require "syspro/api_operations/get_version" | ||
16 | +require "syspro/api_operations/request" | ||
17 | + | ||
9 | 18 | ||
10 | module Syspro | 19 | module Syspro |
11 | - # Your code goes here... | 20 | + @api_base = "http://syspro.wildlandlabs.com:90/SYSPROWCFService/Rest" |
21 | + | ||
22 | + @open_timeout = 30 | ||
23 | + @read_timeout = 80 | ||
24 | + | ||
25 | + @log_level = nil | ||
26 | + @logger = nil | ||
27 | + | ||
28 | + @max_network_retries = 0 | ||
29 | + @max_network_retry_delay = 2 | ||
30 | + @initial_network_retry_delay = 0.5 | ||
31 | + | ||
32 | + class << self | ||
33 | + attr_accessor :api_base, :open_timeout, :read_timeout | ||
34 | + end | ||
35 | + | ||
36 | + # Options that should be persisted between API requests. This includes | ||
37 | + # client, which is an object containing an HTTP client to reuse. | ||
38 | + OPTS_PERSISTABLE = ( | ||
39 | + Set[:client] | ||
40 | + ).freeze | ||
41 | + | ||
42 | + # map to the same values as the standard library's logger | ||
43 | + LEVEL_DEBUG = Logger::DEBUG | ||
44 | + LEVEL_ERROR = Logger::ERROR | ||
45 | + LEVEL_INFO = Logger::INFO | ||
46 | + | ||
47 | + # When set prompts the library to log some extra information to $stdout and | ||
48 | + # $stderr about what it's doing. For example, it'll produce information about | ||
49 | + # requests, responses, and errors that are received. Valid log levels are | ||
50 | + # `debug` and `info`, with `debug` being a little more verbose in places. | ||
12 | # | 51 | # |
52 | + # Use of this configuration is only useful when `.logger` is _not_ set. When | ||
53 | + # it is, the decision what levels to print is entirely deferred to the logger. | ||
54 | + def self.log_level | ||
55 | + @log_level | ||
56 | + end | ||
13 | 57 | ||
14 | - def self.ca_store | ||
15 | - @ca_store ||= begin | ||
16 | - store = OpenSSL::X509::Store.new | ||
17 | - store.add_file(ca_bundle_path) | ||
18 | - store | 58 | + def self.log_level=(val) |
59 | + # Backwards compatibility for values that we briefly allowed | ||
60 | + if val == "debug" | ||
61 | + val = LEVEL_DEBUG | ||
62 | + elsif val == "info" | ||
63 | + val = LEVEL_INFO | ||
19 | end | 64 | end |
65 | + | ||
66 | + if !val.nil? && ![LEVEL_DEBUG, LEVEL_ERROR, LEVEL_INFO].include?(val) | ||
67 | + raise ArgumentError, "log_level should only be set to `nil`, `debug` or `info`" | ||
68 | + end | ||
69 | + @log_level = val | ||
70 | + end | ||
71 | + | ||
72 | + # Sets a logger to which logging output will be sent. The logger should | ||
73 | + # support the same interface as the `Logger` class that's part of Ruby's | ||
74 | + # standard library (hint, anything in `Rails.logger` will likely be | ||
75 | + # suitable). | ||
76 | + # | ||
77 | + # If `.logger` is set, the value of `.log_level` is ignored. The decision on | ||
78 | + # what levels to print is entirely deferred to the logger. | ||
79 | + def self.logger | ||
80 | + @logger | ||
81 | + end | ||
82 | + | ||
83 | + def self.logger=(val) | ||
84 | + @logger = val | ||
85 | + end | ||
86 | + | ||
87 | + def self.max_network_retries | ||
88 | + @max_network_retries | ||
20 | end | 89 | end |
90 | + | ||
91 | + def self.max_network_retries=(val) | ||
92 | + @max_network_retries = val.to_i | ||
93 | + end | ||
94 | + | ||
95 | + Stripe.log_level = ENV["STRIPE_LOG"] unless ENV["STRIPE_LOG"].nil? | ||
21 | end | 96 | end |
1 | +module Syspro | ||
2 | + module ApiOperations | ||
3 | + module Request | ||
4 | + def request(method, url, params = {}, opts = {}) | ||
5 | + client = SysproClient.active_client | ||
6 | + | ||
7 | + headers = opts.clone | ||
8 | + | ||
9 | + resp = client.execute_request( | ||
10 | + method, url, | ||
11 | + headers: headers, | ||
12 | + params: params | ||
13 | + ) | ||
14 | + | ||
15 | + resp | ||
16 | + end | ||
17 | + | ||
18 | + def warn_on_opts_in_params(params) | ||
19 | + Util::OPTS_USER_SPECIFIED.each do |opt| | ||
20 | + if params.key?(opt) | ||
21 | + $stderr.puts("WARNING: #{opt} should be in opts instead of params.") | ||
22 | + end | ||
23 | + end | ||
24 | + end | ||
25 | + end | ||
26 | + end | ||
27 | +end | ||
28 | + |
1 | +require_relative "syspro_client" | ||
2 | +require_relative "api_operations/request" | ||
3 | + | ||
4 | +module Syspro | ||
5 | + class ApiResource < Syspro::SysproClient | ||
6 | + | ||
7 | + def self.class_name | ||
8 | + name.split("::")[-1] | ||
9 | + end | ||
10 | + | ||
11 | + def self.resource_url | ||
12 | + if self == APIResource | ||
13 | + raise NotImplementedError, "APIResource is an abstract class. You should perform actions on its subclasses (Charge, Customer, etc.)" | ||
14 | + end | ||
15 | + "/v1/#{CGI.escape(class_name.downcase)}s" | ||
16 | + end | ||
17 | + | ||
18 | + def resource_url | ||
19 | + unless (id = self["id"]) | ||
20 | + raise InvalidRequestError.new("Could not determine which URL to request: #{self.class} instance has invalid ID: #{id.inspect}", "id") | ||
21 | + end | ||
22 | + "#{self.class.resource_url}/#{CGI.escape(id)}" | ||
23 | + end | ||
24 | + | ||
25 | + def refresh | ||
26 | + resp, opts = request(:get, resource_url, @retrieve_params) | ||
27 | + initialize_from(resp.data, opts) | ||
28 | + end | ||
29 | + | ||
30 | + def self.retrieve(id, opts = {}) | ||
31 | + opts = Util.normalize_opts(opts) | ||
32 | + instance = new(id, opts) | ||
33 | + instance.refresh | ||
34 | + instance | ||
35 | + end | ||
36 | + end | ||
37 | +end |
1 | +require_relative "api_resource" | ||
2 | + | ||
3 | +module Syspro | ||
4 | + class SingletonAPIResource < ApiResource | ||
5 | + def self.resource_url | ||
6 | + if self == SingletonAPIResource | ||
7 | + raise NotImplementedError, "SingletonAPIResource is an abstract class. You should perform actions on its subclasses (Customer, etc.)" | ||
8 | + end | ||
9 | + "/v1/#{CGI.escape(class_name.downcase)}" | ||
10 | + end | ||
11 | + | ||
12 | + def resource_url | ||
13 | + self.class.resource_url | ||
14 | + end | ||
15 | + | ||
16 | + def self.retrieve(opts = {}) | ||
17 | + instance = new(nil, Util.normalize_opts(opts)) | ||
18 | + instance.refresh | ||
19 | + instance | ||
20 | + end | ||
21 | + end | ||
22 | +end |
lib/syspro/syspro_client.rb
1 | module Syspro | 1 | module Syspro |
2 | class SysproClient | 2 | class SysproClient |
3 | + attr_accessor :conn, :api_base | ||
4 | + | ||
3 | def initialize(conn = nil) | 5 | def initialize(conn = nil) |
4 | self.conn = conn || self.class.default_conn | 6 | self.conn = conn || self.class.default_conn |
5 | @system_profiler = SystemProfiler.new | 7 | @system_profiler = SystemProfiler.new |
@@ -14,7 +16,308 @@ module Syspro | @@ -14,7 +16,308 @@ module Syspro | ||
14 | end | 16 | end |
15 | 17 | ||
16 | def get_syspro_version | 18 | def get_syspro_version |
19 | + version_getter = Syspro::ApiOperations::GetVersion.new | ||
20 | + version_getter.request(:get, version_getter.resource_url) | ||
21 | + end | ||
22 | + | ||
23 | + # A default Faraday connection to be used when one isn't configured. This | ||
24 | + # object should never be mutated, and instead instantiating your own | ||
25 | + # connection and wrapping it in a SysproClient object should be preferred. | ||
26 | + def self.default_conn | ||
27 | + # We're going to keep connections around so that we can take advantage | ||
28 | + # of connection re-use, so make sure that we have a separate connection | ||
29 | + # object per thread. | ||
30 | + Thread.current[:syspro_client_default_conn] ||= begin | ||
31 | + conn = Faraday.new do |c| | ||
32 | + c.use Faraday::Request::Multipart | ||
33 | + c.use Faraday::Request::UrlEncoded | ||
34 | + c.use Faraday::Response::RaiseError | ||
35 | + c.adapter Faraday.default_adapter | ||
36 | + end | ||
37 | + | ||
38 | + #if Syspro.verify_ssl_certs | ||
39 | + #conn.ssl.verify = true | ||
40 | + #conn.ssl.cert_store = Syspro.ca_store | ||
41 | + #else | ||
42 | + conn.ssl.verify = false | ||
43 | + | ||
44 | + unless @verify_ssl_warned | ||
45 | + @verify_ssl_warned = true | ||
46 | + $stderr.puts("WARNING: Running without SSL cert verification. " \ | ||
47 | + "You should never do this in production. " \ | ||
48 | + "Execute 'Syspro.verify_ssl_certs = true' to enable verification.") | ||
49 | + end | ||
50 | + #end | ||
51 | + | ||
52 | + conn | ||
53 | + end | ||
54 | + end | ||
55 | + | ||
56 | + def execute_request(method, path, api_base: nil, headers: {}, params: {}) | ||
57 | + api_base ||= Syspro.api_base | ||
58 | + | ||
59 | + params = Util.objects_to_ids(params) | ||
60 | + url = api_url(path, api_base) | ||
61 | + | ||
62 | + body = nil | ||
63 | + query_params = nil | ||
64 | + | ||
65 | + case method.to_s.downcase.to_sym | ||
66 | + when :get, :head, :delete | ||
67 | + query_params = params | ||
68 | + else | ||
69 | + body = if headers[:content_type] && headers[:content_type] == "multipart/form-data" | ||
70 | + params | ||
71 | + else | ||
72 | + Util.encode_parameters(params) | ||
73 | + end | ||
74 | + end | ||
75 | + | ||
76 | + headers = request_headers(method) | ||
77 | + .update(Util.normalize_headers(headers)) | ||
78 | + | ||
79 | + # stores information on the request we're about to make so that we don't | ||
80 | + # have to pass as many parameters around for logging. | ||
81 | + context = RequestLogContext.new | ||
82 | + context.body = body | ||
83 | + context.method = method | ||
84 | + context.path = path | ||
85 | + context.query_params = query_params ? Util.encode_parameters(query_params) : nil | ||
86 | + | ||
87 | + http_resp = execute_request_with_rescues(api_base, context) do | ||
88 | + conn.run_request(method, url, body, headers) do |req| | ||
89 | + req.options.open_timeout = Syspro.open_timeout | ||
90 | + req.options.timeout = Syspro.read_timeout | ||
91 | + req.params = query_params unless query_params.nil? | ||
92 | + end | ||
93 | + end | ||
94 | + | ||
95 | + begin | ||
96 | + resp = SysproResponse.from_faraday_response(http_resp) | ||
97 | + rescue JSON::ParserError | ||
98 | + raise general_api_error(http_resp.status, http_resp.body) | ||
99 | + end | ||
100 | + | ||
101 | + # Allows SysproClient#request to return a response object to a caller. | ||
102 | + @last_response = resp | ||
103 | + [resp] | ||
104 | + end | ||
105 | + | ||
106 | + def general_api_error(status, body) | ||
107 | + APIError.new("Invalid response object from API: #{body.inspect} " \ | ||
108 | + "(HTTP response code was #{status})", | ||
109 | + http_status: status, http_body: body) | ||
110 | + end | ||
111 | + | ||
112 | + def api_url(url = "", api_base = nil) | ||
113 | + (api_base || Syspro.api_base) + url | ||
114 | + end | ||
115 | + | ||
116 | + def request_headers(method) | ||
117 | + user_agent = "Syspro/7 RubyBindings/#{Syspro::VERSION}" | ||
118 | + | ||
119 | + headers = { | ||
120 | + "User-Agent" => user_agent, | ||
121 | + "Content-Type" => "application/x-www-form-urlencoded", | ||
122 | + } | ||
123 | + | ||
124 | + headers | ||
125 | + end | ||
126 | + | ||
127 | + def execute_request_with_rescues(api_base, context) | ||
128 | + num_retries = 0 | ||
129 | + begin | ||
130 | + request_start = Time.now | ||
131 | + log_request(context, num_retries) | ||
132 | + resp = yield | ||
133 | + context = context.dup_from_response(resp) | ||
134 | + log_response(context, request_start, resp.status, resp.body) | ||
135 | + | ||
136 | + # We rescue all exceptions from a request so that we have an easy spot to | ||
137 | + # implement our retry logic across the board. We'll re-raise if it's a type | ||
138 | + # of exception that we didn't expect to handle. | ||
139 | + rescue StandardError => e | ||
140 | + # If we modify context we copy it into a new variable so as not to | ||
141 | + # taint the original on a retry. | ||
142 | + error_context = context | ||
143 | + | ||
144 | + if e.respond_to?(:response) && e.response | ||
145 | + error_context = context.dup_from_response(e.response) | ||
146 | + log_response(error_context, request_start, | ||
147 | + e.response[:status], e.response[:body]) | ||
148 | + else | ||
149 | + log_response_error(error_context, request_start, e) | ||
150 | + end | ||
151 | + | ||
152 | + if self.class.should_retry?(e, num_retries) | ||
153 | + num_retries += 1 | ||
154 | + sleep self.class.sleep_time(num_retries) | ||
155 | + retry | ||
156 | + end | ||
157 | + | ||
158 | + case e | ||
159 | + when Faraday::ClientError | ||
160 | + if e.response | ||
161 | + handle_error_response(e.response, error_context) | ||
162 | + else | ||
163 | + handle_network_error(e, error_context, num_retries, api_base) | ||
164 | + end | ||
165 | + | ||
166 | + # Only handle errors when we know we can do so, and re-raise otherwise. | ||
167 | + # This should be pretty infrequent. | ||
168 | + else | ||
169 | + raise | ||
170 | + end | ||
171 | + end | ||
172 | + | ||
173 | + resp | ||
174 | + end | ||
175 | + | ||
176 | + def self.should_retry?(e, num_retries) | ||
177 | + # Retry on timeout-related problems (either on open or read). | ||
178 | + return true if e.is_a?(Faraday::TimeoutError) | ||
179 | + | ||
180 | + # Destination refused the connection, the connection was reset, or a | ||
181 | + # variety of other connection failures. This could occur from a single | ||
182 | + # saturated server, so retry in case it's intermittent. | ||
183 | + return true if e.is_a?(Faraday::ConnectionFailed) | ||
184 | + | ||
185 | + if e.is_a?(Faraday::ClientError) && e.response | ||
186 | + # 409 conflict | ||
187 | + return true if e.response[:status] == 409 | ||
188 | + end | ||
189 | + | ||
190 | + false | ||
191 | + end | ||
192 | + | ||
193 | + def log_request(context, num_retries) | ||
194 | + Util.log_info("Request to Syspro API", | ||
195 | + account: context.account, | ||
196 | + api_version: context.api_version, | ||
197 | + method: context.method, | ||
198 | + num_retries: num_retries, | ||
199 | + path: context.path) | ||
200 | + Util.log_debug("Request details", | ||
201 | + body: context.body, | ||
202 | + query_params: context.query_params) | ||
203 | + end | ||
204 | + private :log_request | ||
205 | + | ||
206 | + def log_response(context, request_start, status, body) | ||
207 | + Util.log_info("Response from Syspro API", | ||
208 | + account: context.account, | ||
209 | + api_version: context.api_version, | ||
210 | + elapsed: Time.now - request_start, | ||
211 | + method: context.method, | ||
212 | + path: context.path, | ||
213 | + request_id: context.request_id, | ||
214 | + status: status) | ||
215 | + Util.log_debug("Response details", | ||
216 | + body: body, | ||
217 | + request_id: context.request_id) | ||
218 | + end | ||
219 | + private :log_response | ||
220 | + | ||
221 | + def log_response_error(context, request_start, e) | ||
222 | + Util.log_error("Request error", | ||
223 | + elapsed: Time.now - request_start, | ||
224 | + error_message: e.message, | ||
225 | + method: context.method, | ||
226 | + path: context.path) | ||
227 | + end | ||
228 | + private :log_response_error | ||
229 | + | ||
230 | + # RequestLogContext stores information about a request that's begin made so | ||
231 | + # that we can log certain information. It's useful because it means that we | ||
232 | + # don't have to pass around as many parameters. | ||
233 | + class RequestLogContext | ||
234 | + attr_accessor :body | ||
235 | + attr_accessor :account | ||
236 | + attr_accessor :api_version | ||
237 | + attr_accessor :method | ||
238 | + attr_accessor :path | ||
239 | + attr_accessor :query_params | ||
240 | + attr_accessor :request_id | ||
241 | + | ||
242 | + # The idea with this method is that we might want to update some of | ||
243 | + # context information because a response that we've received from the API | ||
244 | + # contains information that's more authoritative than what we started | ||
245 | + # with for a request. For example, we should trust whatever came back in | ||
246 | + # a `Stripe-Version` header beyond what configuration information that we | ||
247 | + # might have had available. | ||
248 | + def dup_from_response(resp) | ||
249 | + return self if resp.nil? | ||
250 | + | ||
251 | + # Faraday's API is a little unusual. Normally it'll produce a response | ||
252 | + # object with a `headers` method, but on error what it puts into | ||
253 | + # `e.response` is an untyped `Hash`. | ||
254 | + headers = if resp.is_a?(Faraday::Response) | ||
255 | + resp.headers | ||
256 | + else | ||
257 | + resp[:headers] | ||
258 | + end | ||
259 | + | ||
260 | + context = dup | ||
261 | + context.account = headers["Stripe-Account"] | ||
262 | + context.api_version = headers["Stripe-Version"] | ||
263 | + context.request_id = headers["Request-Id"] | ||
264 | + context | ||
265 | + end | ||
266 | + end | ||
267 | + | ||
268 | + # SystemProfiler extracts information about the system that we're running | ||
269 | + # in so that we can generate a rich user agent header to help debug | ||
270 | + # integrations. | ||
271 | + class SystemProfiler | ||
272 | + def self.uname | ||
273 | + if File.exist?("/proc/version") | ||
274 | + File.read("/proc/version").strip | ||
275 | + else | ||
276 | + case RbConfig::CONFIG["host_os"] | ||
277 | + when /linux|darwin|bsd|sunos|solaris|cygwin/i | ||
278 | + uname_from_system | ||
279 | + when /mswin|mingw/i | ||
280 | + uname_from_system_ver | ||
281 | + else | ||
282 | + "unknown platform" | ||
283 | + end | ||
284 | + end | ||
285 | + end | ||
286 | + | ||
287 | + def self.uname_from_system | ||
288 | + (`uname -a 2>/dev/null` || "").strip | ||
289 | + rescue Errno::ENOENT | ||
290 | + "uname executable not found" | ||
291 | + rescue Errno::ENOMEM # couldn't create subprocess | ||
292 | + "uname lookup failed" | ||
293 | + end | ||
294 | + | ||
295 | + def self.uname_from_system_ver | ||
296 | + (`ver` || "").strip | ||
297 | + rescue Errno::ENOENT | ||
298 | + "ver executable not found" | ||
299 | + rescue Errno::ENOMEM # couldn't create subprocess | ||
300 | + "uname lookup failed" | ||
301 | + end | ||
302 | + | ||
303 | + def initialize | ||
304 | + @uname = self.class.uname | ||
305 | + end | ||
306 | + | ||
307 | + def user_agent | ||
308 | + lang_version = "#{RUBY_VERSION} p#{RUBY_PATCHLEVEL} (#{RUBY_RELEASE_DATE})" | ||
17 | 309 | ||
310 | + { | ||
311 | + application: Syspro.app_info, | ||
312 | + bindings_version: Syspro::VERSION, | ||
313 | + lang: "ruby", | ||
314 | + lang_version: lang_version, | ||
315 | + platform: RUBY_PLATFORM, | ||
316 | + engine: defined?(RUBY_ENGINE) ? RUBY_ENGINE : "", | ||
317 | + uname: @uname, | ||
318 | + hostname: Socket.gethostname, | ||
319 | + }.delete_if { |_k, v| v.nil? } | ||
320 | + end | ||
18 | end | 321 | end |
19 | end | 322 | end |
20 | end | 323 | end |
1 | +module Syspro | ||
2 | + class SysproObject | ||
3 | + | ||
4 | + # Re-initializes the object based on a hash of values (usually one that's | ||
5 | + # come back from an API call). Adds or removes value accessors as necessary | ||
6 | + # and updates the state of internal data. | ||
7 | + # | ||
8 | + # Protected on purpose! Please do not expose. | ||
9 | + # | ||
10 | + # ==== Options | ||
11 | + # | ||
12 | + # * +:values:+ Hash used to update accessors and values. | ||
13 | + # * +:opts:+ Options for SysproObject like an API key. | ||
14 | + # * +:partial:+ Indicates that the re-initialization should not attempt to | ||
15 | + # remove accessors. | ||
16 | + def initialize_from(values, opts, partial = false) | ||
17 | + @opts = Util.normalize_opts(opts) | ||
18 | + | ||
19 | + # the `#send` is here so that we can keep this method private | ||
20 | + @original_values = self.class.send(:deep_copy, values) | ||
21 | + | ||
22 | + removed = partial ? Set.new : Set.new(@values.keys - values.keys) | ||
23 | + added = Set.new(values.keys - @values.keys) | ||
24 | + | ||
25 | + # Wipe old state before setting new. This is useful for e.g. updating a | ||
26 | + # customer, where there is no persistent card parameter. Mark those values | ||
27 | + # which don't persist as transient | ||
28 | + | ||
29 | + remove_accessors(removed) | ||
30 | + add_accessors(added, values) | ||
31 | + | ||
32 | + removed.each do |k| | ||
33 | + @values.delete(k) | ||
34 | + @transient_values.add(k) | ||
35 | + @unsaved_values.delete(k) | ||
36 | + end | ||
37 | + | ||
38 | + update_attributes(values, opts, dirty: false) | ||
39 | + values.each_key do |k| | ||
40 | + @transient_values.delete(k) | ||
41 | + @unsaved_values.delete(k) | ||
42 | + end | ||
43 | + | ||
44 | + self | ||
45 | + end | ||
46 | + | ||
47 | + def remove_accessors(keys) | ||
48 | + # not available in the #instance_eval below | ||
49 | + protected_fields = self.class.protected_fields | ||
50 | + | ||
51 | + metaclass.instance_eval do | ||
52 | + keys.each do |k| | ||
53 | + next if protected_fields.include?(k) | ||
54 | + next if @@permanent_attributes.include?(k) | ||
55 | + | ||
56 | + # Remove methods for the accessor's reader and writer. | ||
57 | + [k, :"#{k}=", :"#{k}?"].each do |method_name| | ||
58 | + remove_method(method_name) if method_defined?(method_name) | ||
59 | + end | ||
60 | + end | ||
61 | + end | ||
62 | + end | ||
63 | + | ||
64 | + def add_accessors(keys, values) | ||
65 | + # not available in the #instance_eval below | ||
66 | + protected_fields = self.class.protected_fields | ||
67 | + | ||
68 | + metaclass.instance_eval do | ||
69 | + keys.each do |k| | ||
70 | + next if protected_fields.include?(k) | ||
71 | + next if @@permanent_attributes.include?(k) | ||
72 | + | ||
73 | + if k == :method | ||
74 | + # Object#method is a built-in Ruby method that accepts a symbol | ||
75 | + # and returns the corresponding Method object. Because the API may | ||
76 | + # also use `method` as a field name, we check the arity of *args | ||
77 | + # to decide whether to act as a getter or call the parent method. | ||
78 | + define_method(k) { |*args| args.empty? ? @values[k] : super(*args) } | ||
79 | + else | ||
80 | + define_method(k) { @values[k] } | ||
81 | + end | ||
82 | + | ||
83 | + define_method(:"#{k}=") do |v| | ||
84 | + if v == "" | ||
85 | + raise ArgumentError, "You cannot set #{k} to an empty string. " \ | ||
86 | + "We interpret empty strings as nil in requests. " \ | ||
87 | + "You may set (object).#{k} = nil to delete the property." | ||
88 | + end | ||
89 | + @values[k] = Util.convert_to_stripe_object(v, @opts) | ||
90 | + dirty_value!(@values[k]) | ||
91 | + @unsaved_values.add(k) | ||
92 | + end | ||
93 | + | ||
94 | + if [FalseClass, TrueClass].include?(values[k].class) | ||
95 | + define_method(:"#{k}?") { @values[k] } | ||
96 | + end | ||
97 | + end | ||
98 | + end | ||
99 | + end | ||
100 | + end | ||
101 | +end |
1 | +require "nokogiri" | ||
2 | + | ||
3 | +module Syspro | ||
4 | + # SysproResponse encapsulates some vitals of a response that came back from | ||
5 | + # the Syspro API. | ||
6 | + class SysproResponse | ||
7 | + # The data contained by the HTTP body of the response deserialized from | ||
8 | + # JSON. | ||
9 | + attr_accessor :data | ||
10 | + | ||
11 | + # The raw HTTP body of the response. | ||
12 | + attr_accessor :http_body | ||
13 | + | ||
14 | + # A Hash of the HTTP headers of the response. | ||
15 | + attr_accessor :http_headers | ||
16 | + | ||
17 | + # The integer HTTP status code of the response. | ||
18 | + attr_accessor :http_status | ||
19 | + | ||
20 | + # The Syspro request ID of the response. | ||
21 | + attr_accessor :request_id | ||
22 | + | ||
23 | + # Initializes a SysproResponse object from a Hash like the kind returned as | ||
24 | + # part of a Faraday exception. | ||
25 | + # | ||
26 | + # This may throw JSON::ParserError if the response body is not valid JSON. | ||
27 | + def self.from_faraday_hash(http_resp) | ||
28 | + resp = SysproResponse.new | ||
29 | + resp.data = Nokogiri::XML(http_resp[:body]) | ||
30 | + resp.http_body = http_resp[:body] | ||
31 | + resp.http_headers = http_resp[:headers] | ||
32 | + resp.http_status = http_resp[:status] | ||
33 | + resp.request_id = http_resp[:headers]["Request-Id"] | ||
34 | + resp | ||
35 | + end | ||
36 | + | ||
37 | + # Initializes a SysproResponse object from a Faraday HTTP response object. | ||
38 | + # | ||
39 | + # This may throw JSON::ParserError if the response body is not valid JSON. | ||
40 | + def self.from_faraday_response(http_resp) | ||
41 | + resp = SysproResponse.new | ||
42 | + resp.data = Nokogiri::XML(http_resp[:body]) | ||
43 | + resp.http_body = http_resp.body | ||
44 | + resp.http_headers = http_resp.headers | ||
45 | + resp.http_status = http_resp.status | ||
46 | + resp.request_id = http_resp.headers["Request-Id"] | ||
47 | + resp | ||
48 | + end | ||
49 | + end | ||
50 | +end |
1 | +module Syspro | ||
2 | + class Util | ||
3 | + # Options that a user is allowed to specify. | ||
4 | + OPTS_USER_SPECIFIED = Set[ | ||
5 | + # :syspro_version | ||
6 | + ].freeze | ||
7 | + | ||
8 | + | ||
9 | + def self.objects_to_ids(h) | ||
10 | + case h | ||
11 | + when ApiResource | ||
12 | + h.id | ||
13 | + when Hash | ||
14 | + res = {} | ||
15 | + h.each { |k, v| res[k] = objects_to_ids(v) unless v.nil? } | ||
16 | + res | ||
17 | + when Array | ||
18 | + h.map { |v| objects_to_ids(v) } | ||
19 | + else | ||
20 | + h | ||
21 | + end | ||
22 | + end | ||
23 | + | ||
24 | + # Converts a hash of fields or an array of hashes into a +SysproObject+ or | ||
25 | + # array of +SysproObject+s. These new objects will be created as a concrete | ||
26 | + # type as dictated by their `object` field (e.g. an `object` value of | ||
27 | + # `charge` would create an instance of +Charge+), but if `object` is not | ||
28 | + # present or of an unknown type, the newly created instance will fall back | ||
29 | + # to being a +SysproObject+. | ||
30 | + # | ||
31 | + # ==== Attributes | ||
32 | + # | ||
33 | + # * +data+ - Hash of fields and values to be converted into a SysproObject. | ||
34 | + # * +opts+ - Options for +SysproObject+ like an API key that will be reused | ||
35 | + # on subsequent API calls. | ||
36 | + def self.convert_to_syspro_object(data, opts = {}) | ||
37 | + case data | ||
38 | + when Array | ||
39 | + data.map { |i| convert_to_syspro_object(i, opts) } | ||
40 | + when Hash | ||
41 | + # Try converting to a known object class. If none available, fall back to generic SysproObject | ||
42 | + object_classes.fetch(data[:object], SysproObject).construct_from(data, opts) | ||
43 | + else | ||
44 | + data | ||
45 | + end | ||
46 | + end | ||
47 | + | ||
48 | + | ||
49 | + # The secondary opts argument can either be a string or hash | ||
50 | + # Turn this value into an api_key and a set of headers | ||
51 | + def self.normalize_opts(opts) | ||
52 | + case opts | ||
53 | + when String | ||
54 | + opts | ||
55 | + when Hash | ||
56 | + opts.clone | ||
57 | + else | ||
58 | + raise TypeError, "normalize_opts expects a string or a hash" | ||
59 | + end | ||
60 | + end | ||
61 | + | ||
62 | + # Normalizes header keys so that they're all lower case and each | ||
63 | + # hyphen-delimited section starts with a single capitalized letter. For | ||
64 | + # example, `request-id` becomes `Request-Id`. This is useful for extracting | ||
65 | + # certain key values when the user could have set them with a variety of | ||
66 | + # diffent naming schemes. | ||
67 | + def self.normalize_headers(headers) | ||
68 | + headers.each_with_object({}) do |(k, v), new_headers| | ||
69 | + if k.is_a?(Symbol) | ||
70 | + k = titlecase_parts(k.to_s.tr("_", "-")) | ||
71 | + elsif k.is_a?(String) | ||
72 | + k = titlecase_parts(k) | ||
73 | + end | ||
74 | + | ||
75 | + new_headers[k] = v | ||
76 | + end | ||
77 | + end | ||
78 | + | ||
79 | + def self.encode_parameters(params) | ||
80 | + Util.flatten_params(params) | ||
81 | + .map { |k, v| "#{url_encode(k)}=#{url_encode(v)}" }.join("&") | ||
82 | + end | ||
83 | + | ||
84 | + def self.flatten_params(params, parent_key = nil) | ||
85 | + result = [] | ||
86 | + | ||
87 | + # do not sort the final output because arrays (and arrays of hashes | ||
88 | + # especially) can be order sensitive, but do sort incoming parameters | ||
89 | + params.each do |key, value| | ||
90 | + calculated_key = parent_key ? "#{parent_key}[#{key}]" : key.to_s | ||
91 | + if value.is_a?(Hash) | ||
92 | + result += flatten_params(value, calculated_key) | ||
93 | + elsif value.is_a?(Array) | ||
94 | + check_array_of_maps_start_keys!(value) | ||
95 | + result += flatten_params_array(value, calculated_key) | ||
96 | + else | ||
97 | + result << [calculated_key, value] | ||
98 | + end | ||
99 | + end | ||
100 | + | ||
101 | + result | ||
102 | + end | ||
103 | + | ||
104 | + def self.log_error(message, data = {}) | ||
105 | + if !Syspro.logger.nil? || | ||
106 | + !Syspro.log_level.nil? && Syspro.log_level <= Syspro::LEVEL_ERROR | ||
107 | + log_internal(message, data, color: :cyan, | ||
108 | + level: Syspro::LEVEL_ERROR, logger: Syspro.logger, out: $stderr) | ||
109 | + end | ||
110 | + end | ||
111 | + | ||
112 | + def self.log_info(message, data = {}) | ||
113 | + if !Syspro.logger.nil? || | ||
114 | + !Syspro.log_level.nil? && Syspro.log_level <= Syspro::LEVEL_INFO | ||
115 | + log_internal(message, data, color: :cyan, | ||
116 | + level: Syspro::LEVEL_INFO, logger: Syspro.logger, out: $stdout) | ||
117 | + end | ||
118 | + end | ||
119 | + | ||
120 | + def self.log_debug(message, data = {}) | ||
121 | + if !Syspro.logger.nil? || | ||
122 | + !Syspro.log_level.nil? && Syspro.log_level <= Syspro::LEVEL_DEBUG | ||
123 | + log_internal(message, data, color: :blue, | ||
124 | + level: Syspro::LEVEL_DEBUG, logger: Syspro.logger, out: $stdout) | ||
125 | + end | ||
126 | + end | ||
127 | + | ||
128 | + end | ||
129 | +end | ||
130 | + |
syspro.gemspec
@@ -30,6 +30,7 @@ Gem::Specification.new do |spec| | @@ -30,6 +30,7 @@ Gem::Specification.new do |spec| | ||
30 | spec.add_dependency("faraday", "~> 0.10") | 30 | spec.add_dependency("faraday", "~> 0.10") |
31 | 31 | ||
32 | spec.add_development_dependency "bundler", "~> 1.16" | 32 | spec.add_development_dependency "bundler", "~> 1.16" |
33 | + spec.add_development_dependency "pry", "~> 0.11" | ||
33 | spec.add_development_dependency "rake", "~> 10.0" | 34 | spec.add_development_dependency "rake", "~> 10.0" |
34 | spec.add_development_dependency "minitest", "~> 5.0" | 35 | spec.add_development_dependency "minitest", "~> 5.0" |
35 | end | 36 | end |
test/client_test.rb
@@ -3,6 +3,6 @@ require "test_helper" | @@ -3,6 +3,6 @@ require "test_helper" | ||
3 | class SysproClientTest < Minitest::Test | 3 | class SysproClientTest < Minitest::Test |
4 | def test_get_syspro_version | 4 | def test_get_syspro_version |
5 | client = ::Syspro::SysproClient.new | 5 | client = ::Syspro::SysproClient.new |
6 | - refute_nil client.get_syspro_version | 6 | + assert_match /(\d+\.)?(\d+\.)?(\d+\.)?(\d+)/, client.get_syspro_version[0].http_body |
7 | end | 7 | end |
8 | end | 8 | end |