dependency_manager 0.0.1
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/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +99 -0
- data/Guardfile +70 -0
- data/LICENSE.txt +21 -0
- data/README.md +566 -0
- data/Rakefile +6 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/dependency_manager.gemspec +44 -0
- data/lib/dependency_manager.rb +9 -0
- data/lib/dependency_manager/config_schema_macros.rb +74 -0
- data/lib/dependency_manager/container.rb +119 -0
- data/lib/dependency_manager/dependency_tree.rb +29 -0
- data/lib/dependency_manager/factory.rb +257 -0
- data/lib/dependency_manager/resolver.rb +49 -0
- data/lib/dependency_manager/version.rb +3 -0
- metadata +136 -0
data/Rakefile
ADDED
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,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,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
|