provider_kit 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ProviderKit
4
+ # Serializable object to read/write from account settings fields (encrypted)
5
+ class EncryptedSettings < Settings
6
+
7
+ def to_value
8
+ encrypt_data_for_storage
9
+ end
10
+
11
+ def self.dump(data)
12
+ case data
13
+ when self
14
+ data.to_value
15
+ else
16
+ new(data).to_value
17
+ end
18
+ end
19
+
20
+ def self.load(data)
21
+ new(data.presence)
22
+ end
23
+
24
+ private
25
+
26
+ def build_data(data)
27
+ if Hash === data
28
+ data
29
+ elsif decrypted = decrypt_data(data)
30
+ JSON.parse(decrypted)
31
+ end
32
+ end
33
+
34
+ def decrypt_data(encrypted_data)
35
+ if encrypted_data.present?
36
+ ProviderKit::Encryptor.shared.decrypt(encrypted_data)
37
+ end
38
+ end
39
+
40
+ def encrypt_data_for_storage
41
+ if data.present?
42
+ ProviderKit::Encryptor.shared.encrypt(data.to_json)
43
+ end
44
+ end
45
+
46
+ end
47
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ProviderKit
4
+ # Wrapper around ActiveSupport::MessageEncryptor with a shared credential key
5
+ class Encryptor
6
+
7
+ def decrypt(encrypted_value, purpose: :provider)
8
+ crypt.decrypt_and_verify(encrypted_value, purpose:)
9
+ rescue ActiveSupport::MessageVerifier::InvalidSignature
10
+ nil
11
+ end
12
+
13
+ def encrypt(raw_value, purpose: :provider)
14
+ crypt.encrypt_and_sign(raw_value, purpose:)
15
+ end
16
+
17
+ def self.shared
18
+ @shared ||= new
19
+ end
20
+
21
+ private
22
+
23
+ def crypt
24
+ @crypt ||= ActiveSupport::MessageEncryptor.new(key)
25
+ end
26
+
27
+ def key
28
+ ProviderKit.config.credentials_key.byteslice(0, 32)
29
+ end
30
+
31
+ end
32
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support"
4
+
5
+ module ProviderKit
6
+ class Engine < ::Rails::Engine
7
+
8
+ isolate_namespace ProviderKit
9
+
10
+ engine_name "provider_kit"
11
+
12
+ initializer "provider_kit.load_credentials_key" do |app|
13
+ ProviderKit.configure do |config|
14
+ credentials_key = ENV["PROVIDERKIT_KEY"].presence || Rails.application.credentials.secret_key_base.presence || Rails.application.credentials.secret_key_base
15
+ config.credentials_key ||= credentials_key
16
+ end
17
+ end
18
+
19
+ end
20
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ProviderKit
4
+
5
+ module Exceptions
6
+ end
7
+
8
+ class InvalidCapability < StandardError; end
9
+ class InvalidTask < StandardError; end
10
+ class InvalidProviderContext < StandardError; end
11
+
12
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/rescuable"
4
+
5
+ module ProviderKit
6
+ # Handles the execution of Tasks
7
+ #
8
+ # Includes callbacks for the perform method, so the following callbacks are available:
9
+ #
10
+ # * before_perform
11
+ # * around_perform
12
+ # * after_perform
13
+ #
14
+ # Also included is Rescuable, so an individual task can be rescued like a job:
15
+ #
16
+ # rescue_from InvalidProviderContext do
17
+ # logger.warn "Provider context was not found for this task"
18
+ # end
19
+ #
20
+ module Execution
21
+
22
+ extend ActiveSupport::Concern
23
+ include ActiveSupport::Rescuable
24
+
25
+ def perform_now
26
+ run_callbacks :perform do
27
+ perform
28
+ end
29
+ rescue => exception
30
+ tag_logger(self.class.name, process_id) do
31
+ rescue_with_handler(exception) || raise
32
+ end
33
+ end
34
+
35
+ def perform
36
+ fail NotImplementedError
37
+ end
38
+
39
+ end
40
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ProviderKit
4
+ # Generic interface for working with JSON-based APIs.
5
+ #
6
+ # This class is just a simple wrapper around net/http so we don't need
7
+ # to use a third-party networking library for basic stuff.
8
+ class JsonClient
9
+
10
+ DEFAULT_HEADERS = {
11
+ "Accept" => "application/json",
12
+ "User-Agent" => "ProviderKitBot/1.0 (+https://example.com)"
13
+ }
14
+
15
+ attr_reader :base_url
16
+ attr_reader :default_headers
17
+ attr_reader :default_params
18
+ attr_reader :default_mode
19
+
20
+ def initialize(base_url, **options)
21
+ @base_url = base_url
22
+ @default_headers = options[:headers].presence || DEFAULT_HEADERS
23
+ @default_params = options[:params].presence || {}
24
+ @default_mode = options[:mode].presence || :json
25
+ end
26
+
27
+ %w( get post patch put delete ).each do |http_method|
28
+ class_eval <<-CODE, __FILE__, __LINE__ + 1
29
+ def #{ http_method }(path, params: {}, headers: {}, mode: nil)
30
+ req = request(path, method: :#{ http_method }, params: params, headers: headers, mode: mode)
31
+ req.json
32
+ end
33
+ CODE
34
+ end
35
+
36
+ def request(path, method: :get, params: {}, headers: {}, mode: nil)
37
+ path = "/#{ path }".gsub(%r{//}, "/")
38
+ url = "#{ base_url }#{ path }"
39
+ headers = default_headers.merge(headers)
40
+ params = default_params.merge(params)
41
+ mode = mode.presence || default_mode
42
+
43
+ JsonRequest.new(url, method:, params:, headers:, mode:)
44
+ end
45
+
46
+ private
47
+
48
+ # Provide a generic log subscriber for basic request details.
49
+ #
50
+ # A more detailed log subscriber could be configured to log the request/response data too if needed later
51
+ class LogSubscriber < ActiveSupport::LogSubscriber
52
+
53
+ def perform_start(event)
54
+ info do
55
+ request = event.payload[:request]
56
+
57
+ "Performing json request (Request ID: #{ request.process_id }) #{ request.method.to_s.upcase } #{ request.url }"
58
+ end
59
+ end
60
+
61
+ def perform(event)
62
+ request = event.payload[:request]
63
+ ex = event.payload[:exception_object]
64
+
65
+ if ex
66
+ error do
67
+ "Error performing json request (Request ID: #{ request.process_id }) in #{ event.duration.round(2) }ms: #{ ex.class } (#{ ex.message }):\n" + Array(ex.backtrace).join("\n")
68
+ end
69
+ else
70
+ info do
71
+ "Performed json request (Request ID: #{ request.process_id }) in #{ event.duration.round(2) }ms (#{ request.method.to_s.upcase } #{ request.url })"
72
+ end
73
+ end
74
+ end
75
+
76
+ end
77
+
78
+ end
79
+ end
80
+
81
+ ProviderKit::JsonClient::LogSubscriber.attach_to :provider_kit_json_client
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ProviderKit
4
+ # Generic interface for working with http request/response and json
5
+ class JsonRequest
6
+
7
+ include ProviderKit::Callbacks
8
+ include ProviderKit::Execution
9
+
10
+ attr_reader :url
11
+ attr_reader :method
12
+ attr_reader :params
13
+ attr_reader :headers
14
+ attr_reader :mode
15
+ attr_reader :body
16
+ attr_reader :process_id
17
+
18
+ attr_reader :http_request
19
+ attr_reader :http_response
20
+
21
+ around_perform do |request, block, _|
22
+ tag_logger(request.process_id) do
23
+ payload = { request: }
24
+ ActiveSupport::Notifications.instrument("perform_start.provider_kit_json_client", payload.dup)
25
+ ActiveSupport::Notifications.instrument("perform.provider_kit_json_client", payload) do
26
+ block.call
27
+ end
28
+ end
29
+ end
30
+
31
+ def initialize(url, method: :get, params: {}, headers: {}, mode: :json)
32
+ @url = url
33
+ @method = method
34
+ @params = params
35
+ @headers = headers
36
+ @mode = mode
37
+ @process_id = SecureRandom.uuid
38
+
39
+ prepare_request
40
+ perform_now
41
+ end
42
+
43
+ def json
44
+ JSON.parse(body)
45
+ rescue
46
+ nil
47
+ end
48
+
49
+ def success?
50
+ (200..299).include?(status)
51
+ end
52
+
53
+ def status
54
+ http_response.code.to_i
55
+ end
56
+
57
+ def uri
58
+ @uri ||= URI(url)
59
+ end
60
+
61
+ private
62
+
63
+ def http
64
+ @http ||= begin
65
+ http = Net::HTTP.new(uri.host, uri.port)
66
+
67
+ if uri.port == 443
68
+ http.use_ssl = true
69
+ http.verify_mode = OpenSSL::SSL::VERIFY_NONE
70
+ end
71
+
72
+ http
73
+ end
74
+ end
75
+
76
+ def logger
77
+ Rails.logger
78
+ end
79
+
80
+ def tag_logger(*tags)
81
+ tags.unshift("JsonRequest")
82
+
83
+ logger.tagged(*tags) { yield }
84
+ end
85
+
86
+ # Thanks
87
+ # https://github.com/rest-client/rest-client/blob/master/lib/restclient/request.rb#L600
88
+ def parse_body!
89
+ return unless http_response.present?
90
+
91
+ body = http_response.body
92
+
93
+ content_encoding = http_response["content-encoding"]
94
+
95
+ @body = if (!body) || body.empty?
96
+ body
97
+ elsif content_encoding == "gzip"
98
+ Zlib::GzipReader.new(StringIO.new(body)).read
99
+ elsif content_encoding == "deflate"
100
+ begin
101
+ Zlib::Inflate.new.inflate(body)
102
+ rescue Zlib::DataError
103
+ Zlib::Inflate.new(-Zlib::MAX_WBITS).inflate(body)
104
+ end
105
+ else
106
+ body
107
+ end
108
+ end
109
+
110
+ def perform
111
+ return @http_response if @http_response
112
+
113
+ @http_response = http.request(http_request)
114
+ parse_body!
115
+ http_response
116
+ end
117
+
118
+ def prepare_request
119
+ case method
120
+ when :get
121
+ uri.query = URI.encode_www_form(params)
122
+ @http_request = Net::HTTP::Get.new(uri)
123
+ when :post
124
+ @http_request = Net::HTTP::Post.new(uri)
125
+ set_request_body!
126
+ when :delete
127
+ @http_request = Net::HTTP::Delete.new(uri)
128
+ set_request_body!
129
+ when :patch
130
+ @http_request = Net::HTTP::Patch.new(uri)
131
+ set_request_body!
132
+ when :put
133
+ @http_request = Net::HTTP::Put.new(uri)
134
+ set_request_body!
135
+ end
136
+
137
+ return nil unless http_request
138
+
139
+ headers.each do |key, value|
140
+ http_request[key] = value
141
+ end
142
+ end
143
+
144
+ def set_request_body!
145
+ return unless params.present?
146
+
147
+ if use_json_body?
148
+ http_request.body = params.to_json
149
+ http_request["Content-Type"] = "application/json"
150
+ else
151
+ http_request.set_form_data(params.stringify_keys)
152
+ end
153
+ end
154
+
155
+ def use_json_body?
156
+ mode == :json
157
+ end
158
+
159
+ end
160
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ProviderKit
4
+ # Utility class to log warnings and failures found during data imports
5
+ module Logging
6
+
7
+ extend ActiveSupport::Concern
8
+
9
+ included do
10
+ cattr_accessor :logger, default: ActiveSupport::TaggedLogging.new(Rails.logger)
11
+
12
+ around_perform do |task, block, _|
13
+ tag_logger(task.class.name, task.process_id) do
14
+ payload = {
15
+ provider: task.provider&.key.presence || "unknown provider",
16
+ task:
17
+ }
18
+
19
+ ActiveSupport::Notifications.instrument("perform_start.provider_kit_tasks", payload.dup)
20
+
21
+ ActiveSupport::Notifications.instrument("perform.provider_kit_tasks", payload) do
22
+ block.call
23
+ end
24
+ end
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def logger
31
+ Rails.logger
32
+ end
33
+
34
+ def tag_logger(*tags)
35
+ tags.unshift(provider.key)
36
+ tags.unshift("Provider")
37
+
38
+ logger.tagged(*tags) { yield }
39
+ end
40
+
41
+ class LogSubscriber < ActiveSupport::LogSubscriber
42
+
43
+ def perform_start(event)
44
+ info do
45
+ task = event.payload[:task]
46
+ provider = event.payload[:provider]
47
+
48
+ "Performing #{ task.class.name } (Process ID: #{ task.process_id }) for #{ provider }"
49
+ end
50
+ end
51
+
52
+ def perform(event)
53
+ task = event.payload[:task]
54
+ provider = event.payload[:provider]
55
+ ex = event.payload[:exception_object]
56
+
57
+ if ex
58
+ error do
59
+ "Error performing #{ task.class.name } (Process ID: #{ task.process_id }) for #{ provider } in #{ event.duration.round(2) }ms: #{ ex.class } (#{ ex.message }):\n" + Array(ex.backtrace).join("\n")
60
+ end
61
+ else
62
+ info do
63
+ "Performed #{ task.class.name } (Process ID: #{ task.process_id }) for #{ provider } in #{ event.duration.round(2) }ms"
64
+ end
65
+ end
66
+ end
67
+
68
+ end
69
+
70
+ end
71
+ end
72
+
73
+ ProviderKit::Logging::LogSubscriber.attach_to :provider_kit_tasks
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ProviderKit
4
+ # Shortcut for passing an object into a provider
5
+ module Provideable
6
+
7
+ extend ActiveSupport::Concern
8
+
9
+ included do
10
+ validate :validate_provider_presence
11
+ end
12
+
13
+ def provider
14
+ if provider_key.present?
15
+ @provider ||= ProviderKit::ProviderAttribute.new(provider_key, record: self)
16
+ end
17
+ end
18
+
19
+ def provider=(value)
20
+ @provider = nil
21
+
22
+ write_attribute(:provider, value.to_s.presence)
23
+ end
24
+
25
+ def provider_key
26
+ read_attribute(:provider).to_s.presence
27
+ end
28
+
29
+ private
30
+
31
+ def validate_provider_presence
32
+ if provider.present? && provider_key != "default"
33
+ return
34
+ end
35
+
36
+ errors.add :provider, :blank
37
+ end
38
+
39
+ end
40
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ProviderKit
4
+ # Given an object, try and find its registered data provider
5
+ #
6
+ # Or directly pass a provider key
7
+ class Provider
8
+
9
+ attr_reader :record
10
+
11
+ def initialize(record)
12
+ @record = record
13
+ end
14
+
15
+ def key
16
+ record_provider.presence || record_source.presence
17
+ end
18
+
19
+ def present?
20
+ provider.present?
21
+ end
22
+
23
+ def provider
24
+ return nil unless registration
25
+
26
+ registration.klass
27
+ end
28
+
29
+ def provider_instance
30
+ return nil unless provider
31
+
32
+ instance = provider.new
33
+
34
+ if instance.respond_to?(:context=) && record != key
35
+ instance.context = record
36
+ end
37
+
38
+ instance
39
+ end
40
+
41
+ def registration
42
+ if key.present?
43
+ ProviderKit.registrations[key.to_sym]
44
+ end
45
+ end
46
+
47
+ private
48
+
49
+ def record_provider
50
+ if record.respond_to?(:provider)
51
+ record.provider
52
+ end
53
+ end
54
+
55
+ def record_source
56
+ if Symbol === record
57
+ record
58
+ end
59
+ end
60
+
61
+ end
62
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ProviderKit
4
+ # serializer for a provider attribute turned into a Provider instance
5
+ class ProviderAttribute
6
+
7
+ include Buildable
8
+
9
+ attr_reader :key
10
+ attr_reader :record
11
+ attr_reader :context
12
+
13
+ def initialize(key, record: nil, **context)
14
+ @key = (key.to_s.presence || "default").to_sym
15
+ @record = record
16
+ @context = context.reverse_merge(default_context)
17
+ end
18
+
19
+ def ==(other_key)
20
+ to_s == other_key.to_s
21
+ end
22
+
23
+ def inspect
24
+ @key.inspect
25
+ end
26
+
27
+ def method_missing(method_name, *args)
28
+ return nil unless provider.present?
29
+
30
+ if provider.respond_to?(method_name)
31
+ return provider.public_send(method_name, *args)
32
+ end
33
+
34
+ # self.amazon_selling_partner? ==> self.key == :amazon_selling_partner
35
+ if match = method_name.to_s.match(/^(?<attribute>[a-z0-9_]+)\?$/)
36
+ return match[:attribute].to_clean_sym == key
37
+ end
38
+
39
+ provider.with_context(**context).public_send(method_name, *args)
40
+ end
41
+
42
+ def label
43
+ name
44
+ end
45
+
46
+ def name
47
+ registration&.name.presence || key.to_s.titleize
48
+ end
49
+
50
+ def options
51
+ registration&.options.presence || {}
52
+ end
53
+
54
+ def present?
55
+ provider.present?
56
+ end
57
+
58
+ def registration
59
+ if present?
60
+ ProviderKit.registrations[key.to_sym]
61
+ end
62
+ end
63
+
64
+ def to_s
65
+ key.to_s
66
+ end
67
+
68
+ def with_context(**context)
69
+ @context = context
70
+ self
71
+ end
72
+
73
+ def self.dump(instance)
74
+ case instance
75
+ when self
76
+ instance.to_s
77
+ else
78
+ new(instance).to_s
79
+ end
80
+ end
81
+
82
+ def self.load(str)
83
+ new(str)
84
+ end
85
+
86
+ private
87
+
88
+ def default_context
89
+ _context = {}
90
+ _context[record.model_name.param_key.to_sym] = record if record.present?
91
+ _context
92
+ end
93
+
94
+ def provider
95
+ @provider ||= with(key)
96
+ end
97
+
98
+ end
99
+ end