injectable 0.0.5 → 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +11 -0
- data/.rspec +3 -0
- data/.travis.yml +7 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +4 -0
- data/Gemfile.lock +35 -0
- data/LICENSE.txt +21 -0
- data/README.md +291 -142
- data/Rakefile +2 -26
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/injectable.gemspec +28 -0
- data/lib/injectable.rb +57 -46
- data/lib/injectable/class_methods.rb +157 -0
- data/lib/injectable/dependencies_graph.rb +43 -0
- data/lib/injectable/dependencies_proxy.rb +29 -0
- data/lib/injectable/dependency.rb +40 -0
- data/lib/injectable/instance_methods.rb +53 -0
- data/lib/injectable/missing_dependencies_exception.rb +4 -0
- data/lib/injectable/version.rb +1 -2
- metadata +82 -31
- data/LICENSE +0 -20
- data/lib/injectable/container.rb +0 -127
- data/lib/injectable/inflector.rb +0 -30
- data/lib/injectable/macros.rb +0 -60
- data/lib/injectable/registerable.rb +0 -26
- data/lib/injectable/registry.rb +0 -89
data/Rakefile
CHANGED
@@ -1,30 +1,6 @@
|
|
1
|
-
require "bundler"
|
2
|
-
Bundler.setup
|
3
|
-
|
4
|
-
require "rake"
|
1
|
+
require "bundler/gem_tasks"
|
5
2
|
require "rspec/core/rake_task"
|
6
3
|
|
7
|
-
|
8
|
-
require "injectable/version"
|
9
|
-
|
10
|
-
task :gem => :build
|
11
|
-
task :build do
|
12
|
-
system "gem build injectable.gemspec"
|
13
|
-
end
|
14
|
-
|
15
|
-
task :install => :build do
|
16
|
-
system "sudo gem install injectable-#{Injectable::VERSION}.gem"
|
17
|
-
end
|
18
|
-
|
19
|
-
task :release => :build do
|
20
|
-
system "git tag -a v#{Injectable::VERSION} -m 'Tagging #{Injectable::VERSION}'"
|
21
|
-
system "git push --tags"
|
22
|
-
system "gem push injectable-#{Injectable::VERSION}.gem"
|
23
|
-
system "rm injectable-#{Injectable::VERSION}.gem"
|
24
|
-
end
|
25
|
-
|
26
|
-
RSpec::Core::RakeTask.new("spec") do |spec|
|
27
|
-
spec.pattern = "spec/**/*_spec.rb"
|
28
|
-
end
|
4
|
+
RSpec::Core::RakeTask.new(:spec)
|
29
5
|
|
30
6
|
task :default => :spec
|
data/bin/console
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require "bundler/setup"
|
4
|
+
require "injectable"
|
5
|
+
|
6
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
7
|
+
# with your gem easier. You can also use a different console, if you like.
|
8
|
+
|
9
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
10
|
+
# require "pry"
|
11
|
+
# Pry.start
|
12
|
+
|
13
|
+
require "irb"
|
14
|
+
IRB.start(__FILE__)
|
data/bin/setup
ADDED
data/injectable.gemspec
ADDED
@@ -0,0 +1,28 @@
|
|
1
|
+
|
2
|
+
lib = File.expand_path("../lib", __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require "injectable/version"
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = 'injectable'
|
8
|
+
spec.version = Injectable::VERSION
|
9
|
+
spec.authors = %w(Papipo iovis9 jantequera amrocco)
|
10
|
+
spec.email = %w(rodrigo@rubiconmd.com david.marchante@rubiconmd.com julio@rubiconmd.com anthony@rubiconmd.com)
|
11
|
+
|
12
|
+
spec.summary = %q{A library to help you build nice service objects with dependency injection.}
|
13
|
+
spec.homepage = 'https://github.com/rubiconmd/injectable'
|
14
|
+
spec.license = "MIT"
|
15
|
+
|
16
|
+
# Specify which files should be added to the gem when it is released.
|
17
|
+
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
18
|
+
spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
|
19
|
+
`git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
20
|
+
end
|
21
|
+
spec.bindir = 'exe'
|
22
|
+
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
23
|
+
spec.require_paths = ['lib']
|
24
|
+
|
25
|
+
spec.add_development_dependency 'bundler', '~> 2.0'
|
26
|
+
spec.add_development_dependency 'rake', '~> 12.0'
|
27
|
+
spec.add_development_dependency 'rspec', '~> 3.0'
|
28
|
+
end
|
data/lib/injectable.rb
CHANGED
@@ -1,51 +1,62 @@
|
|
1
|
-
|
2
|
-
require
|
3
|
-
require
|
4
|
-
require
|
5
|
-
require
|
1
|
+
require 'injectable/version'
|
2
|
+
require 'injectable/class_methods'
|
3
|
+
require 'injectable/dependencies_graph'
|
4
|
+
require 'injectable/dependencies_proxy'
|
5
|
+
require 'injectable/dependency'
|
6
|
+
require 'injectable/instance_methods'
|
7
|
+
require 'injectable/missing_dependencies_exception'
|
6
8
|
|
7
|
-
#
|
8
|
-
# container, and removes some basic boilerplate code of creating basic
|
9
|
-
# constructors that set instance variables on the class. Constructor injection
|
10
|
-
# is the only available option here.
|
9
|
+
# Convert your class into an injectable service
|
11
10
|
#
|
12
|
-
# @
|
11
|
+
# @example
|
12
|
+
# You would create a service like this:
|
13
|
+
#
|
14
|
+
# class AddPlayerToTeamRoster
|
15
|
+
# include Injectable
|
16
|
+
#
|
17
|
+
# dependency :team_query
|
18
|
+
# dependency :player_query, class: UserQuery
|
19
|
+
#
|
20
|
+
# argument :team_id
|
21
|
+
# argument :player_id
|
22
|
+
#
|
23
|
+
# def call
|
24
|
+
# player_must_exist!
|
25
|
+
# team_must_exist!
|
26
|
+
# team_must_accept_players!
|
27
|
+
#
|
28
|
+
# team.add_to_roster(player)
|
29
|
+
# end
|
30
|
+
#
|
31
|
+
# private
|
32
|
+
#
|
33
|
+
# def player
|
34
|
+
# @player ||= player_query.call(player_id)
|
35
|
+
# end
|
36
|
+
#
|
37
|
+
# def team
|
38
|
+
# @team ||= team_query.call(team_id)
|
39
|
+
# end
|
40
|
+
#
|
41
|
+
# def player_must_exist!
|
42
|
+
# player.present? || raise UserNotFoundException
|
43
|
+
# end
|
44
|
+
#
|
45
|
+
# def team_must_exist!
|
46
|
+
# team.present? || raise TeamNotFoundException
|
47
|
+
# end
|
48
|
+
#
|
49
|
+
# def team_must_accept_players!
|
50
|
+
# team.accepts_players? || raise TeamFullException
|
51
|
+
# end
|
52
|
+
# end
|
53
|
+
#
|
54
|
+
# And use it like this:
|
55
|
+
#
|
56
|
+
# AddPlayerToTeamRoster.call(player_id: player.id, team_id: team.id)
|
13
57
|
module Injectable
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
# Configure the global injectable registry. Simply just yields to the
|
18
|
-
# registry itself. If no block is provided returns the registry.
|
19
|
-
#
|
20
|
-
# @example Configure the registry.
|
21
|
-
# Injectable.configure do |registry|
|
22
|
-
# registry.register_implementation(:user, User)
|
23
|
-
# end
|
24
|
-
#
|
25
|
-
# @return [ Injectable::Registry ] The registry.
|
26
|
-
#
|
27
|
-
# @since 0.0.3
|
28
|
-
def configure
|
29
|
-
block_given? ? yield(Registry) : Registry
|
30
|
-
end
|
31
|
-
|
32
|
-
# Including the Injectable module will extend the class with the necessary
|
33
|
-
# macros.
|
34
|
-
#
|
35
|
-
# @example Include the module.
|
36
|
-
# class UserService
|
37
|
-
# include Injectable
|
38
|
-
# end
|
39
|
-
#
|
40
|
-
# @param [ Class ] klass The including class.
|
41
|
-
#
|
42
|
-
# @since 0.0.0
|
43
|
-
def included(klass)
|
44
|
-
Registry.register_implementation(
|
45
|
-
Inflector.underscore(klass.name).to_sym,
|
46
|
-
klass
|
47
|
-
)
|
48
|
-
klass.extend(Macros)
|
49
|
-
end
|
58
|
+
def self.included(base)
|
59
|
+
base.extend(Injectable::ClassMethods)
|
60
|
+
base.prepend(Injectable::InstanceMethods)
|
50
61
|
end
|
51
62
|
end
|
@@ -0,0 +1,157 @@
|
|
1
|
+
module Injectable
|
2
|
+
module ClassMethods
|
3
|
+
def self.extended(base)
|
4
|
+
base.class_eval do
|
5
|
+
simple_class_attribute :dependencies,
|
6
|
+
:call_arguments,
|
7
|
+
:initialize_arguments
|
8
|
+
|
9
|
+
self.dependencies = DependenciesGraph.new(namespace: base)
|
10
|
+
self.initialize_arguments = {}
|
11
|
+
self.call_arguments = {}
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
def inherited(base)
|
16
|
+
base.class_eval do
|
17
|
+
self.dependencies = dependencies.with_namespace(base)
|
18
|
+
self.initialize_arguments = initialize_arguments.dup
|
19
|
+
self.call_arguments = call_arguments.dup
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
# Blatantly stolen from rails' ActiveSupport.
|
24
|
+
# This is a simplified version of class_attribute
|
25
|
+
def simple_class_attribute(*attrs)
|
26
|
+
attrs.each do |name|
|
27
|
+
define_singleton_method(name) { nil }
|
28
|
+
|
29
|
+
ivar = "@#{name}"
|
30
|
+
|
31
|
+
define_singleton_method("#{name}=") do |val|
|
32
|
+
singleton_class.class_eval do
|
33
|
+
define_method(name) { val }
|
34
|
+
end
|
35
|
+
|
36
|
+
if singleton_class?
|
37
|
+
class_eval do
|
38
|
+
define_method(name) do
|
39
|
+
if instance_variable_defined? ivar
|
40
|
+
instance_variable_get ivar
|
41
|
+
else
|
42
|
+
singleton_class.send name
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
val
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
# Use the service with the params declared with '.argument'
|
53
|
+
# @param args [Hash] parameters needed for the Service
|
54
|
+
# @example MyService.call(foo: 'first_argument', bar: 'second_argument')
|
55
|
+
def call(args = {})
|
56
|
+
new.call(args)
|
57
|
+
end
|
58
|
+
|
59
|
+
# Declare dependencies for the service
|
60
|
+
# @param name [Symbol] the name of the service
|
61
|
+
# @option options [Class] :class The class to use if it's different from +name+
|
62
|
+
# @option options [Symbol, Array<Symbol>] :depends_on if the dependency has more dependencies
|
63
|
+
# @yield explicitly declare the dependency
|
64
|
+
#
|
65
|
+
# @return [Object] the injected dependency
|
66
|
+
#
|
67
|
+
# @example Using the same name as the service object
|
68
|
+
# dependency :team_query
|
69
|
+
# # => @team_query = TeamQuery.new
|
70
|
+
#
|
71
|
+
# @example Specifying a different class
|
72
|
+
# dependency :player_query, class: UserQuery
|
73
|
+
# # => @player_query = UserQuery.new
|
74
|
+
#
|
75
|
+
# @example With a block
|
76
|
+
# dependency :active_players do
|
77
|
+
# ->(players) { players.select(&:active?) }
|
78
|
+
# end
|
79
|
+
# # => @active_players = [lambda]
|
80
|
+
#
|
81
|
+
# @example With more dependencies
|
82
|
+
# dependency :counter
|
83
|
+
# dependency :team_service
|
84
|
+
# dependency :player_counter, depends_on: [:counter, :team_service]
|
85
|
+
# # => @counter = Counter.new
|
86
|
+
# # => @team_service = TeamService.new
|
87
|
+
# # => @player_counter = PlayerCounter.new(counter: @counter, team_service: @team_service)
|
88
|
+
#
|
89
|
+
# @example Dependencies that don't accept keyword arguments
|
90
|
+
# dependency :counter
|
91
|
+
# dependency :player_counter, depends_on: :counter do |counter:|
|
92
|
+
# PlayerCounter.new(counter)
|
93
|
+
# end
|
94
|
+
# # => @counter = Counter.new
|
95
|
+
# # => @player_counter = PlayerCounter.new(@counter)
|
96
|
+
def dependency(name, options = {}, &block)
|
97
|
+
options[:block] = block if block_given?
|
98
|
+
options[:depends_on] = Array(options.fetch(:depends_on, []))
|
99
|
+
options[:name] = name
|
100
|
+
dependencies.add(options)
|
101
|
+
define_method name do
|
102
|
+
instance_variable_get("@#{name}") || dependencies_proxy.get(name)
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
# Declare the arguments for `#call` and initialize the accessors
|
107
|
+
# This helps us clean up the code for memoization:
|
108
|
+
#
|
109
|
+
# ```
|
110
|
+
# private
|
111
|
+
#
|
112
|
+
# def player
|
113
|
+
# # player_id exists in the context because we added it as an argument
|
114
|
+
# @player ||= player_query.call(player_id)
|
115
|
+
# end
|
116
|
+
# ```
|
117
|
+
#
|
118
|
+
# Every argument is required unless given an optional default value
|
119
|
+
# @param name Name of the argument
|
120
|
+
# @option options :default The default value of the argument
|
121
|
+
# @example
|
122
|
+
# argument :player_id
|
123
|
+
# # => def call(player_id:)
|
124
|
+
# # => @player_id = player_id
|
125
|
+
# # => end
|
126
|
+
# @example with default arguments
|
127
|
+
# argument :team_id, default: 1
|
128
|
+
# # => def call(team_id: 1)
|
129
|
+
# # => @team_id = team_id
|
130
|
+
# # => end
|
131
|
+
def argument(name, options = {})
|
132
|
+
call_arguments[name] = options
|
133
|
+
attr_accessor name
|
134
|
+
end
|
135
|
+
|
136
|
+
def initialize_with(name, options = {})
|
137
|
+
initialize_arguments[name] = options
|
138
|
+
attr_accessor name
|
139
|
+
end
|
140
|
+
|
141
|
+
# Get the #initialize arguments declared with '.initialize_with' with no default
|
142
|
+
# @private
|
143
|
+
def required_initialize_arguments
|
144
|
+
find_required_arguments initialize_arguments
|
145
|
+
end
|
146
|
+
|
147
|
+
# Get the #call arguments declared with '.argument' with no default
|
148
|
+
# @private
|
149
|
+
def required_call_arguments
|
150
|
+
find_required_arguments call_arguments
|
151
|
+
end
|
152
|
+
|
153
|
+
def find_required_arguments(hash)
|
154
|
+
hash.reject { |_arg, options| options.key?(:default) }.keys
|
155
|
+
end
|
156
|
+
end
|
157
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
module Injectable
|
2
|
+
# Holds the dependency signatures of the service object
|
3
|
+
class DependenciesGraph
|
4
|
+
attr_reader :graph, :dependency_class, :proxy_class
|
5
|
+
attr_accessor :namespace
|
6
|
+
|
7
|
+
def initialize(namespace:,
|
8
|
+
proxy_class: ::Injectable::DependenciesProxy,
|
9
|
+
dependency_class: ::Injectable::Dependency)
|
10
|
+
@namespace = namespace
|
11
|
+
@graph = {}
|
12
|
+
@proxy_class = proxy_class
|
13
|
+
@dependency_class = dependency_class
|
14
|
+
end
|
15
|
+
|
16
|
+
def names
|
17
|
+
graph.keys
|
18
|
+
end
|
19
|
+
|
20
|
+
def with_namespace(namespace)
|
21
|
+
dup.tap { |dupe| dupe.namespace = namespace }
|
22
|
+
end
|
23
|
+
|
24
|
+
# Adds the signature of a dependency to the graph
|
25
|
+
def add(name:, depends_on:, **kwargs)
|
26
|
+
check_for_missing_dependencies!(depends_on)
|
27
|
+
graph[name] = dependency_class.new(kwargs.merge(name: name, depends_on: depends_on))
|
28
|
+
end
|
29
|
+
|
30
|
+
def proxy
|
31
|
+
proxy_class.new(graph: graph, namespace: namespace)
|
32
|
+
end
|
33
|
+
|
34
|
+
private
|
35
|
+
|
36
|
+
def check_for_missing_dependencies!(deps)
|
37
|
+
missing = deps.reject { |dep| graph.key?(dep) }
|
38
|
+
return if missing.empty?
|
39
|
+
|
40
|
+
raise Injectable::MissingDependenciesException, "missing dependencies: #{missing.join(', ')}"
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
module Injectable
|
2
|
+
# Memoizes the Dependencies generated by Dependency based on DependencyGraph
|
3
|
+
class DependenciesProxy
|
4
|
+
attr_reader :graph, :namespace
|
5
|
+
|
6
|
+
def initialize(graph:, namespace: nil)
|
7
|
+
@graph = graph
|
8
|
+
@namespace = namespace
|
9
|
+
@instances = {}
|
10
|
+
end
|
11
|
+
|
12
|
+
# Get the instance of the dependency +name+
|
13
|
+
def get(name)
|
14
|
+
@instances[name] ||= graph[name].instance(args: memoized_dependencies_of(name), namespace: namespace)
|
15
|
+
end
|
16
|
+
|
17
|
+
private
|
18
|
+
|
19
|
+
def memoized_dependencies_of(name)
|
20
|
+
return [] if dependencies_of(name).empty?
|
21
|
+
|
22
|
+
dependencies_of(name).each_with_object({}) { |dep, hash| hash[dep] = get(dep) }
|
23
|
+
end
|
24
|
+
|
25
|
+
def dependencies_of(name)
|
26
|
+
graph[name].depends_on
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
module Injectable
|
2
|
+
# Initialize a dependency based on the options or the block passed
|
3
|
+
Dependency = Struct.new(:name, :block, :class, :call, :with, :depends_on, keyword_init: true) do
|
4
|
+
def instance(args: [], namespace: nil)
|
5
|
+
args = wrap_args(args)
|
6
|
+
wrap_call build_instance(args, namespace: namespace)
|
7
|
+
end
|
8
|
+
|
9
|
+
private
|
10
|
+
|
11
|
+
def wrap_args(args)
|
12
|
+
args = with unless with.nil?
|
13
|
+
args.is_a?(Array) ? args : [args]
|
14
|
+
end
|
15
|
+
|
16
|
+
def wrap_call(the_instance)
|
17
|
+
return the_instance unless call
|
18
|
+
|
19
|
+
lambda do |*args|
|
20
|
+
the_instance.public_send(call, *args)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def build_instance(args, namespace:)
|
25
|
+
block.nil? ? klass(namespace: namespace).new(*args) : block.call(*args)
|
26
|
+
end
|
27
|
+
|
28
|
+
def klass(namespace:)
|
29
|
+
self.class || resolve(namespace: namespace)
|
30
|
+
end
|
31
|
+
|
32
|
+
def resolve(namespace:)
|
33
|
+
(namespace || Object).const_get(camelcased)
|
34
|
+
end
|
35
|
+
|
36
|
+
def camelcased
|
37
|
+
@camelcased ||= name.to_s.split('_').map(&:capitalize).join
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|