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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ba514d53e0d9f91bec62dbe6ff0687b73038ae223a19f266a6a11321fda19b54
4
+ data.tar.gz: 33ab21762119c6ca00ce6951fde0bedd45f6c9e719c4b175478592df47c7aadf
5
+ SHA512:
6
+ metadata.gz: e16ff38a23a7cb98796299f1259f009d94fb5ae2b28cf6db850e38b3f58f6a0ab89eec170d25a1b537569842497694ba74fb0fd660d91df609fc3b573c578675
7
+ data.tar.gz: 00ed090c391619a04d47dc39ca56d42f9bbdef1742bfa4ced62e009624508224b71e7677f4cac17a1c33e9c16b36e544c1c75b6ad07d35d1610b0b92ce80d678
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 John D. Tornow
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,129 @@
1
+ # ProviderKit
2
+
3
+ ProviderKit is a simple utility for creating an abstraction layer between various third-party services (usually via API) and your application's code.
4
+
5
+ It contains some conventions and utilities for making this easier. Think of it as a way to create simple wrappers on top of third-party APIs, but also a way to make those consistent across 'providers' of the same type.
6
+
7
+ For example, say you support payments from multiple third-party services like Stripe or PayPal. In each of those services we'll need to handle customers, subscriptions, and payments. Here's how we could interface with that using ProviderKit:
8
+
9
+ ```ruby
10
+ # customer 1 uses Stripe
11
+ user = User.create(provider: :stripe, ...)
12
+ user.provider.customers.get(id: user.customer_key) # => { id: "cus_Nff" }
13
+
14
+ # customer 2 uses PayPal
15
+ user2 = User.create(provider: :paypal)
16
+ user.provider.customers.get(id: user.customer_key) # => { id: "abc-123" }
17
+ ```
18
+
19
+ Then anywhere in your code where you have business logic that requires a customer the code is consistent, but each individual provider contains its custom code to deal with that provider's details. Here's what the 'get customer' logic could look like inside of a provider's configuration:
20
+
21
+ ```ruby
22
+ module StripeProvider
23
+ class Customers
24
+ def get(id:)
25
+ stripe_customer = Stripe::Customer.retrieve(id)
26
+
27
+ {
28
+ id: stripe_customer.id,
29
+ # ... etc
30
+ }
31
+ end
32
+ end
33
+ end
34
+ ```
35
+
36
+ This library was originally developed for this exact use case, but we use it for a bunch of different provider types including linking multiple CRM tools, ecommerce providers, and many more.
37
+
38
+ ## Generator
39
+
40
+ Use the generator to create a new provider:
41
+
42
+ ```bash
43
+ rails g provider my_provider_name
44
+ ```
45
+
46
+ ## Capabilities
47
+
48
+ Providers have 'capabilities' to determine what types of things they can do. Each provider registers those capabilities within the provider setup itself. For example, the `Customers` class above would likely be registered within the `StripeProvider` like this:
49
+
50
+ ```ruby
51
+ module StripeProvider
52
+ class Provider
53
+
54
+ include ProviderKit::Capable
55
+
56
+ capable_of :subscriptions, with: Customers
57
+
58
+ end
59
+ end
60
+ ```
61
+
62
+ This registration offers some nice reflection methods, so you can determine at runtime which features a particular provider allows. For example, Stripe has a customers [search endpoint](https://docs.stripe.com/api/customers/search) but PayPal does not. So we can build the search feature into `StripeProvider`:
63
+
64
+ ```ruby
65
+ module StripeProvider
66
+ class Customers
67
+ def search(query:)
68
+ customers = Stripe::Customer.search(query:)
69
+
70
+ customers.map do |customer|
71
+ {
72
+ id: customer.id,
73
+ name: customer.name,
74
+ # ...
75
+ }
76
+ end
77
+ end
78
+ end
79
+ end
80
+ ```
81
+
82
+ Then we can check it at runtime to make sure the provider is capable of this action:
83
+
84
+ ```ruby
85
+ if user.provider.capable_of?(:customers, :search)
86
+ user.customers.search(query: 'john@example.com')
87
+ else
88
+ []
89
+ end
90
+ ```
91
+
92
+ This is a primitive example but it's a powerful concept!
93
+
94
+ ## Credentials
95
+
96
+ Rails credentials can be used within the context of a provider's capability methods. By convention, store each provider's credentials in this format:
97
+
98
+ ```yaml
99
+ # rails credentials:edit
100
+
101
+ providers:
102
+ provider_name_here:
103
+ secret_key: abc123
104
+
105
+ stripe:
106
+ secret_key: sk_1234
107
+ ```
108
+
109
+ Then they can be used anywhere in a provider:
110
+
111
+ ```ruby
112
+ module StripeProvider
113
+ class Customers
114
+ def get(id:)
115
+ # you probably should put this in an initializer instead
116
+ # so it doesn't need to be in every method,
117
+ # but it does work!
118
+ Stripe.api_key = credentials.secret_key
119
+
120
+ # ...
121
+ end
122
+ end
123
+ end
124
+ ```
125
+
126
+ ## A Work In Progress
127
+
128
+ This gem has been in production since 2019, so it's battle tested, but not very well spec-tested. It's an ongoing work in progress. If you find it helpful, go for it, I'm very open to contributions. But it's presented as-is, and probably won't be very actively maintained other than ensuring it works with new versions of Ruby and Rails.
129
+
data/Rakefile ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+
5
+ APP_RAKEFILE = File.expand_path("spec/dummy/Rakefile", __dir__)
6
+
7
+ load "rails/tasks/engine.rake"
8
+
9
+ load "rails/tasks/statistics.rake"
10
+
11
+ require "bundler/gem_tasks"
@@ -0,0 +1,10 @@
1
+ Description:
2
+ generate a new providerkit provider
3
+
4
+ Example:
5
+ bin/rails generate provider CustomerIo
6
+
7
+ This will create:
8
+ app/providers/customer_io.rb
9
+ app/providers/customer_io/provider.rb
10
+ spec/providers/customer_io.rb
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ProviderGenerator < Rails::Generators::NamedBase
4
+
5
+ source_root File.expand_path("templates", __dir__)
6
+
7
+ argument :attributes, type: :array, default: [], banner: "capabilities"
8
+
9
+ class_option :type, type: :string, desc: "Assign a type to this provider"
10
+ class_option :types, type: :array, desc: "Assign multiple types to this provider"
11
+
12
+ def add_provider_initializer
13
+ if types.any?
14
+ insert_into_file "config/initializers/providers.rb", %Q(\nProviderKit.register :#{ provider_name }, class_name: "#{ class_name }::Provider", types: [ #{ types.map(&:inspect).join(", ") } ]\n)
15
+ else
16
+ insert_into_file "config/initializers/providers.rb", %Q(\nProviderKit.register :#{ provider_name }, class_name: "#{ class_name }::Provider"\n)
17
+ end
18
+ end
19
+
20
+ def create_provider_files
21
+ template "context.rb", File.join("app/providers/#{ singular_name }.rb")
22
+ template "provider.rb", File.join("app/providers/#{ singular_name }/provider.rb")
23
+
24
+ capabilities.each do |capability|
25
+ @capability = capability
26
+ template "capability.rb", File.join("app/providers/#{ singular_name }/capabilities/#{ capability.underscore }.rb")
27
+ end
28
+ end
29
+
30
+ def create_spec_file
31
+ template "spec.rb", File.join("spec/providers/#{ singular_name }_spec.rb")
32
+ end
33
+
34
+ private
35
+
36
+ attr_reader :capability
37
+
38
+ def capabilities
39
+ @capabilities ||= attributes_names.map do |c|
40
+ c.to_s.classify.pluralize
41
+ end.sort
42
+ end
43
+
44
+ def provider_name
45
+ singular_name.to_s.gsub("_", "")
46
+ end
47
+
48
+ def types
49
+ @types ||= begin
50
+ types = options[:type].presence || options[:types].presence || []
51
+ types = Array(types).flatten.compact.map(&:to_sym)
52
+ end
53
+ end
54
+
55
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module <%= class_name %>
4
+ module Capabilities
5
+ class <%= capability %>
6
+
7
+ # capability methods here
8
+
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module <%= class_name %>
4
+
5
+ include ProviderKit::Context
6
+
7
+ # Change this if you want the provider name to be something else
8
+ def self.provider_key
9
+ :<%= provider_name %>
10
+ end
11
+
12
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module <%= class_name %>
4
+ class Provider
5
+
6
+ include ProviderKit::Capable
7
+
8
+ # Use credentials for the Current.account when set
9
+ # include AccountCredentiable
10
+ <% capabilities.each do |capability| %>
11
+ capable_of :<%= capability.underscore %>, with: Capabilities::<%= capability %><% end %>
12
+ end
13
+ end
@@ -0,0 +1,34 @@
1
+ require "rails_helper"
2
+
3
+ RSpec.describe <%= class_name %> do
4
+
5
+ describe ".provider" do
6
+ it "is the provider" do
7
+ expect(<%= class_name %>.provider).to be_kind_of(<%= class_name %>::Provider)
8
+ end
9
+ end
10
+
11
+ describe ".provider_key" do
12
+ it "is :<%= provider_name %>" do
13
+ expect(<%= class_name %>.provider_key).to eq(:<%= provider_name %>)
14
+ end
15
+ end<% if types.any? %>
16
+
17
+ describe ".provider_types" do<% types.each do |type| %>
18
+ it "includes <%= type %>" do
19
+ expect(<%= class_name %>.provider_types).to include(:<%= type %>)
20
+ expect(ProviderKit.providers(type: :<%= type %>)).to include(<%= class_name %>::Provider)
21
+ end
22
+ <% end %>
23
+ end<% end %><% capabilities.each do |capability| %>
24
+
25
+ context ".<%= capability.underscore %>" do
26
+ it "is a capability" do
27
+ expect(<%= class_name %>.capable_of?(:<%= capability.underscore %>)).to eq(true)
28
+ expect(<%= class_name %>.<%= capability.underscore %>).to be_kind_of(ProviderKit::Capability)
29
+ end
30
+
31
+ pending "fill in <%= class_name %>.<%= capability.underscore %> methods here"
32
+ end<% end %>
33
+
34
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ProviderKit
4
+ # Designed to add provider utility methods to module
5
+ module Buildable
6
+
7
+ # ProviderKit.with(:stripe).subscriptions.get(id: "123")
8
+ def with(key_or_record)
9
+ provider = ProviderKit::Provider.new(key_or_record)
10
+ return nil unless provider.present?
11
+
12
+ provider.provider_instance
13
+ end
14
+
15
+ end
16
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/callbacks"
4
+
5
+ module ProviderKit
6
+ # Add callback behavior to services for logging and lifecycle checks
7
+ module Callbacks
8
+
9
+ extend ActiveSupport::Concern
10
+ include ActiveSupport::Callbacks
11
+
12
+ included do
13
+ define_callbacks :perform
14
+ end
15
+
16
+ module ClassMethods
17
+
18
+ def after_perform(*filters, &blk)
19
+ set_callback(:perform, :after, *filters, &blk)
20
+ end
21
+
22
+ def around_perform(*filters, &blk)
23
+ set_callback(:perform, :around, *filters, &blk)
24
+ end
25
+
26
+ def before_perform(*filters, &blk)
27
+ set_callback(:perform, :before, *filters, &blk)
28
+ end
29
+
30
+ end
31
+
32
+ end
33
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ProviderKit
4
+ class Capability
5
+
6
+ attr_reader :key, :provider
7
+
8
+ def initialize(key, target_klass, provider: nil, **options)
9
+ @key = key
10
+ @target_klass = target_klass.to_s
11
+ @options = options || {}
12
+ @provider = provider
13
+ @context = options.delete(:context)
14
+ end
15
+
16
+ def callable?(method_name)
17
+ callable_methods.include?(method_name.to_s)
18
+ end
19
+
20
+ def method_missing(method_name, *args, **kwargs)
21
+ case method_name.to_s
22
+ when /(.+)\?$/
23
+ call($1, *args, **kwargs) == true
24
+ else
25
+ call(method_name, *args, **kwargs)
26
+ end
27
+ end
28
+
29
+ def with_context(provider: @provider, **context)
30
+ Capability.new(
31
+ key,
32
+ @target_klass,
33
+ provider: provider.with_context(**context),
34
+ **options.merge(context:)
35
+ )
36
+ end
37
+
38
+ private
39
+
40
+ attr_reader :context
41
+ attr_reader :options
42
+
43
+ def call(method_name, *args, **kwargs)
44
+ call_target = target
45
+ return nil unless call_target
46
+ return nil unless callable?(method_name)
47
+
48
+ raw_response = call_target.public_send(method_name, *args, **kwargs)
49
+
50
+ case raw_response
51
+ when Hash
52
+ format_response_hash(raw_response)
53
+ when Array
54
+ raw_response.map { |h| format_response_hash(h) }
55
+ else
56
+ raw_response
57
+ end
58
+ end
59
+
60
+ # Creates a safe list of methods that can be called on the target
61
+ # Only those methods that were defined in the class are valid
62
+ def callable_methods
63
+ @callable_methods ||= begin
64
+ blacklist = Object.public_instance_methods
65
+ whitelist = target.class.public_instance_methods - blacklist
66
+
67
+ Set.new(whitelist.map(&:to_s))
68
+ end
69
+ end
70
+
71
+ def format_response_hash(raw_response)
72
+ return raw_response unless raw_response.is_a?(Hash)
73
+
74
+ klass = ProviderKit.config.capability_response_format
75
+ klass = Settings if klass == :object
76
+
77
+ if klass.is_a?(Class)
78
+ klass.new(raw_response)
79
+ else
80
+ raw_response
81
+ end
82
+ end
83
+
84
+ def prepare_target
85
+ klass = @target_klass
86
+
87
+ if String === klass
88
+ klass = klass.safe_constantize
89
+ end
90
+
91
+ unless klass.respond_to?(:new)
92
+ raise ProviderKit::InvalidCapability.new("Invalid capability class: #{ @target_klass }")
93
+ end
94
+
95
+ klass
96
+ end
97
+
98
+ def target_class
99
+ @target = nil unless Rails.application.config.cache_classes
100
+ @target ||= prepare_target
101
+ end
102
+
103
+ def target
104
+ unless target_class.included_modules.include?(CapabilityExtension)
105
+ target_class.send(:include, CapabilityExtension)
106
+ end
107
+
108
+ instance = if options.present?
109
+ target_class.new(options)
110
+ else
111
+ target_class.new
112
+ end
113
+
114
+ instance.instance_variable_set("@context", context)
115
+ instance.instance_variable_set("@provider", provider)
116
+
117
+ instance
118
+ end
119
+
120
+ end
121
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ProviderKit
4
+ module CapabilityExtension
5
+
6
+ extend ActiveSupport::Concern
7
+
8
+ private
9
+
10
+ attr_reader :provider
11
+ attr_reader :context
12
+
13
+ def config
14
+ provider&.config
15
+ end
16
+
17
+ def credentials
18
+ provider&.credentials
19
+ end
20
+
21
+ end
22
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ProviderKit
4
+ # Mixin for a model to make it have the provider methods
5
+ module Capable
6
+
7
+ extend ActiveSupport::Concern
8
+
9
+ included do
10
+ attr_accessor :context
11
+
12
+ mattr_accessor :capabilities
13
+ mattr_accessor :key
14
+
15
+ self.capabilities ||= {}
16
+
17
+ delegate :capable_of?, :perform_capability, to: self
18
+ end
19
+
20
+ def config
21
+ registration&.config
22
+ end
23
+
24
+ def credentials
25
+ registration&.credentials
26
+ end
27
+
28
+ def display_key
29
+ registration&.display_key
30
+ end
31
+
32
+ def method_missing(method_name, *args, **kwargs)
33
+ if capable_of?(method_name)
34
+ if kwargs.present?
35
+ perform_capability(method_name).with_context(provider: self, **kwargs)
36
+ else
37
+ perform_capability(method_name).with_context(provider: self, **(context || {}))
38
+ end
39
+ else
40
+ super
41
+ end
42
+ end
43
+
44
+ def provider_key
45
+ key
46
+ end
47
+
48
+ def with_context(**context)
49
+ self.context = context
50
+ self
51
+ end
52
+
53
+ class_methods do
54
+
55
+ def capable_of(namespace, with:, **options)
56
+ self.capabilities[namespace.to_sym] = Capability.new(namespace, with, **options)
57
+ end
58
+
59
+ def capable_of?(thing, method_name = nil)
60
+ return false unless self.capabilities.has_key?(thing.to_sym)
61
+ return true unless method_name.present?
62
+
63
+ capability = capabilities[thing]
64
+ return false unless capability
65
+
66
+ capability.callable?(method_name)
67
+ end
68
+
69
+ def method_missing(method_name, *args, **kwargs)
70
+ if capable_of?(method_name)
71
+ perform_capability(method_name, *args, **kwargs)
72
+ else
73
+ super
74
+ end
75
+ end
76
+
77
+ def perform_capability(name, **kwargs)
78
+ capabilities[name].with_context(provider:, **kwargs)
79
+ end
80
+
81
+ def provider
82
+ provider_key = key.presence || module_parent&.provider_key
83
+ ProviderKit.with(provider_key)
84
+ end
85
+
86
+ def with_context(**context)
87
+ instance = new()
88
+ instance.context = context
89
+ instance
90
+ end
91
+
92
+ end
93
+
94
+ private
95
+
96
+ def registration
97
+ @registration ||= ProviderKit.registrations[key]
98
+ end
99
+
100
+ end
101
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ProviderKit
4
+ # Designed to add provider utility methods to module
5
+ module Context
6
+
7
+ extend ActiveSupport::Concern
8
+
9
+ class_methods do
10
+
11
+ def capabilities
12
+ provider.capabilities.keys
13
+ end
14
+
15
+ def capable_of?(thing, method_name = nil)
16
+ provider.capable_of?(thing, method_name)
17
+ end
18
+
19
+ def config
20
+ provider_registration.config
21
+ end
22
+
23
+ def credentials
24
+ provider_registration.credentials
25
+ end
26
+
27
+ def method_missing(method_name, **kwargs)
28
+ if capable_of?(method_name)
29
+ return provider.perform_capability(method_name, **kwargs)
30
+ end
31
+
32
+ super
33
+ end
34
+
35
+ def provider
36
+ @provider ||= ProviderKit.with(provider_key)
37
+ end
38
+
39
+ def provider_key
40
+ @provider_key ||= self.to_s.underscore.gsub(/_provider$/, "").to_sym
41
+ end
42
+
43
+ def provider_registration
44
+ ProviderKit.registrations[provider_key]
45
+ end
46
+
47
+ def provider_types
48
+ provider_registration.types
49
+ end
50
+
51
+ def with_context(**kwargs)
52
+ provider.with_context(**kwargs)
53
+ end
54
+
55
+ end
56
+
57
+ end
58
+ end