dependency_manager 0.0.1
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.
- 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
|