diff --git a/Gemfile.lock b/Gemfile.lock index f930ab7..d6613cb 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -7,10 +7,15 @@ PATH GEM remote: https://rubygems.org/ specs: + coderay (1.1.2) faraday (0.14.0) multipart-post (>= 1.2, < 3) + method_source (0.9.0) minitest (5.11.3) multipart-post (2.0.0) + pry (0.11.3) + coderay (~> 1.1.0) + method_source (~> 0.9.0) rake (10.5.0) PLATFORMS @@ -19,6 +24,7 @@ PLATFORMS DEPENDENCIES bundler (~> 1.16) minitest (~> 5.0) + pry (~> 0.11) rake (~> 10.0) syspro! diff --git a/lib/syspro.rb b/lib/syspro.rb index 517a744..78279bc 100644 --- a/lib/syspro.rb +++ b/lib/syspro.rb @@ -4,18 +4,93 @@ require "json" require "logger" require "openssl" -require "syspro/version" +require "syspro/api_resource" require "syspro/syspro_client" +require "syspro/singleton_api_resource" +require "syspro/syspro_object" +require "syspro/syspro_response" +require "syspro/util" +require "syspro/version" + +require "syspro/api_operations/get_version" +require "syspro/api_operations/request" + module Syspro - # Your code goes here... + @api_base = "http://syspro.wildlandlabs.com:90/SYSPROWCFService/Rest" + + @open_timeout = 30 + @read_timeout = 80 + + @log_level = nil + @logger = nil + + @max_network_retries = 0 + @max_network_retry_delay = 2 + @initial_network_retry_delay = 0.5 + + class << self + attr_accessor :api_base, :open_timeout, :read_timeout + end + + # Options that should be persisted between API requests. This includes + # client, which is an object containing an HTTP client to reuse. + OPTS_PERSISTABLE = ( + Set[:client] + ).freeze + + # map to the same values as the standard library's logger + LEVEL_DEBUG = Logger::DEBUG + LEVEL_ERROR = Logger::ERROR + LEVEL_INFO = Logger::INFO + + # When set prompts the library to log some extra information to $stdout and + # $stderr about what it's doing. For example, it'll produce information about + # requests, responses, and errors that are received. Valid log levels are + # `debug` and `info`, with `debug` being a little more verbose in places. # + # Use of this configuration is only useful when `.logger` is _not_ set. When + # it is, the decision what levels to print is entirely deferred to the logger. + def self.log_level + @log_level + end - def self.ca_store - @ca_store ||= begin - store = OpenSSL::X509::Store.new - store.add_file(ca_bundle_path) - store + def self.log_level=(val) + # Backwards compatibility for values that we briefly allowed + if val == "debug" + val = LEVEL_DEBUG + elsif val == "info" + val = LEVEL_INFO end + + if !val.nil? && ![LEVEL_DEBUG, LEVEL_ERROR, LEVEL_INFO].include?(val) + raise ArgumentError, "log_level should only be set to `nil`, `debug` or `info`" + end + @log_level = val + end + + # Sets a logger to which logging output will be sent. The logger should + # support the same interface as the `Logger` class that's part of Ruby's + # standard library (hint, anything in `Rails.logger` will likely be + # suitable). + # + # If `.logger` is set, the value of `.log_level` is ignored. The decision on + # what levels to print is entirely deferred to the logger. + def self.logger + @logger + end + + def self.logger=(val) + @logger = val + end + + def self.max_network_retries + @max_network_retries end + + def self.max_network_retries=(val) + @max_network_retries = val.to_i + end + + Stripe.log_level = ENV["STRIPE_LOG"] unless ENV["STRIPE_LOG"].nil? end diff --git a/lib/syspro/api_operations/get_version.rb b/lib/syspro/api_operations/get_version.rb new file mode 100644 index 0000000..e9a6194 --- /dev/null +++ b/lib/syspro/api_operations/get_version.rb @@ -0,0 +1,14 @@ +require_relative "request" + +module Syspro + module ApiOperations + class GetVersion + include ApiOperations::Request + + def resource_url + "/GetVersion" + end + end + end +end + diff --git a/lib/syspro/api_operations/request.rb b/lib/syspro/api_operations/request.rb new file mode 100644 index 0000000..d6b0f8c --- /dev/null +++ b/lib/syspro/api_operations/request.rb @@ -0,0 +1,28 @@ +module Syspro + module ApiOperations + module Request + def request(method, url, params = {}, opts = {}) + client = SysproClient.active_client + + headers = opts.clone + + resp = client.execute_request( + method, url, + headers: headers, + params: params + ) + + resp + end + + def warn_on_opts_in_params(params) + Util::OPTS_USER_SPECIFIED.each do |opt| + if params.key?(opt) + $stderr.puts("WARNING: #{opt} should be in opts instead of params.") + end + end + end + end + end +end + diff --git a/lib/syspro/api_resource.rb b/lib/syspro/api_resource.rb new file mode 100644 index 0000000..1033c54 --- /dev/null +++ b/lib/syspro/api_resource.rb @@ -0,0 +1,37 @@ +require_relative "syspro_client" +require_relative "api_operations/request" + +module Syspro + class ApiResource < Syspro::SysproClient + + def self.class_name + name.split("::")[-1] + end + + def self.resource_url + if self == APIResource + raise NotImplementedError, "APIResource is an abstract class. You should perform actions on its subclasses (Charge, Customer, etc.)" + end + "/v1/#{CGI.escape(class_name.downcase)}s" + end + + def resource_url + unless (id = self["id"]) + raise InvalidRequestError.new("Could not determine which URL to request: #{self.class} instance has invalid ID: #{id.inspect}", "id") + end + "#{self.class.resource_url}/#{CGI.escape(id)}" + end + + def refresh + resp, opts = request(:get, resource_url, @retrieve_params) + initialize_from(resp.data, opts) + end + + def self.retrieve(id, opts = {}) + opts = Util.normalize_opts(opts) + instance = new(id, opts) + instance.refresh + instance + end + end +end diff --git a/lib/syspro/singleton_api_resource.rb b/lib/syspro/singleton_api_resource.rb new file mode 100644 index 0000000..ece7c01 --- /dev/null +++ b/lib/syspro/singleton_api_resource.rb @@ -0,0 +1,22 @@ +require_relative "api_resource" + +module Syspro + class SingletonAPIResource < ApiResource + def self.resource_url + if self == SingletonAPIResource + raise NotImplementedError, "SingletonAPIResource is an abstract class. You should perform actions on its subclasses (Customer, etc.)" + end + "/v1/#{CGI.escape(class_name.downcase)}" + end + + def resource_url + self.class.resource_url + end + + def self.retrieve(opts = {}) + instance = new(nil, Util.normalize_opts(opts)) + instance.refresh + instance + end + end +end diff --git a/lib/syspro/syspro_client.rb b/lib/syspro/syspro_client.rb index 9d482ee..1756e13 100644 --- a/lib/syspro/syspro_client.rb +++ b/lib/syspro/syspro_client.rb @@ -1,5 +1,7 @@ module Syspro class SysproClient + attr_accessor :conn, :api_base + def initialize(conn = nil) self.conn = conn || self.class.default_conn @system_profiler = SystemProfiler.new @@ -14,7 +16,308 @@ module Syspro end def get_syspro_version + version_getter = Syspro::ApiOperations::GetVersion.new + version_getter.request(:get, version_getter.resource_url) + end + + # A default Faraday connection to be used when one isn't configured. This + # object should never be mutated, and instead instantiating your own + # connection and wrapping it in a SysproClient object should be preferred. + def self.default_conn + # We're going to keep connections around so that we can take advantage + # of connection re-use, so make sure that we have a separate connection + # object per thread. + Thread.current[:syspro_client_default_conn] ||= begin + conn = Faraday.new do |c| + c.use Faraday::Request::Multipart + c.use Faraday::Request::UrlEncoded + c.use Faraday::Response::RaiseError + c.adapter Faraday.default_adapter + end + + #if Syspro.verify_ssl_certs + #conn.ssl.verify = true + #conn.ssl.cert_store = Syspro.ca_store + #else + conn.ssl.verify = false + + unless @verify_ssl_warned + @verify_ssl_warned = true + $stderr.puts("WARNING: Running without SSL cert verification. " \ + "You should never do this in production. " \ + "Execute 'Syspro.verify_ssl_certs = true' to enable verification.") + end + #end + + conn + end + end + + def execute_request(method, path, api_base: nil, headers: {}, params: {}) + api_base ||= Syspro.api_base + + params = Util.objects_to_ids(params) + url = api_url(path, api_base) + + body = nil + query_params = nil + + case method.to_s.downcase.to_sym + when :get, :head, :delete + query_params = params + else + body = if headers[:content_type] && headers[:content_type] == "multipart/form-data" + params + else + Util.encode_parameters(params) + end + end + + headers = request_headers(method) + .update(Util.normalize_headers(headers)) + + # stores information on the request we're about to make so that we don't + # have to pass as many parameters around for logging. + context = RequestLogContext.new + context.body = body + context.method = method + context.path = path + context.query_params = query_params ? Util.encode_parameters(query_params) : nil + + http_resp = execute_request_with_rescues(api_base, context) do + conn.run_request(method, url, body, headers) do |req| + req.options.open_timeout = Syspro.open_timeout + req.options.timeout = Syspro.read_timeout + req.params = query_params unless query_params.nil? + end + end + + begin + resp = SysproResponse.from_faraday_response(http_resp) + rescue JSON::ParserError + raise general_api_error(http_resp.status, http_resp.body) + end + + # Allows SysproClient#request to return a response object to a caller. + @last_response = resp + [resp] + end + + def general_api_error(status, body) + APIError.new("Invalid response object from API: #{body.inspect} " \ + "(HTTP response code was #{status})", + http_status: status, http_body: body) + end + + def api_url(url = "", api_base = nil) + (api_base || Syspro.api_base) + url + end + + def request_headers(method) + user_agent = "Syspro/7 RubyBindings/#{Syspro::VERSION}" + + headers = { + "User-Agent" => user_agent, + "Content-Type" => "application/x-www-form-urlencoded", + } + + headers + end + + def execute_request_with_rescues(api_base, context) + num_retries = 0 + begin + request_start = Time.now + log_request(context, num_retries) + resp = yield + context = context.dup_from_response(resp) + log_response(context, request_start, resp.status, resp.body) + + # We rescue all exceptions from a request so that we have an easy spot to + # implement our retry logic across the board. We'll re-raise if it's a type + # of exception that we didn't expect to handle. + rescue StandardError => e + # If we modify context we copy it into a new variable so as not to + # taint the original on a retry. + error_context = context + + if e.respond_to?(:response) && e.response + error_context = context.dup_from_response(e.response) + log_response(error_context, request_start, + e.response[:status], e.response[:body]) + else + log_response_error(error_context, request_start, e) + end + + if self.class.should_retry?(e, num_retries) + num_retries += 1 + sleep self.class.sleep_time(num_retries) + retry + end + + case e + when Faraday::ClientError + if e.response + handle_error_response(e.response, error_context) + else + handle_network_error(e, error_context, num_retries, api_base) + end + + # Only handle errors when we know we can do so, and re-raise otherwise. + # This should be pretty infrequent. + else + raise + end + end + + resp + end + + def self.should_retry?(e, num_retries) + # Retry on timeout-related problems (either on open or read). + return true if e.is_a?(Faraday::TimeoutError) + + # Destination refused the connection, the connection was reset, or a + # variety of other connection failures. This could occur from a single + # saturated server, so retry in case it's intermittent. + return true if e.is_a?(Faraday::ConnectionFailed) + + if e.is_a?(Faraday::ClientError) && e.response + # 409 conflict + return true if e.response[:status] == 409 + end + + false + end + + def log_request(context, num_retries) + Util.log_info("Request to Syspro API", + account: context.account, + api_version: context.api_version, + method: context.method, + num_retries: num_retries, + path: context.path) + Util.log_debug("Request details", + body: context.body, + query_params: context.query_params) + end + private :log_request + + def log_response(context, request_start, status, body) + Util.log_info("Response from Syspro API", + account: context.account, + api_version: context.api_version, + elapsed: Time.now - request_start, + method: context.method, + path: context.path, + request_id: context.request_id, + status: status) + Util.log_debug("Response details", + body: body, + request_id: context.request_id) + end + private :log_response + + def log_response_error(context, request_start, e) + Util.log_error("Request error", + elapsed: Time.now - request_start, + error_message: e.message, + method: context.method, + path: context.path) + end + private :log_response_error + + # RequestLogContext stores information about a request that's begin made so + # that we can log certain information. It's useful because it means that we + # don't have to pass around as many parameters. + class RequestLogContext + attr_accessor :body + attr_accessor :account + attr_accessor :api_version + attr_accessor :method + attr_accessor :path + attr_accessor :query_params + attr_accessor :request_id + + # The idea with this method is that we might want to update some of + # context information because a response that we've received from the API + # contains information that's more authoritative than what we started + # with for a request. For example, we should trust whatever came back in + # a `Stripe-Version` header beyond what configuration information that we + # might have had available. + def dup_from_response(resp) + return self if resp.nil? + + # Faraday's API is a little unusual. Normally it'll produce a response + # object with a `headers` method, but on error what it puts into + # `e.response` is an untyped `Hash`. + headers = if resp.is_a?(Faraday::Response) + resp.headers + else + resp[:headers] + end + + context = dup + context.account = headers["Stripe-Account"] + context.api_version = headers["Stripe-Version"] + context.request_id = headers["Request-Id"] + context + end + end + + # SystemProfiler extracts information about the system that we're running + # in so that we can generate a rich user agent header to help debug + # integrations. + class SystemProfiler + def self.uname + if File.exist?("/proc/version") + File.read("/proc/version").strip + else + case RbConfig::CONFIG["host_os"] + when /linux|darwin|bsd|sunos|solaris|cygwin/i + uname_from_system + when /mswin|mingw/i + uname_from_system_ver + else + "unknown platform" + end + end + end + + def self.uname_from_system + (`uname -a 2>/dev/null` || "").strip + rescue Errno::ENOENT + "uname executable not found" + rescue Errno::ENOMEM # couldn't create subprocess + "uname lookup failed" + end + + def self.uname_from_system_ver + (`ver` || "").strip + rescue Errno::ENOENT + "ver executable not found" + rescue Errno::ENOMEM # couldn't create subprocess + "uname lookup failed" + end + + def initialize + @uname = self.class.uname + end + + def user_agent + lang_version = "#{RUBY_VERSION} p#{RUBY_PATCHLEVEL} (#{RUBY_RELEASE_DATE})" + { + application: Syspro.app_info, + bindings_version: Syspro::VERSION, + lang: "ruby", + lang_version: lang_version, + platform: RUBY_PLATFORM, + engine: defined?(RUBY_ENGINE) ? RUBY_ENGINE : "", + uname: @uname, + hostname: Socket.gethostname, + }.delete_if { |_k, v| v.nil? } + end end end end diff --git a/lib/syspro/syspro_object.rb b/lib/syspro/syspro_object.rb new file mode 100644 index 0000000..7c761a4 --- /dev/null +++ b/lib/syspro/syspro_object.rb @@ -0,0 +1,101 @@ +module Syspro + class SysproObject + + # Re-initializes the object based on a hash of values (usually one that's + # come back from an API call). Adds or removes value accessors as necessary + # and updates the state of internal data. + # + # Protected on purpose! Please do not expose. + # + # ==== Options + # + # * +:values:+ Hash used to update accessors and values. + # * +:opts:+ Options for SysproObject like an API key. + # * +:partial:+ Indicates that the re-initialization should not attempt to + # remove accessors. + def initialize_from(values, opts, partial = false) + @opts = Util.normalize_opts(opts) + + # the `#send` is here so that we can keep this method private + @original_values = self.class.send(:deep_copy, values) + + removed = partial ? Set.new : Set.new(@values.keys - values.keys) + added = Set.new(values.keys - @values.keys) + + # Wipe old state before setting new. This is useful for e.g. updating a + # customer, where there is no persistent card parameter. Mark those values + # which don't persist as transient + + remove_accessors(removed) + add_accessors(added, values) + + removed.each do |k| + @values.delete(k) + @transient_values.add(k) + @unsaved_values.delete(k) + end + + update_attributes(values, opts, dirty: false) + values.each_key do |k| + @transient_values.delete(k) + @unsaved_values.delete(k) + end + + self + end + + def remove_accessors(keys) + # not available in the #instance_eval below + protected_fields = self.class.protected_fields + + metaclass.instance_eval do + keys.each do |k| + next if protected_fields.include?(k) + next if @@permanent_attributes.include?(k) + + # Remove methods for the accessor's reader and writer. + [k, :"#{k}=", :"#{k}?"].each do |method_name| + remove_method(method_name) if method_defined?(method_name) + end + end + end + end + + def add_accessors(keys, values) + # not available in the #instance_eval below + protected_fields = self.class.protected_fields + + metaclass.instance_eval do + keys.each do |k| + next if protected_fields.include?(k) + next if @@permanent_attributes.include?(k) + + if k == :method + # Object#method is a built-in Ruby method that accepts a symbol + # and returns the corresponding Method object. Because the API may + # also use `method` as a field name, we check the arity of *args + # to decide whether to act as a getter or call the parent method. + define_method(k) { |*args| args.empty? ? @values[k] : super(*args) } + else + define_method(k) { @values[k] } + end + + define_method(:"#{k}=") do |v| + if v == "" + raise ArgumentError, "You cannot set #{k} to an empty string. " \ + "We interpret empty strings as nil in requests. " \ + "You may set (object).#{k} = nil to delete the property." + end + @values[k] = Util.convert_to_stripe_object(v, @opts) + dirty_value!(@values[k]) + @unsaved_values.add(k) + end + + if [FalseClass, TrueClass].include?(values[k].class) + define_method(:"#{k}?") { @values[k] } + end + end + end + end + end +end diff --git a/lib/syspro/syspro_response.rb b/lib/syspro/syspro_response.rb new file mode 100644 index 0000000..2344d6b --- /dev/null +++ b/lib/syspro/syspro_response.rb @@ -0,0 +1,50 @@ +require "nokogiri" + +module Syspro + # SysproResponse encapsulates some vitals of a response that came back from + # the Syspro API. + class SysproResponse + # The data contained by the HTTP body of the response deserialized from + # JSON. + attr_accessor :data + + # The raw HTTP body of the response. + attr_accessor :http_body + + # A Hash of the HTTP headers of the response. + attr_accessor :http_headers + + # The integer HTTP status code of the response. + attr_accessor :http_status + + # The Syspro request ID of the response. + attr_accessor :request_id + + # Initializes a SysproResponse object from a Hash like the kind returned as + # part of a Faraday exception. + # + # This may throw JSON::ParserError if the response body is not valid JSON. + def self.from_faraday_hash(http_resp) + resp = SysproResponse.new + resp.data = Nokogiri::XML(http_resp[:body]) + resp.http_body = http_resp[:body] + resp.http_headers = http_resp[:headers] + resp.http_status = http_resp[:status] + resp.request_id = http_resp[:headers]["Request-Id"] + resp + end + + # Initializes a SysproResponse object from a Faraday HTTP response object. + # + # This may throw JSON::ParserError if the response body is not valid JSON. + def self.from_faraday_response(http_resp) + resp = SysproResponse.new + resp.data = Nokogiri::XML(http_resp[:body]) + resp.http_body = http_resp.body + resp.http_headers = http_resp.headers + resp.http_status = http_resp.status + resp.request_id = http_resp.headers["Request-Id"] + resp + end + end +end diff --git a/lib/syspro/util.rb b/lib/syspro/util.rb new file mode 100644 index 0000000..b86d2ad --- /dev/null +++ b/lib/syspro/util.rb @@ -0,0 +1,130 @@ +module Syspro + class Util + # Options that a user is allowed to specify. + OPTS_USER_SPECIFIED = Set[ + # :syspro_version + ].freeze + + + def self.objects_to_ids(h) + case h + when ApiResource + h.id + when Hash + res = {} + h.each { |k, v| res[k] = objects_to_ids(v) unless v.nil? } + res + when Array + h.map { |v| objects_to_ids(v) } + else + h + end + end + + # Converts a hash of fields or an array of hashes into a +SysproObject+ or + # array of +SysproObject+s. These new objects will be created as a concrete + # type as dictated by their `object` field (e.g. an `object` value of + # `charge` would create an instance of +Charge+), but if `object` is not + # present or of an unknown type, the newly created instance will fall back + # to being a +SysproObject+. + # + # ==== Attributes + # + # * +data+ - Hash of fields and values to be converted into a SysproObject. + # * +opts+ - Options for +SysproObject+ like an API key that will be reused + # on subsequent API calls. + def self.convert_to_syspro_object(data, opts = {}) + case data + when Array + data.map { |i| convert_to_syspro_object(i, opts) } + when Hash + # Try converting to a known object class. If none available, fall back to generic SysproObject + object_classes.fetch(data[:object], SysproObject).construct_from(data, opts) + else + data + end + end + + + # The secondary opts argument can either be a string or hash + # Turn this value into an api_key and a set of headers + def self.normalize_opts(opts) + case opts + when String + opts + when Hash + opts.clone + else + raise TypeError, "normalize_opts expects a string or a hash" + end + end + + # Normalizes header keys so that they're all lower case and each + # hyphen-delimited section starts with a single capitalized letter. For + # example, `request-id` becomes `Request-Id`. This is useful for extracting + # certain key values when the user could have set them with a variety of + # diffent naming schemes. + def self.normalize_headers(headers) + headers.each_with_object({}) do |(k, v), new_headers| + if k.is_a?(Symbol) + k = titlecase_parts(k.to_s.tr("_", "-")) + elsif k.is_a?(String) + k = titlecase_parts(k) + end + + new_headers[k] = v + end + end + + def self.encode_parameters(params) + Util.flatten_params(params) + .map { |k, v| "#{url_encode(k)}=#{url_encode(v)}" }.join("&") + end + + def self.flatten_params(params, parent_key = nil) + result = [] + + # do not sort the final output because arrays (and arrays of hashes + # especially) can be order sensitive, but do sort incoming parameters + params.each do |key, value| + calculated_key = parent_key ? "#{parent_key}[#{key}]" : key.to_s + if value.is_a?(Hash) + result += flatten_params(value, calculated_key) + elsif value.is_a?(Array) + check_array_of_maps_start_keys!(value) + result += flatten_params_array(value, calculated_key) + else + result << [calculated_key, value] + end + end + + result + end + + def self.log_error(message, data = {}) + if !Syspro.logger.nil? || + !Syspro.log_level.nil? && Syspro.log_level <= Syspro::LEVEL_ERROR + log_internal(message, data, color: :cyan, + level: Syspro::LEVEL_ERROR, logger: Syspro.logger, out: $stderr) + end + end + + def self.log_info(message, data = {}) + if !Syspro.logger.nil? || + !Syspro.log_level.nil? && Syspro.log_level <= Syspro::LEVEL_INFO + log_internal(message, data, color: :cyan, + level: Syspro::LEVEL_INFO, logger: Syspro.logger, out: $stdout) + end + end + + def self.log_debug(message, data = {}) + if !Syspro.logger.nil? || + !Syspro.log_level.nil? && Syspro.log_level <= Syspro::LEVEL_DEBUG + log_internal(message, data, color: :blue, + level: Syspro::LEVEL_DEBUG, logger: Syspro.logger, out: $stdout) + end + end + + end +end + diff --git a/syspro.gemspec b/syspro.gemspec index bd01ee7..a32fa3f 100644 --- a/syspro.gemspec +++ b/syspro.gemspec @@ -30,6 +30,7 @@ Gem::Specification.new do |spec| spec.add_dependency("faraday", "~> 0.10") spec.add_development_dependency "bundler", "~> 1.16" + spec.add_development_dependency "pry", "~> 0.11" spec.add_development_dependency "rake", "~> 10.0" spec.add_development_dependency "minitest", "~> 5.0" end diff --git a/test/client_test.rb b/test/client_test.rb index 7af0bb8..721ed8a 100644 --- a/test/client_test.rb +++ b/test/client_test.rb @@ -3,6 +3,6 @@ require "test_helper" class SysproClientTest < Minitest::Test def test_get_syspro_version client = ::Syspro::SysproClient.new - refute_nil client.get_syspro_version + assert_match /(\d+\.)?(\d+\.)?(\d+\.)?(\d+)/, client.get_syspro_version[0].http_body end end diff --git a/test/test_helper.rb b/test/test_helper.rb index fef5973..522c564 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,4 +1,5 @@ $LOAD_PATH.unshift File.expand_path("../../lib", __FILE__) require "syspro" +require "pry" require "minitest/autorun" -- libgit2 0.21.4