dependency_manager 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
data/bin/console ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "dependency_manager"
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,44 @@
1
+
2
+ lib = File.expand_path("../lib", __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require "dependency_manager/version"
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "dependency_manager"
8
+ spec.version = DependencyManager::VERSION
9
+ spec.authors = ["Brandon Weaver"]
10
+ spec.email = ["keystonelemur@gmail.com"]
11
+
12
+ spec.summary = %q{Dependency Management for applications with several dependencies}
13
+ spec.description =
14
+ %q{Manages and loads large collections of dependencies and wraps them into a service container for later consumption}
15
+ spec.homepage = "https://www.github/com/baweaver/dependency_manager"
16
+ spec.license = "MIT"
17
+
18
+ # Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host'
19
+ # to allow pushing to a single host or delete this section to allow pushing to any host.
20
+ if spec.respond_to?(:metadata)
21
+ spec.metadata["homepage_uri"] = spec.homepage
22
+ spec.metadata["source_code_uri"] = "https://www.github/com/baweaver/dependency_manager"
23
+ spec.metadata["changelog_uri"] = "https://www.github/com/baweaver/dependency_manager/CHANGELOG.md"
24
+ else
25
+ raise "RubyGems 2.0 or newer is required to protect against " \
26
+ "public gem pushes."
27
+ end
28
+
29
+ # Specify which files should be added to the gem when it is released.
30
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
31
+ spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
32
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
33
+ end
34
+ spec.bindir = "exe"
35
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
36
+ spec.require_paths = ["lib"]
37
+
38
+ spec.add_development_dependency "bundler", "~> 2.0"
39
+ spec.add_development_dependency "rake", "~> 10.0"
40
+ spec.add_development_dependency "rspec", "~> 3.0"
41
+ spec.add_development_dependency "guard-rspec", "~> 4.7"
42
+
43
+ spec.add_runtime_dependency "dry-schema", "~> 1.5"
44
+ end
@@ -0,0 +1,9 @@
1
+ require "dependency_manager/version"
2
+
3
+ require "dependency_manager/container"
4
+ require "dependency_manager/dependency_tree"
5
+ require "dependency_manager/factory"
6
+ require "dependency_manager/resolver"
7
+
8
+ module DependencyManager
9
+ end
@@ -0,0 +1,74 @@
1
+ require 'dry/schema'
2
+
3
+ module DependencyManager
4
+ # Class-level methods for validation of configurations
5
+ module ConfigSchemaMacros
6
+ # Hook for binding class-level methods to the child class
7
+ #
8
+ # @param klass [Class]
9
+ # Factory to bind to
10
+ #
11
+ # @return [void]
12
+ def self.included(klass)
13
+ klass.extend(ClassMethods)
14
+ end
15
+
16
+ # Runs validation against configuration without throwing errors
17
+ #
18
+ # @param target: configuration [Hash[Symbol, Any]]
19
+ # Configuration to validate, defaulting to `configuration`
20
+ #
21
+ # @return [Dry::Validation::Result]
22
+ def validate(target: configuration)
23
+ self.class.validate(**target)
24
+ end
25
+
26
+ # Immediate return validation that will raise an exception if the contract
27
+ # is not fulfilled
28
+ #
29
+ # @param target: configuration [Hash[Symbol, Any]]
30
+ # Configuration to validate, defaulting to `configuration`
31
+ #
32
+ # @raises [ArgumentError]
33
+ # Failure
34
+ #
35
+ # @return [TrueClass]
36
+ # Success
37
+ def validate!(target: configuration)
38
+ validation_result = validate(target: target)
39
+
40
+ return true if validation_result.success?
41
+
42
+ errors = validation_result
43
+ .errors
44
+ .map { |e| "#{e.path} #{e.text}" }
45
+ .join(', ')
46
+
47
+ raise ArgumentError, "Configuration is invalid: #{errors}"
48
+ end
49
+
50
+ module ClassMethods
51
+ # Class-level macro for validations
52
+ #
53
+ # @see https://dry-rb.org/gems/dry-validation
54
+ #
55
+ # @param &dry_schema [Proc]
56
+ # Dry Schema to validate with
57
+ #
58
+ # @return [Dry::Schema]
59
+ def validate_with(&dry_schema)
60
+ @dry_schema = Dry::Schema.Params(&dry_schema)
61
+ end
62
+
63
+ # Runs validator
64
+ #
65
+ # @param **configuration [Hash[Symbol, Any]]
66
+ # Hash to validate with schema
67
+ #
68
+ # @return [Dry::Validation::Result]
69
+ def validate(**configuration)
70
+ @dry_schema.call(configuration)
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,119 @@
1
+ require 'set'
2
+
3
+ module DependencyManager
4
+ class Container
5
+ # A container should only be built once
6
+ class BuildOnceError < ArgumentError; end
7
+
8
+ # You can't add new dependencies after a build
9
+ class AddedFactoryAfterBuildError < ArgumentError; end
10
+
11
+ attr_reader :dependencies
12
+
13
+ # Creates a new Dependency Container
14
+ #
15
+ # @param app_context: [Any]
16
+ # Contextual information for the currently running application
17
+ #
18
+ # @param configuration: [Hash[Symbol, Any]]
19
+ # Hash of configuration values, typically loaded from a YAML or JSON file
20
+ #
21
+ # @param factories: Factory.factories [Array[Factory]]
22
+ # All factories to build dependency chain from. This will default to the
23
+ # `Factory.factories` method, which will grab all children of the base
24
+ # `Factory` class.
25
+ #
26
+ # @return [Container]
27
+ def initialize(app_context:, configuration:, factories: Factory.factories)
28
+ @app_context = app_context
29
+ @configuration = configuration
30
+ @factories = factories.is_a?(Set) ? factories : Set[*factories]
31
+ @built = false
32
+ end
33
+
34
+ # Register a factory explicitly
35
+ #
36
+ # @param factory [type] [description]
37
+ #
38
+ # @return [type] [description]
39
+ def register(factory)
40
+ raise AddedFactoryAfterBuildError, "Cannot add Factories after Container has been built" if @built
41
+
42
+ @factories.add factory
43
+ end
44
+
45
+ # Builds all the dependencies from factories
46
+ #
47
+ # @return [Hash[Symbol, Any]]
48
+ # Built resources
49
+ def build
50
+ raise BuildOnceError, "Cannot build more than once" if @built
51
+
52
+ @dependency_tree = dependency_tree
53
+ @ordered_factory_dependencies = dependency_tree.tsort
54
+
55
+ @dependencies = {}
56
+
57
+ # Take the ordered factories
58
+ @ordered_factory_dependencies.each do |factory_name|
59
+ # Get their associated class
60
+ factory = DependencyManager::Factory.get(factory_name)
61
+
62
+ # Figure out which dependencies we need, which are optional, and which
63
+ # will break the factory build coming up
64
+ resolved_dependencies = Resolver.new(
65
+ factory: factory,
66
+ loaded_dependencies: dependencies
67
+ ).resolve
68
+
69
+ # Create an instance of the factory including its resolved dependencies.
70
+ factory_instance = factory.new(
71
+ app_context: @app_context,
72
+ factory_config: get_config(factory),
73
+ **resolved_dependencies
74
+ )
75
+
76
+ # ...and build the dependency based on the provided configuration options.
77
+ @dependencies[factory.dependency_name] = factory_instance.build
78
+ end
79
+
80
+ @built = true
81
+
82
+ @dependencies
83
+ end
84
+
85
+ # Fetch a dependency by name
86
+ #
87
+ # @param dependency [Symbol]
88
+ #
89
+ # @return [Any]
90
+ def fetch(dependency)
91
+ @dependencies.fetch(dependency)
92
+ end
93
+
94
+ # Listing of all dependencies
95
+ #
96
+ # @return [Hash[Symbol, Any]]
97
+ def to_h
98
+ @dependencies
99
+ end
100
+
101
+ def dependency_tree
102
+ DependencyTree.new(dependency_hash)
103
+ end
104
+
105
+ private def dependency_hash
106
+ @factories.map { |k| [k.name, k.factory_dependencies] }.to_h
107
+ end
108
+
109
+ # Gets the dependencies configuration from the master configuration.
110
+ #
111
+ # @param klass [Class]
112
+ # Class to get configuration for
113
+ #
114
+ # @return [Hash[Symbol, Any]]
115
+ private def get_config(klass)
116
+ @configuration.fetch(klass.dependency_name, {})
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,29 @@
1
+ require 'tsort'
2
+ require 'delegate'
3
+
4
+ module DependencyManager
5
+ # Dependency tree implementation using TSort to resolve the order in which
6
+ # factories should be run.
7
+ class DependencyTree < Delegator
8
+ include TSort
9
+
10
+ attr_reader :resources
11
+
12
+ # Allow access to the underlying hash
13
+ alias_method :__getobj__, :resources
14
+
15
+ def initialize(resources)
16
+ @resources = resources
17
+ end
18
+
19
+ # TSort interface method
20
+ def tsort_each_node(&block)
21
+ @resources.each_key(&block)
22
+ end
23
+
24
+ # TSort interface method
25
+ def tsort_each_child(node, &block)
26
+ @resources.fetch(node).each(&block)
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,257 @@
1
+ require 'dependency_manager/config_schema_macros'
2
+ require 'set'
3
+
4
+ module DependencyManager
5
+ # Base for all other factories, providing interface hints and generic
6
+ # functionality
7
+ #
8
+ # ### Initialize for Dependency Specifications
9
+ #
10
+ # Every keyword argument used in the `initialize` function for a Factory
11
+ # is used to resolve the dependencies of the class with the exception of
12
+ # `CONTEXT_DEPENDENCIES`.
13
+ #
14
+ # `:keyreq` represents a required argument, while `:key` represents an
15
+ # optional one:
16
+ #
17
+ # ```ruby
18
+ # def initialize(logger:, optional_dependency: nil, **dependencies)
19
+ # super(**dependencies)
20
+ #
21
+ # @logger = logger
22
+ # @optional_dependency = optional_dependency
23
+ # end
24
+ # ```
25
+ #
26
+ # This value could be `nil` or any other sane default value for the
27
+ # dependency specified.
28
+ #
29
+ # The `Factory` implements several helper methods on its singleton class
30
+ # like `dependencies`, `optional_dependencies`, and `factory_dependencies` to
31
+ # help with constructing dependency chains.
32
+ class Factory
33
+ # Methods for validating configurations
34
+ include ConfigSchemaMacros
35
+
36
+ # Dependencies that are always present and injected at a top level
37
+ # rather than by other factories
38
+ CONTEXT_DEPENDENCIES = %i(app_context factory_config)
39
+
40
+ # Keyword param types
41
+ KEYWORD_ARGS = %i(keyreq key)
42
+ OPTIONAL_ARG = :key
43
+
44
+ class << self
45
+ # Captures classes inheriting from Factory for later use
46
+ #
47
+ # @param subclass [Class]
48
+ # The subclass
49
+ #
50
+ # @return [void]
51
+ def inherited(subclass)
52
+ @factories ||= Set.new
53
+ @factories.add subclass
54
+ end
55
+
56
+ # Get all available factory names except the Base factory
57
+ #
58
+ # @return [Array[Symbol]]
59
+ def factories
60
+ @factories || Set.new
61
+ end
62
+
63
+ # Get a factory by its underscored name
64
+ #
65
+ # @param factory_name [Symbol]
66
+ #
67
+ # @return [Symbol] Constant name
68
+ def get(factory_name)
69
+ const_name = constantize(factory_name)
70
+
71
+ unless const_defined?(const_name)
72
+ raise ArgumentError, "Tried to get non-existant Factory. Did you remember to define it?: #{const_name}"
73
+ end
74
+
75
+ const_get const_name
76
+ end
77
+
78
+ # Utility to constantize an underscored string or symbol
79
+ #
80
+ # @param s [String, Symbol]
81
+ #
82
+ # @return [Symbol]
83
+ def constantize(s)
84
+ s.to_s.split('_').map(&:capitalize).join.to_sym
85
+ end
86
+
87
+ def const_name
88
+ to_s.split('::').last
89
+ end
90
+
91
+ # Name of the factory
92
+ #
93
+ # @return [String]
94
+ def name
95
+ underscore const_name
96
+ end
97
+
98
+ # Name of the expected dependency to be generated
99
+ #
100
+ # @return [Symbol]
101
+ def dependency_name
102
+ name.to_s.sub(/_factory$/, '').to_sym
103
+ end
104
+
105
+ def parameters
106
+ instance_method(:initialize).parameters
107
+ end
108
+
109
+ # Dependencies of the class under the factory that it needs to initialize.
110
+ #
111
+ # @return [Array[Symbol]]
112
+ def dependencies
113
+ dependencies = parameters
114
+ .select { |type, _name| KEYWORD_ARGS.include?(type) }
115
+ .map(&:last)
116
+
117
+ dependencies - CONTEXT_DEPENDENCIES
118
+ end
119
+
120
+ # Dependencies of the factory itself to make sure factories load in the
121
+ # correct order.
122
+ #
123
+ # @return [Array[Symbol]]
124
+ def factory_dependencies
125
+ dependencies.map { |d| "#{d}_factory".to_sym }
126
+ end
127
+
128
+ # Dependencies required to build the factory
129
+ #
130
+ # @return [Array[Symbol]]
131
+ def required_dependencies
132
+ dependencies - optional_dependencies
133
+ end
134
+
135
+ # Optional arguments that are not strictly required, but used
136
+ # for additional functionality.
137
+ #
138
+ # @return [Array[Symbol]]
139
+ def optional_dependencies
140
+ optionals = parameters
141
+ .select { |type, _name| type == OPTIONAL_ARG }
142
+ .map(&:last)
143
+
144
+ optionals - CONTEXT_DEPENDENCIES
145
+ end
146
+
147
+ # Underscores a constant name
148
+ #
149
+ # @param const_name [Symbol]
150
+ #
151
+ # @return [Symbol]
152
+ def underscore(const_name)
153
+ const_name.gsub(/([^\^])([A-Z])/,'\1_\2').downcase.to_sym
154
+ end
155
+ end
156
+
157
+ # Creates a new Factory.
158
+ #
159
+ # @param app_context: nil [AppContext]
160
+ # Application context information. Defaulted to `nil` in case users
161
+ # do not need this information.
162
+ #
163
+ # @param factory_config: [Hash[Symbol, Any]]
164
+ # Configuration specific to the factory
165
+ #
166
+ # @return [Factory]
167
+ def initialize(app_context: nil, factory_config:)
168
+ @app_context = app_context
169
+ @factory_config = factory_config
170
+ end
171
+
172
+ # Used to build the dependency
173
+ #
174
+ # @raise [NotImplementedError]
175
+ def build
176
+ raise NotImplementedError
177
+ end
178
+
179
+ # Used to generate configuration for the dependency,
180
+ # not always necessary for shorter builds.
181
+ #
182
+ # As a default will be an alias for `@factory_config`,
183
+ # and is used as a hook point for any validation.
184
+ #
185
+ # @raise [Hash[Symbol, Any]]
186
+ def configuration
187
+ @configuration ||= deep_merge(default_configuration, @factory_config)
188
+ end
189
+
190
+ # Default configuration of the Factory
191
+ #
192
+ # @return [Hash[Symbol, Any]]
193
+ def default_configuration
194
+ {}
195
+ end
196
+
197
+ # Used to load and require any associated external dependencies.
198
+ #
199
+ # @raise [NotImplementedError]
200
+ def load_requirements
201
+ raise NotImplementedError
202
+ end
203
+
204
+ # Whether or not the dependency should be enabled. It is suggested to
205
+ # use this as a guard when building dependencies:
206
+ #
207
+ # ```ruby
208
+ # def build
209
+ # return unless enabled?
210
+ #
211
+ # # ...
212
+ # end
213
+ # ```
214
+ #
215
+ # @return [FalseClass] Disabled by default
216
+ def enabled?
217
+ false
218
+ end
219
+
220
+ # Deeply merges two Hashes
221
+ #
222
+ # @param a [Hash]
223
+ # Original Hash
224
+ #
225
+ # @param b [Hash]
226
+ # Hash to merge
227
+ #
228
+ # @param &fn [Proc[Any, Any, Any]]
229
+ # Merging function
230
+ #
231
+ # @return [Hash]
232
+ protected def deep_merge(a, b, &fn)
233
+ deep_merge!(a.dup, b.dup, &fn)
234
+ end
235
+
236
+ # Destructive merge of two hashes
237
+ #
238
+ # @param a [Hash]
239
+ # Original Hash
240
+ #
241
+ # @param b [Hash]
242
+ # Hash to merge
243
+ #
244
+ # @param &fn [Proc[Any, Any, Any]]
245
+ # Merging function
246
+ #
247
+ # @return [Hash]
248
+ protected def deep_merge!(a, b, &fn)
249
+ a.merge!(b) do |key, left, right|
250
+ next deep_merge(left, right, &fn) if left.is_a?(Hash) && right.is_a?(Hash)
251
+ next fn.call(left, right, &fn) if block_given?
252
+
253
+ right
254
+ end
255
+ end
256
+ end
257
+ end