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 | 7 | GEM |
8 | 8 | remote: https://rubygems.org/ |
9 | 9 | specs: |
10 | + coderay (1.1.2) | |
10 | 11 | faraday (0.14.0) |
11 | 12 | multipart-post (>= 1.2, < 3) |
13 | + method_source (0.9.0) | |
12 | 14 | minitest (5.11.3) |
13 | 15 | multipart-post (2.0.0) |
16 | + pry (0.11.3) | |
17 | + coderay (~> 1.1.0) | |
18 | + method_source (~> 0.9.0) | |
14 | 19 | rake (10.5.0) |
15 | 20 | |
16 | 21 | PLATFORMS |
... | ... | @@ -19,6 +24,7 @@ PLATFORMS |
19 | 24 | DEPENDENCIES |
20 | 25 | bundler (~> 1.16) |
21 | 26 | minitest (~> 5.0) |
27 | + pry (~> 0.11) | |
22 | 28 | rake (~> 10.0) |
23 | 29 | syspro! |
24 | 30 | ... | ... |
lib/syspro.rb
... | ... | @@ -4,18 +4,93 @@ require "json" |
4 | 4 | require "logger" |
5 | 5 | require "openssl" |
6 | 6 | |
7 | -require "syspro/version" | |
7 | +require "syspro/api_resource" | |
8 | 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 | 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 | 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 | 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 | 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 | 1 | module Syspro |
2 | 2 | class SysproClient |
3 | + attr_accessor :conn, :api_base | |
4 | + | |
3 | 5 | def initialize(conn = nil) |
4 | 6 | self.conn = conn || self.class.default_conn |
5 | 7 | @system_profiler = SystemProfiler.new |
... | ... | @@ -14,7 +16,308 @@ module Syspro |
14 | 16 | end |
15 | 17 | |
16 | 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 | 321 | end |
19 | 322 | end |
20 | 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 | 30 | spec.add_dependency("faraday", "~> 0.10") |
31 | 31 | |
32 | 32 | spec.add_development_dependency "bundler", "~> 1.16" |
33 | + spec.add_development_dependency "pry", "~> 0.11" | |
33 | 34 | spec.add_development_dependency "rake", "~> 10.0" |
34 | 35 | spec.add_development_dependency "minitest", "~> 5.0" |
35 | 36 | end | ... | ... |
test/client_test.rb
... | ... | @@ -3,6 +3,6 @@ require "test_helper" |
3 | 3 | class SysproClientTest < Minitest::Test |
4 | 4 | def test_get_syspro_version |
5 | 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 | 7 | end |
8 | 8 | end | ... | ... |