injectable 0.0.5 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
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