maglev-injectable 2.1.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 032be6c3740e077577ece58660af3636a98adb9cc13272ab12e42f3366724dad
4
+ data.tar.gz: bb6549d739e3e2a5a49fd134436c3874fa4f78e9825d8b795ce9a182cdf28207
5
+ SHA512:
6
+ metadata.gz: ff4c8dd10329368fe28ad93d552ced0a109ab34234a7e7c375989423da9acfdbef467c3e1bf9bf8af6d93d37aa8446c7ad5c5a8ffc43db162923608378b49e6e
7
+ data.tar.gz: '0856a305f51edb5df46c84201c19108220c2af77943cd5bd679e6127c0a82f7004ba128f727eb08b307e03e0ce6523dede3aeb1208c6d7ace7e44c021e04abc2'
@@ -0,0 +1,25 @@
1
+ name: Ruby
2
+
3
+ on:
4
+ push:
5
+ branches: [ master ]
6
+ pull_request:
7
+ branches: [ master ]
8
+
9
+ jobs:
10
+ build:
11
+ runs-on: ubuntu-latest
12
+ strategy:
13
+ matrix:
14
+ ruby: [ 2.5.x, 2.6, 2.7.x ]
15
+ steps:
16
+ - uses: actions/checkout@v2
17
+ - name: Set up Ruby ${{ matrix.ruby }}
18
+ uses: actions/setup-ruby@v1
19
+ with:
20
+ ruby-version: ${{ matrix.ruby }}
21
+ - name: Build and test with Rake
22
+ run: |
23
+ gem install bundler
24
+ bundle install --jobs 4 --retry 3
25
+ bundle exec rake
data/.gitignore ADDED
@@ -0,0 +1,11 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /_yardoc/
4
+ /coverage/
5
+ /doc/
6
+ /pkg/
7
+ /spec/reports/
8
+ /tmp/
9
+
10
+ # rspec failure tracking
11
+ .rspec_status
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.travis.yml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ sudo: false
3
+ language: ruby
4
+ cache: bundler
5
+ rvm:
6
+ - 2.5.1
7
+ before_install: gem install bundler -v 2.0.1
data/CHANGELOG.md ADDED
@@ -0,0 +1,30 @@
1
+ ### Unreleased
2
+
3
+ ### 2.1.1 - 2021-03-15
4
+
5
+ * bug fixes
6
+ * Address final Ruby 2.7 warnings (#24)
7
+
8
+ ### 2.1.0 - 2021-01-05
9
+
10
+ * enhancements
11
+ * Return `method` object instead of monkey patched instance (#17)
12
+ * Prepare for Ruby 3.0 (#22)
13
+
14
+ ### 2.0.0 - 2020-03-16
15
+
16
+ * breaking changes
17
+ * Raises exception if shadowing an existing `#call` method in a dependency (#15)
18
+
19
+ ### 1.0.3 - 2020-03-16
20
+
21
+ * enhancements
22
+ * Added GitHub Actions as CI (#9)
23
+
24
+ * bug fixes
25
+ * Fixed a bug that wouldn't pass a block if `#call` was aliased (#7)
26
+
27
+ ### 1.0.2 - 2020-03-02
28
+
29
+ * security
30
+ * Bump `rake` to 12.3.3 (#11)
@@ -0,0 +1,74 @@
1
+ # Contributor Covenant Code of Conduct
2
+
3
+ ## Our Pledge
4
+
5
+ In the interest of fostering an open and welcoming environment, we as
6
+ contributors and maintainers pledge to making participation in our project and
7
+ our community a harassment-free experience for everyone, regardless of age, body
8
+ size, disability, ethnicity, gender identity and expression, level of experience,
9
+ nationality, personal appearance, race, religion, or sexual identity and
10
+ orientation.
11
+
12
+ ## Our Standards
13
+
14
+ Examples of behavior that contributes to creating a positive environment
15
+ include:
16
+
17
+ * Using welcoming and inclusive language
18
+ * Being respectful of differing viewpoints and experiences
19
+ * Gracefully accepting constructive criticism
20
+ * Focusing on what is best for the community
21
+ * Showing empathy towards other community members
22
+
23
+ Examples of unacceptable behavior by participants include:
24
+
25
+ * The use of sexualized language or imagery and unwelcome sexual attention or
26
+ advances
27
+ * Trolling, insulting/derogatory comments, and personal or political attacks
28
+ * Public or private harassment
29
+ * Publishing others' private information, such as a physical or electronic
30
+ address, without explicit permission
31
+ * Other conduct which could reasonably be considered inappropriate in a
32
+ professional setting
33
+
34
+ ## Our Responsibilities
35
+
36
+ Project maintainers are responsible for clarifying the standards of acceptable
37
+ behavior and are expected to take appropriate and fair corrective action in
38
+ response to any instances of unacceptable behavior.
39
+
40
+ Project maintainers have the right and responsibility to remove, edit, or
41
+ reject comments, commits, code, wiki edits, issues, and other contributions
42
+ that are not aligned to this Code of Conduct, or to ban temporarily or
43
+ permanently any contributor for other behaviors that they deem inappropriate,
44
+ threatening, offensive, or harmful.
45
+
46
+ ## Scope
47
+
48
+ This Code of Conduct applies both within project spaces and in public spaces
49
+ when an individual is representing the project or its community. Examples of
50
+ representing a project or community include using an official project e-mail
51
+ address, posting via an official social media account, or acting as an appointed
52
+ representative at an online or offline event. Representation of a project may be
53
+ further defined and clarified by project maintainers.
54
+
55
+ ## Enforcement
56
+
57
+ Instances of abusive, harassing, or otherwise unacceptable behavior may be
58
+ reported by contacting the project team at dev@rubiconmd.com. All
59
+ complaints will be reviewed and investigated and will result in a response that
60
+ is deemed necessary and appropriate to the circumstances. The project team is
61
+ obligated to maintain confidentiality with regard to the reporter of an incident.
62
+ Further details of specific enforcement policies may be posted separately.
63
+
64
+ Project maintainers who do not follow or enforce the Code of Conduct in good
65
+ faith may face temporary or permanent repercussions as determined by other
66
+ members of the project's leadership.
67
+
68
+ ## Attribution
69
+
70
+ This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
71
+ available at [http://contributor-covenant.org/version/1/4][version]
72
+
73
+ [homepage]: http://contributor-covenant.org
74
+ [version]: http://contributor-covenant.org/version/1/4/
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source "https://rubygems.org"
2
+
3
+ # Specify your gem's dependencies in injectable.gemspec
4
+ gemspec
data/Gemfile.lock ADDED
@@ -0,0 +1,45 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ maglev-injectable (2.1.1)
5
+
6
+ GEM
7
+ remote: https://rubygems.org/
8
+ specs:
9
+ byebug (11.1.3)
10
+ coderay (1.1.3)
11
+ diff-lcs (1.4.4)
12
+ method_source (1.0.0)
13
+ pry (0.13.1)
14
+ coderay (~> 1.1)
15
+ method_source (~> 1.0)
16
+ pry-byebug (3.9.0)
17
+ byebug (~> 11.0)
18
+ pry (~> 0.13.0)
19
+ rake (13.0.3)
20
+ rspec (3.10.0)
21
+ rspec-core (~> 3.10.0)
22
+ rspec-expectations (~> 3.10.0)
23
+ rspec-mocks (~> 3.10.0)
24
+ rspec-core (3.10.1)
25
+ rspec-support (~> 3.10.0)
26
+ rspec-expectations (3.10.1)
27
+ diff-lcs (>= 1.2.0, < 2.0)
28
+ rspec-support (~> 3.10.0)
29
+ rspec-mocks (3.10.1)
30
+ diff-lcs (>= 1.2.0, < 2.0)
31
+ rspec-support (~> 3.10.0)
32
+ rspec-support (3.10.1)
33
+
34
+ PLATFORMS
35
+ ruby
36
+
37
+ DEPENDENCIES
38
+ bundler (~> 2.0)
39
+ maglev-injectable!
40
+ pry-byebug
41
+ rake (~> 13.0)
42
+ rspec (~> 3.0)
43
+
44
+ BUNDLED WITH
45
+ 2.2.4
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2019 amrocco
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,360 @@
1
+ # Injectable
2
+
3
+ [![Maintainability](https://api.codeclimate.com/v1/badges/a45cc5935a5c16b837ed/maintainability)](https://codeclimate.com/github/rubiconmd/injectable/maintainability)![Ruby](https://github.com/rubiconmd/injectable/workflows/Ruby/badge.svg)
4
+
5
+ `Injectable` is an opinionated and declarative [Dependency Injection](https://en.wikipedia.org/wiki/Dependency_injection) library for ruby.
6
+
7
+ It is being used in production (under ruby 2.5.1) in [RubiconMD](https://github.com/rubiconmd) and was extracted from its codebase.
8
+
9
+ ## Installation
10
+
11
+ Add this line to your application's Gemfile:
12
+
13
+ ```ruby
14
+ gem 'injectable', '>= 1.0.0'
15
+ ```
16
+
17
+ And then execute:
18
+
19
+ $ bundle
20
+
21
+ Or install it yourself as:
22
+
23
+ $ gem install injectable
24
+
25
+ ## Motivation
26
+
27
+ The main motivation of `Injectable` is to ease compliance with [SOLID's](https://en.wikipedia.org/wiki/SOLID)\*, [SRP](https://en.wikipedia.org/wiki/Single_responsibility_principle)\* and [Dependency Inversion principle](https://en.wikipedia.org/wiki/Dependency_inversion_principle) by providing a declarative and very readable [DSL](https://en.wikipedia.org/wiki/Domain-specific_language)\* which avoids lots of bolierplate code and thus encourages good practices.*
28
+
29
+ *Sorry about the acronyms, but using an [Ubiquitous Language](https://martinfowler.com/bliki/UbiquitousLanguage.html) is important.
30
+
31
+ ### Encapsulate domain logic
32
+
33
+ Using Ruby on Rails recommended practices as an example, when your application grows enough you usually end up with huge model classes with too many responsibilities.
34
+
35
+ It's way better (although it requires effort and discipline) to split those models and extract domain logic into [Service Objects](https://martinfowler.com/bliki/AnemicDomainModel.html) ("SOs" from now on). You can do this without `Injectable`, but `Injectable` will make your SOs way more readable and a pleasure not only to write but also to test, while encouraging general good practices.
36
+
37
+ ### Avoiding to hardcode dependencies
38
+
39
+ If you find occurences of `SomeClass.any_instance.expects(:method)` in your **unit** tests, then you are probably hardcoding dependencies:
40
+
41
+ ```rb
42
+ test "MyClass#call"
43
+ Collaborator.any_instance.expects(:submit!) # hardcoded dependency
44
+ MyClass.new.call
45
+ end
46
+
47
+ class MyClass
48
+ attr_reader :collaborator
49
+
50
+ def initialize
51
+ @collaborator = Collaborator.new
52
+ end
53
+
54
+ def call
55
+ collaborator.submit!
56
+ end
57
+ end
58
+ ```
59
+
60
+ What if you did this instead:
61
+
62
+ ```rb
63
+ test "MyClass#call"
64
+ collaborator = stub('Collaborator')
65
+ collaborator.expects(:submit!)
66
+ MyClass.new(collaborator: collaborator).call
67
+ end
68
+
69
+ class MyClass
70
+ attr_reader :collaborator
71
+
72
+ def initialize(collaborator: Collaborator.new) # we will just provide a default
73
+ @collaborator = collaborator
74
+ end
75
+
76
+ def call
77
+ collaborator.submit!
78
+ end
79
+ end
80
+ ```
81
+
82
+ The benefits are not only for testing, as now your class is more modular and you can swap collaborators as long as they have the proper interface, in this case they have to `respond_to :submit!`
83
+
84
+ `Injectable` allows you to write the above code like this:
85
+
86
+ ```rb
87
+ class MyClass
88
+ include Injectable
89
+
90
+ dependency :collaborator
91
+
92
+ def call
93
+ collaborator.submit!
94
+ end
95
+ end
96
+ ```
97
+
98
+ It might not seem a lot but:
99
+
100
+ 1. Imagine that you have 4 dependencies. That's a lot of boilerplate.
101
+ 2. `Injectable` is not only this, it has many more features. Please keep reading.
102
+
103
+ ## Usage example
104
+
105
+ `Injectable` is a mixin that you have to include in your class and it will provide several macros.
106
+
107
+ This is a real world example:
108
+
109
+ ```rb
110
+ class PdfGenerator
111
+ include Injectable
112
+
113
+ dependency :wicked_pdf
114
+
115
+ argument :html
116
+ argument :render_footer, default: false
117
+
118
+ def call
119
+ wicked_pdf.pdf_from_string(html, options)
120
+ end
121
+
122
+ private
123
+
124
+ def options
125
+ return {} unless render_footer
126
+
127
+ {
128
+ footer: {
129
+ left: footer,
130
+ }
131
+ }
132
+ end
133
+
134
+ def footer
135
+ "Copyright ® #{Time.current.year}"
136
+ end
137
+ end
138
+
139
+ # And you would use it like this:
140
+ PdfGenerator.call(html: '<some html here>')
141
+ # Overriding the wicked_pdf dependency:
142
+ PdfGenerator.new(wicked_pdf: wicked_pdf_replacement).call(html: '<some html>')
143
+ ```
144
+
145
+ ## Premises
146
+
147
+ In order to understand how (and why) `Injectable` works, you need to know some principles.
148
+
149
+ ### #1 The `#call` method
150
+
151
+ `Injectable` classes **must define a public `#call` method that takes no arguments**.
152
+
153
+ This is **the only public method** you will be defining in your `Injectable` classes.
154
+
155
+ ```rb
156
+ # Correct ✅
157
+ def call
158
+ # do stuff
159
+ end
160
+
161
+ # Wrong ❗️
162
+ def call(some_argument)
163
+ # won't work and will raise an exception at runtime
164
+ end
165
+ ```
166
+
167
+ If you want your `#call` method to receive arguments, that's what the `#argument` macro is for. BTW, we call those **runtime arguments**.
168
+
169
+ Why `#call`?
170
+
171
+ Because it's a ruby idiom. Many things in ruby are `callable`, like lambdas.
172
+
173
+ ### #2 The `initialize` method
174
+
175
+ Injectable classes take their **dependencies as keyword arguments** on the `initialize` method. They can also take **configuration arguments** on `initialize`:
176
+
177
+ ```rb
178
+ MyClass.new(some_dep: some_dep_instance, some_config: true).call
179
+ ```
180
+
181
+ `Injectable` instantiates **dependencies that you have declared with the `dependency` macro** for you and passes them to `initialize`, so if you don't want to override those you don't even need to instantiate the class and you can use the provided class method **`#call` shortcut**:
182
+
183
+ ```rb
184
+ Myclass.call # This is calling `initialize` under the hood
185
+ ```
186
+
187
+ If you need to override dependencies or configuration options, just call `new` yourself:
188
+
189
+ ```rb
190
+ Myclass.new(some_dep: Override.new, some_config: false).call
191
+ ```
192
+
193
+ If you do that, **any dependency that you didn't pass will be injected by `Injectable`**.
194
+ Notice that **configuration arguments**, which are declared with `#initialize_with` behave in the exact same way.
195
+
196
+ ### #3 Keyword arguments
197
+
198
+ Both `#initialize` and `#call` take **keyword arguments**.
199
+
200
+ ### #4 Readers
201
+
202
+ All `Injectable` macros define reader methods for you, that's why you define `#call` without arguments, because **you access everything you declare via reader methods**.
203
+
204
+ ## The `#dependency` macro
205
+
206
+ This is the main reason why you want to use this library in the first place.
207
+
208
+ There are several ways of declaring a `#dependency`:
209
+
210
+ ### Bare dependency name
211
+
212
+ ```rb
213
+ class ReportPdfRenderer
214
+ include Injectable
215
+
216
+ dependency :some_dependency
217
+ end
218
+ ```
219
+
220
+ 1. `Injectable` first tries to find the `SomeDependency` constant in `ReportPdfRenderer`namespace.
221
+ 2. If it doesn't find it, then tries without namespace (`::SomeDependency`).
222
+
223
+ Notice that this happens **at runtime**, not when defining your class.
224
+
225
+ ### Explicit, inline class:
226
+
227
+ ```rb
228
+ class MyInjectable
229
+ include Injectable
230
+
231
+ dependency :client, class: Aws::S3::Client
232
+ dependency :parser, class: VeryLongClassNameForMyParser
233
+ end
234
+ ```
235
+
236
+ Nothing fancy here, you are explicitly telling `Injectable` which class to instantiate for you.
237
+
238
+ You will want to use this style for example if the class is namespaced somewhere else or if you want a different name other than the class', like for example if it's too long.
239
+
240
+ Notice that this approach sets the class when ruby interprets the class, **not at runtime**.
241
+
242
+ ### With a block:
243
+
244
+ ```rb
245
+ dependency :complex_client do
246
+ instance = ThirdPartyLib.new(:foo, bar: 'goo')
247
+ instance.set_config(:name, 'value')
248
+ instance
249
+ end
250
+ ```
251
+
252
+ It's important to understand that `Injectable` won't call `#new` on whatever you return from this block.
253
+
254
+ You probably want to use this when your dependency has a complex setup. We use it a lot when wrapping third party libraries which aren't reused elsewhere.
255
+
256
+ If you want to wrap a third party library and you need to reuse it, then we recommend that you write a specific `Injectable` class for it, so it adheres to its principles and is easier to use.
257
+
258
+ ### `#dependency` options
259
+
260
+ #### `:with`
261
+
262
+ If the dependency takes arguments, you can set them with :with
263
+
264
+ ```rb
265
+ # Arrays will be splatted: WithNormalArguments.new(1, 2, 3)
266
+ dependency :with_normal_arguments, with: [1, 2, 3]
267
+ # Hashes will be passed as-is: WithKeywordArguments.new(foo: 'bar)
268
+ dependency :with_keyword_arguments, with: { foo: 'bar' }
269
+ ```
270
+
271
+ ### `:depends_on`
272
+
273
+ It allows you to share **memoized instances** of dependencies and supports both a single dependency or multiples as an Array:
274
+
275
+ ```rb
276
+ dependency :client # this will be instantiated just once and will be shared
277
+ dependency :reporter, depends_on: :client
278
+ dependency :mailer, depends_on: %i[client reporter]
279
+ ```
280
+
281
+ Dependencies of dependencies will be passed as keyword arguments using the same name they were declared with. In the example above, `Injectable` will instantiate a `Mailer` class passing `{ client: client, reporter: reporter }` to `#initialize`.
282
+
283
+ If you have a dependency that is defined with a block which also depends_on other dependencies, you'll receive those as keyword arguments:
284
+
285
+ ```rb
286
+ dependency :my_dependency, depends_on: :client do |client:|
287
+ MyDependency.new(client)
288
+ end
289
+ ```
290
+
291
+ ### `:call`
292
+
293
+ Sometimes you have a class that doesn't adhere to `Injectable` principles:
294
+
295
+ ```rb
296
+ dependency :renderer
297
+
298
+ def call
299
+ renderer.render # this class does not respond to `call`
300
+ end
301
+ ```
302
+
303
+ `:call` is a way of wrapping such dependency so it behaves like an `Injectable`:
304
+
305
+ ```rb
306
+ dependency :renderer, call: :render
307
+
308
+ def call
309
+ renderer.call
310
+ end
311
+ ```
312
+
313
+ It's important to understand that **you can mix and match all dependency configurations and options** described above.
314
+
315
+ ## `#initialize_with` macro
316
+
317
+ This macro is meant for **configuration arguments** passed to `initialize`:
318
+
319
+ ```rb
320
+ initialize_with :debug, default: false
321
+ ```
322
+
323
+ If you don't pass the `:default` option the argument will be required.
324
+
325
+ ## `#argument` macro
326
+
327
+ `#argument` allows you to define **runtime arguments** passed to `#call`
328
+
329
+ ```rb
330
+ argument :browser, default: 'Unknown'
331
+ ```
332
+
333
+ If you don't pass the `:default` option the argument will be required.
334
+
335
+
336
+ ## Development
337
+
338
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
339
+
340
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
341
+
342
+ ## Contributing
343
+
344
+ Bug reports and pull requests are welcome on GitHub at https://github.com/rubiconmd/injectable. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
345
+
346
+ ## License
347
+
348
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
349
+
350
+ ## Code of Conduct
351
+
352
+ Everyone interacting in the Injectable project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](CODE_OF_CONDUCT.md).
353
+
354
+ ## Credits
355
+
356
+ - [RubiconMD](https://github.com/rubiconmd) allowed extracting this gem from its codebase and release it as open source.
357
+ - [Durran Jordan](https://github.com/durran) allowed the usage of the gem name at rubygems.org.
358
+ - [David Marchante](https://github.com/iovis) brainstormed the `initialize`/`call` approach, did all code reviews and provided lots of insightful feedback and suggestions. He also wrote the inline documentation.
359
+ - [Julio Antequera](https://github.com/jantequera), [Jimmi Carney](https://github.com/ayoformayo) and [Anthony Rocco](https://github.com/amrocco) had the patience to use it and report many bugs. Also most of the features in this gem came up when reviewing their usage of it. Anthony also made the effort of extracting the code from RubiconMD's codebase.
360
+ - [Rodrigo Álvarez](https://github.com/Papipo) had the idea for the DSL and actually wrote the library.
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 "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,159 @@
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
+ return instance_variable_get("@#{name}") if instance_variable_defined?("@#{name}")
103
+
104
+ instance_variable_set "@#{name}", instantiate_dependency(name)
105
+ end
106
+ end
107
+
108
+ # Declare the arguments for `#call` and initialize the accessors
109
+ # This helps us clean up the code for memoization:
110
+ #
111
+ # ```
112
+ # private
113
+ #
114
+ # def player
115
+ # # player_id exists in the context because we added it as an argument
116
+ # @player ||= player_query.call(player_id)
117
+ # end
118
+ # ```
119
+ #
120
+ # Every argument is required unless given an optional default value
121
+ # @param name Name of the argument
122
+ # @option options :default The default value of the argument
123
+ # @example
124
+ # argument :player_id
125
+ # # => def call(player_id:)
126
+ # # => @player_id = player_id
127
+ # # => end
128
+ # @example with default arguments
129
+ # argument :team_id, default: 1
130
+ # # => def call(team_id: 1)
131
+ # # => @team_id = team_id
132
+ # # => end
133
+ def argument(name, options = {})
134
+ call_arguments[name] = options
135
+ attr_accessor name
136
+ end
137
+
138
+ def initialize_with(name, options = {})
139
+ initialize_arguments[name] = options
140
+ attr_accessor name
141
+ end
142
+
143
+ # Get the #initialize arguments declared with '.initialize_with' with no default
144
+ # @private
145
+ def required_initialize_arguments
146
+ find_required_arguments initialize_arguments
147
+ end
148
+
149
+ # Get the #call arguments declared with '.argument' with no default
150
+ # @private
151
+ def required_call_arguments
152
+ find_required_arguments call_arguments
153
+ end
154
+
155
+ def find_required_arguments(hash)
156
+ hash.reject { |_arg, options| options.key?(:default) }.keys
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,40 @@
1
+ require 'forwardable'
2
+
3
+ module Injectable
4
+ # Holds the dependency signatures of the service object
5
+ class DependenciesGraph
6
+ attr_reader :graph, :dependency_class
7
+ attr_accessor :namespace
8
+ extend Forwardable
9
+ def_delegators :@graph, :[]
10
+
11
+ def initialize(namespace:, dependency_class: ::Injectable::Dependency)
12
+ @namespace = namespace
13
+ @graph = {}
14
+ @dependency_class = dependency_class
15
+ end
16
+
17
+ def names
18
+ graph.keys
19
+ end
20
+
21
+ def with_namespace(namespace)
22
+ dup.tap { |dupe| dupe.namespace = namespace }
23
+ end
24
+
25
+ # Adds the signature of a dependency to the graph
26
+ def add(name:, depends_on:, **kwargs)
27
+ check_for_missing_dependencies!(depends_on)
28
+ graph[name] = dependency_class.new(kwargs.merge(name: name, depends_on: depends_on))
29
+ end
30
+
31
+ private
32
+
33
+ def check_for_missing_dependencies!(deps)
34
+ missing = deps.reject { |dep| graph.key?(dep) }
35
+ return if missing.empty?
36
+
37
+ raise Injectable::MissingDependenciesException, "missing dependencies: #{missing.join(', ')}"
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,76 @@
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
+ positional_args, kwargs = split_args(args)
6
+
7
+ wrap_call build_instance(positional_args, kwargs, namespace: namespace)
8
+ end
9
+
10
+ private
11
+
12
+ def split_args(args)
13
+ args = preprocess_args(args)
14
+
15
+ return [[], {}] if args.empty?
16
+
17
+ kwargs = args.pop
18
+
19
+ if kwargs.is_a?(Hash) && kwargs.keys.all? { |key| key.is_a?(Symbol) }
20
+ [args, kwargs]
21
+ else
22
+ [args << kwargs, {}]
23
+ end
24
+ end
25
+
26
+ def preprocess_args(args)
27
+ args = with unless with.nil?
28
+ wrap_args(args)
29
+ end
30
+
31
+ def wrap_args(args)
32
+ args.is_a?(Array) ? args.clone : [args]
33
+ end
34
+
35
+ def wrap_call(the_instance)
36
+ return the_instance unless call
37
+
38
+ if the_instance.respond_to? :call
39
+ raise Injectable::MethodAlreadyExistsException
40
+ end
41
+
42
+ the_instance.public_method(call)
43
+ end
44
+
45
+ def build_instance(args, kwargs, namespace:)
46
+ return build_instance_26(args, kwargs, namespace: namespace) if RUBY_VERSION < '2.7'
47
+
48
+ instantiator = block || klass(namespace: namespace).method(:new)
49
+ if kwargs.empty?
50
+ # otherwise an empty hash will be added in ruby 2.7, which could be taken as
51
+ # positional argument instead.
52
+ instantiator.call(*args)
53
+ else
54
+ instantiator.call(*args, **kwargs)
55
+ end
56
+ end
57
+
58
+ def klass(namespace:)
59
+ self.class || resolve(namespace: namespace)
60
+ end
61
+
62
+ def resolve(namespace:)
63
+ (namespace || Object).const_get(camelcased)
64
+ end
65
+
66
+ def camelcased
67
+ @camelcased ||= name.to_s.split('_').map(&:capitalize).join
68
+ end
69
+
70
+ def build_instance_26(args, kwargs, namespace:)
71
+ args << kwargs if kwargs.any?
72
+
73
+ block.nil? ? klass(namespace: namespace).new(*args) : block.call(*args)
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,68 @@
1
+ module Injectable
2
+ module InstanceMethods
3
+ # Initialize the service with the dependencies injected
4
+ def initialize(args = {})
5
+ check_missing_arguments!(self.class.required_initialize_arguments, args)
6
+ variables_for!(self.class.initialize_arguments, args)
7
+ variables_from_dependencies!(args)
8
+ super()
9
+ end
10
+
11
+ # Entry point of the service.
12
+ # Arguments for this method should be declared explicitly with '.argument'
13
+ # and declare this method without arguments
14
+ def call(args = {})
15
+ check_call_definition!
16
+ check_missing_arguments!(self.class.required_call_arguments, args)
17
+ variables_for!(self.class.call_arguments, args)
18
+ super()
19
+ end
20
+
21
+ private
22
+
23
+ def instantiate_dependency(name)
24
+ deps = self.class.dependencies
25
+ deps[name].instance(args: memoized_dependencies_of(name), namespace: deps.namespace)
26
+ end
27
+
28
+ def memoized_dependencies_of(name)
29
+ return [] if dependencies_of(name).empty?
30
+
31
+ dependencies_of(name).each_with_object({}) { |dep, hash| hash[dep] = public_send(dep) }
32
+ end
33
+
34
+ def dependencies_of(name)
35
+ self.class.dependencies[name].depends_on
36
+ end
37
+
38
+ def check_call_definition!
39
+ return if (self.class.ancestors - [Injectable::InstanceMethods]).any? do |ancestor|
40
+ ancestor.instance_methods(false).include?(:call)
41
+ end
42
+ raise NoMethodError, "A #call method with zero arity must be defined in #{self.class}"
43
+ end
44
+
45
+ def check_missing_arguments!(expected, args)
46
+ missing = expected - args.keys
47
+ return if missing.empty?
48
+ raise ArgumentError, "missing keywords: #{missing.join(',')}"
49
+ end
50
+
51
+ def variables_for!(subject, args)
52
+ subject.each do |arg, options|
53
+ instance_variable_set("@#{arg}", args.fetch(arg) { options[:default] })
54
+ end
55
+ end
56
+
57
+ def variables_from_dependencies!(args)
58
+ self.class.dependencies.names.each do |name|
59
+ next if self.class.initialize_arguments.key?(name)
60
+ next unless args.key?(name)
61
+ next instance_variable_set("@#{name}", args[name]) unless args[name].respond_to?(:new)
62
+ next instance_variable_set("@#{name}", args[name].new) if dependencies_of(name).empty?
63
+
64
+ instance_variable_set("@#{name}", args[name].new(memoized_dependencies_of(name)))
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,4 @@
1
+ module Injectable
2
+ class MethodAlreadyExistsException < RuntimeError
3
+ end
4
+ end
@@ -0,0 +1,4 @@
1
+ module Injectable
2
+ class MissingDependenciesException < RuntimeError
3
+ end
4
+ end
@@ -0,0 +1,3 @@
1
+ module Injectable
2
+ VERSION = '2.1.1'.freeze
3
+ end
data/lib/injectable.rb ADDED
@@ -0,0 +1,62 @@
1
+ require 'injectable/version'
2
+ require 'injectable/class_methods'
3
+ require 'injectable/dependencies_graph'
4
+ require 'injectable/dependency'
5
+ require 'injectable/instance_methods'
6
+ require 'injectable/missing_dependencies_exception'
7
+ require 'injectable/method_already_exists_exception'
8
+
9
+ # Convert your class into an injectable service
10
+ #
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)
57
+ module Injectable
58
+ def self.included(base)
59
+ base.extend(Injectable::ClassMethods)
60
+ base.prepend(Injectable::InstanceMethods)
61
+ end
62
+ end
@@ -0,0 +1,29 @@
1
+ lib = File.expand_path('lib', __dir__)
2
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3
+ require 'injectable/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'maglev-injectable'
7
+ spec.version = Injectable::VERSION
8
+ spec.authors = %w[Papipo iovis jantequera amrocco rewritten]
9
+ spec.email = %w[dev@rubiconmd.com]
10
+
11
+ spec.summary = 'A library to help you build nice service objects with dependency injection.'
12
+ spec.homepage = 'https://github.com/rubiconmd/injectable'
13
+ spec.license = 'MIT'
14
+
15
+ # Specify which files should be added to the gem when it is released.
16
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
17
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
18
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
19
+ end
20
+ spec.bindir = 'exe'
21
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
22
+ spec.require_paths = ['lib']
23
+ spec.required_ruby_version = '>= 2.5'
24
+
25
+ spec.add_development_dependency 'bundler', '~> 2.0'
26
+ spec.add_development_dependency 'pry-byebug'
27
+ spec.add_development_dependency 'rake', '~> 13.0'
28
+ spec.add_development_dependency 'rspec', '~> 3.0'
29
+ end
metadata ADDED
@@ -0,0 +1,125 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: maglev-injectable
3
+ version: !ruby/object:Gem::Version
4
+ version: 2.1.1
5
+ platform: ruby
6
+ authors:
7
+ - Papipo
8
+ - iovis
9
+ - jantequera
10
+ - amrocco
11
+ - rewritten
12
+ autorequire:
13
+ bindir: exe
14
+ cert_chain: []
15
+ date: 2022-09-27 00:00:00.000000000 Z
16
+ dependencies:
17
+ - !ruby/object:Gem::Dependency
18
+ name: bundler
19
+ requirement: !ruby/object:Gem::Requirement
20
+ requirements:
21
+ - - "~>"
22
+ - !ruby/object:Gem::Version
23
+ version: '2.0'
24
+ type: :development
25
+ prerelease: false
26
+ version_requirements: !ruby/object:Gem::Requirement
27
+ requirements:
28
+ - - "~>"
29
+ - !ruby/object:Gem::Version
30
+ version: '2.0'
31
+ - !ruby/object:Gem::Dependency
32
+ name: pry-byebug
33
+ requirement: !ruby/object:Gem::Requirement
34
+ requirements:
35
+ - - ">="
36
+ - !ruby/object:Gem::Version
37
+ version: '0'
38
+ type: :development
39
+ prerelease: false
40
+ version_requirements: !ruby/object:Gem::Requirement
41
+ requirements:
42
+ - - ">="
43
+ - !ruby/object:Gem::Version
44
+ version: '0'
45
+ - !ruby/object:Gem::Dependency
46
+ name: rake
47
+ requirement: !ruby/object:Gem::Requirement
48
+ requirements:
49
+ - - "~>"
50
+ - !ruby/object:Gem::Version
51
+ version: '13.0'
52
+ type: :development
53
+ prerelease: false
54
+ version_requirements: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - "~>"
57
+ - !ruby/object:Gem::Version
58
+ version: '13.0'
59
+ - !ruby/object:Gem::Dependency
60
+ name: rspec
61
+ requirement: !ruby/object:Gem::Requirement
62
+ requirements:
63
+ - - "~>"
64
+ - !ruby/object:Gem::Version
65
+ version: '3.0'
66
+ type: :development
67
+ prerelease: false
68
+ version_requirements: !ruby/object:Gem::Requirement
69
+ requirements:
70
+ - - "~>"
71
+ - !ruby/object:Gem::Version
72
+ version: '3.0'
73
+ description:
74
+ email:
75
+ - dev@rubiconmd.com
76
+ executables: []
77
+ extensions: []
78
+ extra_rdoc_files: []
79
+ files:
80
+ - ".github/workflows/ruby.yml"
81
+ - ".gitignore"
82
+ - ".rspec"
83
+ - ".travis.yml"
84
+ - CHANGELOG.md
85
+ - CODE_OF_CONDUCT.md
86
+ - Gemfile
87
+ - Gemfile.lock
88
+ - LICENSE.txt
89
+ - README.md
90
+ - Rakefile
91
+ - bin/console
92
+ - bin/setup
93
+ - lib/injectable.rb
94
+ - lib/injectable/class_methods.rb
95
+ - lib/injectable/dependencies_graph.rb
96
+ - lib/injectable/dependency.rb
97
+ - lib/injectable/instance_methods.rb
98
+ - lib/injectable/method_already_exists_exception.rb
99
+ - lib/injectable/missing_dependencies_exception.rb
100
+ - lib/injectable/version.rb
101
+ - maglev-injectable.gemspec
102
+ homepage: https://github.com/rubiconmd/injectable
103
+ licenses:
104
+ - MIT
105
+ metadata: {}
106
+ post_install_message:
107
+ rdoc_options: []
108
+ require_paths:
109
+ - lib
110
+ required_ruby_version: !ruby/object:Gem::Requirement
111
+ requirements:
112
+ - - ">="
113
+ - !ruby/object:Gem::Version
114
+ version: '2.5'
115
+ required_rubygems_version: !ruby/object:Gem::Requirement
116
+ requirements:
117
+ - - ">="
118
+ - !ruby/object:Gem::Version
119
+ version: '0'
120
+ requirements: []
121
+ rubygems_version: 3.1.6
122
+ signing_key:
123
+ specification_version: 4
124
+ summary: A library to help you build nice service objects with dependency injection.
125
+ test_files: []