Ecosyste.ms: Awesome
An open API service indexing awesome lists of open source software.
https://github.com/bkuhlmann/infusible
An automatic dependency injector.
https://github.com/bkuhlmann/infusible
dependency injection
Last synced: 2 days ago
JSON representation
An automatic dependency injector.
- Host: GitHub
- URL: https://github.com/bkuhlmann/infusible
- Owner: bkuhlmann
- License: other
- Created: 2022-09-05T13:46:17.000Z (about 2 years ago)
- Default Branch: main
- Last Pushed: 2024-10-13T21:40:01.000Z (28 days ago)
- Last Synced: 2024-10-13T21:55:18.521Z (28 days ago)
- Topics: dependency, injection
- Language: Ruby
- Homepage: https://alchemists.io/projects/infusible
- Size: 217 KB
- Stars: 3
- Watchers: 2
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.adoc
- Funding: .github/FUNDING.yml
- License: LICENSE.adoc
- Citation: CITATION.cff
Awesome Lists containing this project
README
:toc: macro
:toclevels: 5
:figure-caption!::dependency_injection_containers_link: link:https://alchemists.io/articles/dependency_injection_containers[Dependency Injection Containers]
:containable_link: link:https://alchemists.io/projects/containable[Containable]
:http_link: link:https://github.com/httprb/http[HTTP]= Infusible
Automatically injects dependencies within your object via the _Dependency Inversion Principle_ -- the _D_ in _SOLID_ design -- and is a powerful way to compose complex architectures from small objects which leverage the _Single Responsibility Principle_ -- the _S_ in _SOLID_ design.
When coupled with {dependency_injection_containers_link}, as provided by the {containable_link} gem, Infusible completes the second half of the _Dependency Inversion Principle_. Here's a quick example of Infusible in action:
[source,ruby]
----
Import = Infusible[a: 1, b: 2, c: 3]class Demo
include Import[:a, :b, :c]def to_s = "My injected dependencies are: #{a}, #{b}, and #{c}."
endputs Demo.new # My injected dependencies are: 1, 2, and 3.
----By _infusing_ dependencies into your object, you have the ability to define common dependencies that can be injected without the manual setup normally required to define a constructor, set private instance variables, and set private attribute readers.
toc::[]
== Features
* Ensures injected dependencies are _private by default_ but has support for public and protected injection.
* Built atop the link:https://alchemists.io/projects/marameters[Marameters] gem.== Requirements
. link:https://www.ruby-lang.org[Ruby].
. Knowledge of SOLID design principles.== Setup
To install _with_ security, run:
[source,bash]
----
# 💡 Skip this line if you already have the public certificate installed.
gem cert --add <(curl --compressed --location https://alchemists.io/gems.pem)
gem install infusible --trust-policy HighSecurity
----To install _without_ security, run:
[source,bash]
----
gem install infusible
----You can also add the gem directly to your project:
[source,bash]
----
bundle add infusible
----Once the gem is installed, you only need to require it:
[source,ruby]
----
require "infusible"
----== Usage
There is basic and advanced usage. We'll start with the basics and work our to more advanced usage.
=== Basic
This gem requires three steps for proper use:
. A container.
. An import constant.
. An object and/or multiple objects for dependencies to be injected into.Let's walk through each staring by defining a container of dependencies.
==== Containers
A container provides a common object for which you can group related dependencies for injection and reuse. {containable_link} is recommended for defining your dependencies but a primitive `Hash` or any object which responds to the `#[]` message works too.
For documentation purposes, the {containable_link} gem will be used. The following creates a simple container where you might want to use the {http_link} gem to make HTTP requests and log information using Ruby's native logger.
[source,ruby]
----
require "containable"
require "http"
require "logger"module Container
extend Containableregister :http, HTTP
register(:logger) { Logger.new STDOUT }
end
----==== Imports
Once your container is defined, you'll want to define the corresponding import for reuse within your application. Defining an import only requires two lines of code:
[source,ruby]
----
require "infusible"Import = Infusible[Container]
----==== Dependencies
With your container and import defined, you can inject your dependencies by including what you need:
[source,ruby]
----
class Pinger
include Import[:http, :logger]def call url
http.get(url).status.then { |status| logger.info %(The status of "#{url}" is #{status}.) }
end
end
----Now when you ping a URL, you'll see the status of the server logged to console using all injected dependencies:
[source,ruby]
----
Pinger.new.call "https://duckduckgo.com"
# I, [2022-03-01T10:00:00.979741 #81819] INFO -- : The status of "https://duckduckgo.com" is 200 OK.
----=== Advanced
When injecting your dependencies you _must_ always define what dependencies you want to require. By default, none will be injected. The following demonstrates multiple ways to manage the injection of your dependencies.
==== Keys
You can use symbols, strings, or a combination of both when defining which dependencies you want to inject. Example:
[source,ruby]
----
class Pinger
include Import[:http, "logger"]def call = puts "Using: #{http.inspect} and #{logger.inspect}."
end
----==== Namespaces
To access namespaced dependencies within a container, you only need to provide the fully qualified path. Example:
[source,ruby]
----
class Pinger
include Import["primary.http", "primary.logger"]def call = puts "Using: #{http.inspect} and #{logger.inspect}."
end
----The namespace (i.e. `primary`) _and_ delimiter (i.e. `.`) will be removed so only `http` and `logger` are defined for use (as shown in the `#call` method). Only dots (i.e. `.`) are allowed as the delimiter between namespace and dependency.
==== Aliases
Should you want to rename your namespaced dependencies to something more appropriate for your class, use a hash. Example:
[source,ruby]
----
class Pinger
include Import[client: "primary.http"]def call = puts "Using: #{client.inspect}."
end
----The aliased `"primary.http"` will be defined as `client` when imported (as shown in the `#call` method).
You can also mix names, namespaces, and aliases for injection as long as the aliases are defined last. Example:
[source,ruby]
----
class Pinger
include Import[:configuration, "primary.logger", client: :http]def call = puts "Using: #{configuration.inspect}, #{logger.inspect}, and #{client.inspect}."
end
----==== Explicit Dependencies
Earlier, when demonstrating basic usage, all dependencies were injected by default:
[source,ruby]
----
class Pinger
include Import[:http, :logger]
end
----...but we could have a different class -- like a downloader -- that only needs the HTTP client. In that case, we could import the _same_ container but only require the HTTP dependency. Example:
[source,ruby]
----
class Downloader
include Import[:http]
end
----This allows you to reuse `Import` in as many situations as makes sense while improving performance.
==== Custom Initialization
Should you want to use injection in combination with your own initializer, you'll need to ensure the injected dependencies are passed upward. All you need to do is define the injected dependencies as your last argument and then pass them to `super`. Example:
[source,ruby]
----
class Pinger
include Import[:logger]def initialize(http: HTTP, **)
super(**)
@http = http
endprivate
attr_reader :http
end
----The above will ensure the logger gets passed upwards to the superclass while remaining accessible by the subclass.
==== Inheritance
When using inheritance (or multiple inheritance), the child class' dependencies will take precedence over the parent's dependencies as long as the keys are the same. Consider the following:
[source,ruby]
----
class Parent
def initialize logger: Logger.new(StringIO.new)
@logger = logger
endprivate
attr_reader :logger
endclass Child < Parent
include Import[:logger]
end
----In the above situation, the child's logger will be the logger that is injected which overrides the default logger defined by the parent. This applies to multiple inheritance too. Example:
[source,ruby]
----
class Parent
include GeneralImport[:logger]
endclass Child < Parent
include Import[:logger]
end
----Once again, the child's logger will take precedence over the what is provided by default by the parent. This also applies to multiple levels of inheritance or multiple inherited modules. Whichever is last to be injected, wins. Lastly, you can mix and match dependencies too:
[source,ruby]
----
class Parent
include Import[:logger]
endclass Child < Parent
include Import[:http]
end
----With the above, the child class will have access to both the `logger` and `http` dependencies.
⚠️ Be careful when using parent dependencies within your child classes since they are _private by default_. Even though you can reach them, they might change, which can break your downstream dependencies and probably should be avoided or at least defined as `protected` by your parent objects in order to avoid breaking the parent/child relationship.
==== Scopes
By default -- and in all of the examples shown so far -- your dependencies are private by default when injected but you can make them public or protected. Here's a quick guide:
* `include Import[:logger]`: Injects a _private_ logger dependency.
* `include Import.protected(logger)`: Injects a _protected_ logger dependency. Useful with inheritance and a subclass that needs access to the dependency.
* `include Import.public(:logger)`: Injects a _public_ logger dependency.There is no `+#private+` method since `#[]` does this for you and is _recommended practice_. Use of `+#public+` and `+#protected+` should be used sparingly or not at all if you can avoid it. Here's an example where public, protected, and private dependencies are injected:
[source,ruby]
----
module Container
extend Containableregister :one, "One"
register :two, "Two"
register :three, "Three"
endImport = Infusible[Container]
class Demo
include Import.public(:one)
include Import.protected(:two)
include Import[:three]
enddemo = Demo.new
demo.one # "One"
demo.two # NoMethodError: protected method.
demo.three # NoMethodError: private method.
----==== Infused Keys
You have access to the keys of all dependencies via the _private_ `#infused_keys` method which is powerful in metaprogramming situations. For example, consider the following which calls all injected dependencies since they have the same Object API (i.e. `#call`):
Example:
[source,ruby]
----
module Container
extend Containableregister :one, "One"
register :two, "Two"
endImport = Infusible[Container]
class Demo
include Import[:one, :two]def call = infused_keys.each { |key| puts __send__(key) }
endDemo.new.call
# One
# Two
----As you can see, with the _private_ `#infused_keys` attribute reader, we are able to iterate through each infused key and send the `#call` message to each injected dependency.
Since `#infused_keys` is a private attribute reader, this means the infused keys are private to each instance. This includes all ancestors when using inheritance as each parent class in the hierarchy will have it's own unique array of infused keys depending on what was injected for that object.
All infused keys are frozen by default.
=== Tests
As you architect your implementation, you'll want to test your injected dependencies. You might want to stub, mock, or spy on them as well. Test support is primarily provided via the {containable_link} gem. Example:
[source,ruby]
----
# Our container with a single dependency.
module Container
extend Containableregister :kernel, Kernel
end# Our import which defines our container for potential injection.
Import = Infusible[Container]# Our action class which injects our kernel dependency from our container.
class Action
include Import[:kernel]def call = kernel.puts "This is a test."
end
----With our implementation defined, we can test as follows:
[source,ruby]
----
RSpec.describe Action do
subject(:action) { Action.new }let(:kernel) { class_spy Kernel }
before { Container.stub! kernel: }
after { Container.restore }
describe "#call" do
it "prints message" do
action.call
expect(kernel).to have_received(:puts).with("This is a test.")
end
end
end
----Notice there is little setup required to test the injected dependencies. You only need to stub and restore via your `before` and `after` blocks. That's it!
While the above works great for a single spec, over time you'll want to reduce duplicated setup by using a shared context. Here's a rewrite of the above spec which significantly reduces duplication when needing to test multiple objects using the same dependencies:
[source,ruby]
----
# spec/support/shared_contexts/application_container.rb
RSpec.shared_context "with application dependencies" do
let(:kernel) { class_spy Kernel }before { Container.stub! kernel: }
after { Container.restore }
end
----[source,ruby]
----
# spec/lib/action_spec.rb
RSpec.describe Action do
subject(:action) { Action.new }include_context "with application dependencies"
describe "#call" do
it "prints message" do
action.call
expect(kernel).to have_received(:puts).with("This is a test.")
end
end
end
----A shared context allows for reuse across multiple specs by including it as needed.
== Development
To contribute, run:
[source,bash]
----
git clone https://github.com/bkuhlmann/infusible
cd infusible
bin/setup
----You can also use the IRB console for direct access to all objects:
[source,bash]
----
bin/console
----=== Architecture
This gem automates a lot of the boilerplate code you'd manually do by defining your constructor, initializer, and instance variables for you. Normally, when injecting dependencies, you'd do something like this (using the `Pinger` example provided earlier):
[source,ruby]
----
class Pinger
def initialize http: HTTP, logger: Logger.new(STDOUT)
@http = http
@logger = logger
enddef call url
http.get(url).status.then { |status| logger.info %(The status of "#{url}" is #{status}.) }
endprivate
attr_reader :http, :logger
end
----When you use this gem all of the construction, initialization, and setting of private instance variables is taken care of for you. So what you see above is identical to the following:
[source,ruby]
----
class Pinger
include Import[:http, :logger]def call url
http.get(url).status.then { |status| logger.info %(The status of "#{url}" is #{status}.) }
end
end
----Your constructor, initializer, and instance variables are all there. Only you don't have to write all of this yourself anymore. 🎉
=== Style Guide
When using this gem, along with a container like {containable_link}, make sure to adhere to the following guidelines:
* Use containers to group related dependencies that make logical sense for the namespace you are working in and avoid using containers as a junk drawer for throwing random objects in.
* Use containers that don't have a lot of registered dependencies. If you register too many dependencies, that means your objects are too complex and need to be simplified further.
* Use the `Import` constant to define _what_ is possible to import much like you'd use a `Container` to define your dependencies. Defining what is importable improves performance and should be defined in separate files for improved fuzzy file finding.
* Use `**` to forward keyword arguments when defining an initializer which needs to pass injected dependencies upwards.
* Prefer `Import#[]` over the use of `Import#public` and/or `Import#protected` as much as a possible since injected dependencies should be private, by default, in order to not break encapsulation. That said, there are times where making them public and/or protected can save you from writing boilerplate code.== Tests
To test, run:
[source,bash]
----
bin/rake
----== link:https://alchemists.io/policies/license[License]
== link:https://alchemists.io/policies/security[Security]
== link:https://alchemists.io/policies/code_of_conduct[Code of Conduct]
== link:https://alchemists.io/policies/contributions[Contributions]
== link:https://alchemists.io/policies/developer_certificate_of_origin[Developer Certificate of Origin]
== link:https://alchemists.io/projects/infusible/versions[Versions]
== link:https://alchemists.io/community[Community]
== Credits
* Built with link:https://alchemists.io/projects/gemsmith[Gemsmith].
* Engineered by link:https://alchemists.io/team/brooke_kuhlmann[Brooke Kuhlmann].