injectable 0.0.5 → 1.0.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.
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
- $LOAD_PATH.unshift File.expand_path("../lib", __FILE__)
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
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -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
- # encoding: utf-8
2
- require "injectable/container"
3
- require "injectable/inflector"
4
- require "injectable/macros"
5
- require "injectable/registry"
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
- # Objects that include Injectable can have their dependencies satisfied by the
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
- # @since 0.0.0
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
- class << self
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