mortymer 0.0.9 → 0.0.11
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 +4 -4
- data/docs/.vitepress/config.mts +17 -4
- data/docs/.vitepress/theme/style.css +0 -2
- data/docs/guide/dependency-injection.md +205 -0
- data/docs/guide/mortymer-dependency-injection.md +398 -0
- data/lib/mortymer/container.rb +7 -11
- data/lib/mortymer/contract.rb +18 -2
- data/lib/mortymer/model.rb +2 -2
- data/lib/mortymer/sigil.rb +92 -0
- data/lib/mortymer/struct_compiler.rb +110 -0
- data/lib/mortymer/version.rb +1 -1
- data/lib/mortymer.rb +4 -0
- metadata +6 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 1aaae1b0260575ca60caeb8b676577f39766162d8127a6266e1c49e168e11265
|
4
|
+
data.tar.gz: 0f92cfaa12e157dcb4954259dd284b7b46f7305dd912d84a55fef597107997f7
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: c5c3932bfb5b0daf315af6f8f4a1ef36b0a448229fd15b9c71f7c8699d8f41fcfeb8a5858686b55d6c7905628936908512e97537b6a5ec0024061f6b3f4bcd60
|
7
|
+
data.tar.gz: df393725ff3c29b36ede225c046f8f27cbfea156f09917cd1976b8c3188f9dafa5e57e0aa0876a378f7afda62ab0ea06b17f500d0f46f6d8caae18c249c13acf
|
data/docs/.vitepress/config.mts
CHANGED
@@ -7,7 +7,10 @@ export default defineConfig({
|
|
7
7
|
description:
|
8
8
|
"Standalone API definition for ruby frameworks based on dry.rb. Rails compatible from day 0 with a full Ruby flavored dependency injection engine.",
|
9
9
|
markdown: {
|
10
|
-
theme:
|
10
|
+
theme: {
|
11
|
+
light: "catppuccin-latte",
|
12
|
+
dark: "catppuccin-mocha",
|
13
|
+
},
|
11
14
|
},
|
12
15
|
themeConfig: {
|
13
16
|
nav: [
|
@@ -36,7 +39,17 @@ export default defineConfig({
|
|
36
39
|
{ text: "Type System", link: "/guide/type-system" },
|
37
40
|
{
|
38
41
|
text: "Dependency Injection",
|
39
|
-
|
42
|
+
items: [
|
43
|
+
{
|
44
|
+
text: "Introduction to DI in Ruby",
|
45
|
+
link: "/guide/dependency-injection",
|
46
|
+
},
|
47
|
+
|
48
|
+
{
|
49
|
+
text: "DI in Mortymer",
|
50
|
+
link: "/guide/mortymer-dependency-injection",
|
51
|
+
},
|
52
|
+
],
|
40
53
|
},
|
41
54
|
{ text: "Error Handling", link: "/guide/error-handling" },
|
42
55
|
],
|
@@ -63,12 +76,12 @@ export default defineConfig({
|
|
63
76
|
},
|
64
77
|
|
65
78
|
socialLinks: [
|
66
|
-
{ icon: "github", link: "https://github.com/
|
79
|
+
{ icon: "github", link: "https://github.com/adriangs1996/morty" },
|
67
80
|
],
|
68
81
|
|
69
82
|
footer: {
|
70
83
|
message: "Released under the MIT License.",
|
71
|
-
copyright: "Copyright ©
|
84
|
+
copyright: "Copyright © 2025-present",
|
72
85
|
},
|
73
86
|
},
|
74
87
|
});
|
@@ -0,0 +1,205 @@
|
|
1
|
+
# Dependency Injection
|
2
|
+
|
3
|
+
## What is Dependency Injection?
|
4
|
+
|
5
|
+
Dependency Injection (DI) is a design pattern that implements Inversion of Control (IoC) for managing dependencies between components.
|
6
|
+
Instead of having your components create or find their dependencies, these dependencies are "injected" into the component from the outside.
|
7
|
+
|
8
|
+
Consider this example without dependency injection:
|
9
|
+
|
10
|
+
```ruby
|
11
|
+
class UserService
|
12
|
+
def initialize
|
13
|
+
@repository = UserRepository.new
|
14
|
+
@mailer = UserMailer.new
|
15
|
+
end
|
16
|
+
|
17
|
+
def create_user(params)
|
18
|
+
user = @repository.create(params)
|
19
|
+
@mailer.send_welcome_email(user)
|
20
|
+
user
|
21
|
+
end
|
22
|
+
end
|
23
|
+
```
|
24
|
+
|
25
|
+
In the above code, UserService explicitly creates instances of UserRepository and UserMailer,
|
26
|
+
making it tightly coupled to these dependencies. When arguing why DI is usefull, commonly
|
27
|
+
the following arguments arise:
|
28
|
+
|
29
|
+
- <b>Harder to Test</b>: We cannot easily substitute UserRepository or UserMailer with mocks or stubs during testing.
|
30
|
+
This is not entirely true, as in Ruby you can mock pretty much everything, so this does not make a really good
|
31
|
+
argument, although mocking becomes easier when using DI.
|
32
|
+
|
33
|
+
- <b>Less Flexible</b>: If we later decide to use a different repository or mailer, we must modify UserService directly.
|
34
|
+
Think if we have two implementations of UserMailer, for some reason you want to send emails using SendGrid, but other times
|
35
|
+
you want to send emails using your cloud provider solution, or your custom server. UserService really does not cares
|
36
|
+
about how this is implemented or where the emails goes, it just need to send an email for whatever channel we choose.
|
37
|
+
|
38
|
+
- <b>Tightly Coupled</b>: UserService is responsible for constructing its dependencies, making it harder to manage and extend.
|
39
|
+
|
40
|
+
A DI-based approach improves flexibility and testability:
|
41
|
+
|
42
|
+
```ruby
|
43
|
+
class UserService
|
44
|
+
def initialize(repository:, mailer:)
|
45
|
+
@repository = repository
|
46
|
+
@mailer = mailer
|
47
|
+
end
|
48
|
+
|
49
|
+
def create_user(params)
|
50
|
+
user = @repository.create(params)
|
51
|
+
@mailer.send_welcome_email(user)
|
52
|
+
user
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
# Inject dependencies
|
57
|
+
repository = UserRepository.new
|
58
|
+
mailer = UserMailer.new
|
59
|
+
service = UserService.new(repository, mailer)
|
60
|
+
```
|
61
|
+
|
62
|
+
Now, UserService does not need to know how to instantiate its dependencies. Instead, they are provided externally.
|
63
|
+
|
64
|
+
## The Ruby Community’s Perspective on Dependency Injection
|
65
|
+
|
66
|
+
Rubyists often prefer simplicity and flexibility, so DI is not as commonly enforced as in other static-typed languages like Java or C#. This is a topic that spans very different point of views. Just to give an example, read the following Reddit thread
|
67
|
+
<https://www.reddit.com/r/ruby/comments/10x6w8q/dependency_injection/>
|
68
|
+
|
69
|
+
It discusses the use of Dependency Injection (DI) in Ruby, particularly within the context of Ruby on Rails (RoR). Several key points emerge from the conversation:
|
70
|
+
|
71
|
+
- <b>Testing and Metaprogramming</b>: One user notes that in languages like Java and PHP, DI is often employed to facilitate testing by allowing the injection of mock dependencies. However, in Ruby, the language's metaprogramming capabilities enable developers to mock dependencies directly without the need for DI containers, potentially simplifying the codebase.
|
72
|
+
|
73
|
+
- <b>Code Organization and Dependency Tracking</b>: Another perspective suggests that DI serves as a method to organize code systematically and maintain an efficient track of dependencies. Improved testability is viewed as a beneficial byproduct of this organization.
|
74
|
+
|
75
|
+
- <b>Coupling and Code Simplicity</b>: It's acknowledged that while avoiding DI can lead to simpler code, it may also result in tighter coupling between components. This necessitates a careful balance to ensure that the code remains maintainable without becoming overly complex.
|
76
|
+
|
77
|
+
In summary, the thread reflects a nuanced view within the Ruby community regarding DI. Some developers prefer to leverage Ruby's dynamic features and metaprogramming to manage dependencies, while others advocate for DI as a means to achieve cleaner code organization and dependency management. The choice often depends on the specific requirements and context of the project.
|
78
|
+
|
79
|
+
## The way of Mortymer
|
80
|
+
|
81
|
+
I think that DI is necessary, no matter how you decide to handle it. To be aware of
|
82
|
+
what your functionality depends on is crucial when evaluating the impact of changes. While
|
83
|
+
testability benefits exist in Ruby through its powerful metaprogramming capabilities, the main
|
84
|
+
advantage of DI is the explicitness about what your code does and what it depends on. This
|
85
|
+
transparency makes the code more maintainable and easier to understand.
|
86
|
+
|
87
|
+
Additionally, DI naturally supports the Open-Closed Principle (the 'O' in SOLID): when
|
88
|
+
dependencies are injected rather than hardcoded, new functionality can be added by creating
|
89
|
+
new implementations of interfaces rather than modifying existing code. While it's technically
|
90
|
+
possible to extend functionality without DI in Ruby, doing so often requires more complex
|
91
|
+
metaprogramming or direct code modifications, making the system harder to maintain and understand
|
92
|
+
over time.
|
93
|
+
|
94
|
+
With this in mind, we can explore different approaches to do Dependency Injection in Ruby.
|
95
|
+
|
96
|
+
### Constructor Injection
|
97
|
+
|
98
|
+
This is by far the most common implementation of DI. This includes initialization of objects
|
99
|
+
and dependencies are passed as parameters. In ruby, we would do it like:
|
100
|
+
|
101
|
+
```ruby
|
102
|
+
class UserService
|
103
|
+
def initialize(repository: UserRepository.new, mailer: UserMailer.new)
|
104
|
+
@repository = repository
|
105
|
+
@mailer = mailer
|
106
|
+
end
|
107
|
+
|
108
|
+
def find_user(user_id)
|
109
|
+
# Use @repository and @mailer
|
110
|
+
end
|
111
|
+
end
|
112
|
+
```
|
113
|
+
|
114
|
+
Note how we added a default value to the repository and mailer dependency. This is a convenience
|
115
|
+
that will let programmers to initialize easily an `UserService` using just the `#new` method
|
116
|
+
and make it clearer which interface ( any repository we use here must conform to the same interface
|
117
|
+
as `UserRepository` and the same goes to `UserMailer`)
|
118
|
+
|
119
|
+
### Method injection
|
120
|
+
|
121
|
+
Passing dependencies directly to a method when needed:
|
122
|
+
|
123
|
+
```ruby
|
124
|
+
class UserService
|
125
|
+
def create_user(params, repository: UserRepository.new, mailer: UserMailer.new)
|
126
|
+
user = repository.create(params)
|
127
|
+
mailer.send_welcome_email(user)
|
128
|
+
user
|
129
|
+
end
|
130
|
+
end
|
131
|
+
```
|
132
|
+
|
133
|
+
This approach is less common, but is really useful when you have some classes with different
|
134
|
+
dependencies for different methods. This is generally easy to avoid tough, by using the Command Pattern
|
135
|
+
or Service Objects with constructor Injection.
|
136
|
+
|
137
|
+
### Global Injection
|
138
|
+
|
139
|
+
Global Injection is often used in frameworks like Ruby on Rails,
|
140
|
+
where dependencies can be globally configured and injected throughout the application.
|
141
|
+
|
142
|
+
```ruby
|
143
|
+
# config/initializers/dependencies.rb
|
144
|
+
MyAppDependencies = {
|
145
|
+
user_repository: UserRepository.new,
|
146
|
+
mailer: UserMailer.new
|
147
|
+
}
|
148
|
+
|
149
|
+
# In the service
|
150
|
+
class UserService
|
151
|
+
def initialize(repository: MyAppDependencies[:user_repository], mailer: MyAppDependencies[:mailer])
|
152
|
+
@repository = repository
|
153
|
+
@mailer = mailer
|
154
|
+
end
|
155
|
+
|
156
|
+
def create_user(params)
|
157
|
+
user = @repository.create(params)
|
158
|
+
@mailer.send_welcome_email(user)
|
159
|
+
user
|
160
|
+
end
|
161
|
+
end
|
162
|
+
```
|
163
|
+
|
164
|
+
### Dependency Injection Containers
|
165
|
+
|
166
|
+
Gems like dry-container and dry-auto_inject help manage dependencies in larger applications:
|
167
|
+
|
168
|
+
```ruby
|
169
|
+
class UserService
|
170
|
+
include Import["repositories.user_repository", "mailers.user_mailer"]
|
171
|
+
|
172
|
+
def create_user(params)
|
173
|
+
user = user_repository.create(params)
|
174
|
+
user_mailer.send_welcome_email(user)
|
175
|
+
user
|
176
|
+
end
|
177
|
+
end
|
178
|
+
```
|
179
|
+
|
180
|
+
This category is where Mortymer fit in. Actually, DI Containers all essentially do the same: to make developer
|
181
|
+
life easier when declaring and handling this dependencies. For example, the first approach we discuss, **Constructor Injection**,
|
182
|
+
becomes really tedious to use, there is a lot of boilerplate code to write. Imaging all the time writing the same
|
183
|
+
initialize function receiving your dependencies and setting the instance variables. Well, gems do exactly that, remove
|
184
|
+
the boilerplate for you. The main difference is in how they do it and what you get in return. Gems like dry-auto_inject
|
185
|
+
, like shown above, focus on constants resolutions through strings, and dynamically created read methods for your dependencies.
|
186
|
+
|
187
|
+
Mortymer does it in a very different way. We favor dependencies being referenced using constants, because that
|
188
|
+
empowers code navigation and tools like LSP with go to definitions and such features. Also, referencing constants, allows
|
189
|
+
us to use a similar API that is found on strongly-typed languages like Java. The other main difference is that Mortymer will
|
190
|
+
not create methods on the fly for you, you will get instance variables. The same example using Mortymer looks like:
|
191
|
+
|
192
|
+
```ruby
|
193
|
+
class UserService
|
194
|
+
include Mortymer::DependenciesDsl
|
195
|
+
|
196
|
+
inject Repositories::UserRepository
|
197
|
+
inject Mailers::UserMailer, as: :mailer
|
198
|
+
|
199
|
+
def create_user(params)
|
200
|
+
user = @user_repository.create(params)
|
201
|
+
@mailer.send_welcome_email(user)
|
202
|
+
user
|
203
|
+
end
|
204
|
+
end
|
205
|
+
```
|
@@ -0,0 +1,398 @@
|
|
1
|
+
# Mortymer Dependency Injection Guide
|
2
|
+
|
3
|
+
## Overview
|
4
|
+
|
5
|
+
Mortymer provides a powerful yet simple dependency injection system that favors explicit constant references and instance variables over dynamic method creation. This guide will walk you through the various ways to use dependency injection with Mortymer.
|
6
|
+
|
7
|
+
## Basic Usage
|
8
|
+
|
9
|
+
### Simple Injection
|
10
|
+
|
11
|
+
The most basic form of dependency injection in Mortymer uses the `inject` directive:
|
12
|
+
|
13
|
+
```ruby
|
14
|
+
class UserService
|
15
|
+
include Mortymer::DependenciesDsl
|
16
|
+
|
17
|
+
inject UserRepository
|
18
|
+
|
19
|
+
def find_user(id)
|
20
|
+
@user_repository.find(id)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
```
|
24
|
+
|
25
|
+
When you include `Mortymer::DependenciesDsl`, you get access to the `inject` directive. This will:
|
26
|
+
|
27
|
+
1. Create an instance variable `@user_repository`
|
28
|
+
2. Automatically initialize it with an instance of `UserRepository`
|
29
|
+
|
30
|
+
### Custom Variable Names
|
31
|
+
|
32
|
+
You can customize the instance variable name using the `as` option:
|
33
|
+
|
34
|
+
```ruby
|
35
|
+
class UserService
|
36
|
+
include Mortymer::DependenciesDsl
|
37
|
+
|
38
|
+
inject UserRepository, as: :users
|
39
|
+
|
40
|
+
def find_user(id)
|
41
|
+
@users.find(id)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
```
|
45
|
+
|
46
|
+
### Multiple Dependencies
|
47
|
+
|
48
|
+
You can inject multiple dependencies in a single class:
|
49
|
+
|
50
|
+
```ruby
|
51
|
+
class UserService
|
52
|
+
include Mortymer::DependenciesDsl
|
53
|
+
|
54
|
+
inject UserRepository
|
55
|
+
inject UserMailer
|
56
|
+
inject Logger, as: :logger
|
57
|
+
|
58
|
+
def create_user(params)
|
59
|
+
@logger.info("Creating new user")
|
60
|
+
user = @user_repository.create(params)
|
61
|
+
@user_mailer.send_welcome_email(user)
|
62
|
+
user
|
63
|
+
end
|
64
|
+
end
|
65
|
+
```
|
66
|
+
|
67
|
+
## Advanced Usage
|
68
|
+
|
69
|
+
### Dependency Scopes
|
70
|
+
|
71
|
+
Mortymer supports different scopes for dependencies:
|
72
|
+
|
73
|
+
#### Singleton Scope (Default)
|
74
|
+
|
75
|
+
By default, dependencies are singleton-scoped, meaning the same instance is shared across the application:
|
76
|
+
|
77
|
+
```ruby
|
78
|
+
class UserService
|
79
|
+
include Mortymer::DependenciesDsl
|
80
|
+
|
81
|
+
inject UserRepository # Singleton by default
|
82
|
+
end
|
83
|
+
```
|
84
|
+
|
85
|
+
#### Request Scope
|
86
|
+
|
87
|
+
For dependencies that should be unique per request:
|
88
|
+
|
89
|
+
```ruby
|
90
|
+
class UserService
|
91
|
+
include Mortymer::DependenciesDsl
|
92
|
+
|
93
|
+
inject UserRepository, scope: :request
|
94
|
+
end
|
95
|
+
```
|
96
|
+
|
97
|
+
#### Transient Scope
|
98
|
+
|
99
|
+
For dependencies that should be newly instantiated every time:
|
100
|
+
|
101
|
+
```ruby
|
102
|
+
class UserService
|
103
|
+
include Mortymer::DependenciesDsl
|
104
|
+
|
105
|
+
inject UserRepository, scope: :transient
|
106
|
+
end
|
107
|
+
```
|
108
|
+
|
109
|
+
### Factory Registration
|
110
|
+
|
111
|
+
You can register custom factory methods for your dependencies:
|
112
|
+
|
113
|
+
```ruby
|
114
|
+
Mortymer.configure do |config|
|
115
|
+
config.register_factory(UserRepository) do
|
116
|
+
UserRepository.new(connection: DatabaseConnection.current)
|
117
|
+
end
|
118
|
+
end
|
119
|
+
```
|
120
|
+
|
121
|
+
### Interface-Based Dependencies
|
122
|
+
|
123
|
+
Mortymer supports interface-based dependency injection:
|
124
|
+
|
125
|
+
```ruby
|
126
|
+
module UserRepositoryInterface
|
127
|
+
def find(id)
|
128
|
+
raise NotImplementedError
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
class PostgresUserRepository
|
133
|
+
include UserRepositoryInterface
|
134
|
+
# implementation
|
135
|
+
end
|
136
|
+
|
137
|
+
class MongoUserRepository
|
138
|
+
include UserRepositoryInterface
|
139
|
+
# implementation
|
140
|
+
end
|
141
|
+
|
142
|
+
# Register implementation
|
143
|
+
Mortymer.configure do |config|
|
144
|
+
config.register_implementation(UserRepositoryInterface, PostgresUserRepository)
|
145
|
+
end
|
146
|
+
|
147
|
+
class UserService
|
148
|
+
include Mortymer::DependenciesDsl
|
149
|
+
|
150
|
+
inject UserRepositoryInterface
|
151
|
+
end
|
152
|
+
```
|
153
|
+
|
154
|
+
## Testing
|
155
|
+
|
156
|
+
### Mocking Dependencies
|
157
|
+
|
158
|
+
Mortymer makes it easy to mock dependencies in tests:
|
159
|
+
|
160
|
+
```ruby
|
161
|
+
RSpec.describe UserService do
|
162
|
+
let(:repository_mock) { instance_double(UserRepository) }
|
163
|
+
let(:mailer_mock) { instance_double(UserMailer) }
|
164
|
+
|
165
|
+
before do
|
166
|
+
Mortymer.stub_dependency(UserRepository, repository_mock)
|
167
|
+
Mortymer.stub_dependency(UserMailer, mailer_mock)
|
168
|
+
end
|
169
|
+
|
170
|
+
it "creates a user" do
|
171
|
+
service = UserService.new
|
172
|
+
expect(repository_mock).to receive(:create)
|
173
|
+
expect(mailer_mock).to receive(:send_welcome_email)
|
174
|
+
|
175
|
+
service.create_user(name: "John")
|
176
|
+
end
|
177
|
+
end
|
178
|
+
```
|
179
|
+
|
180
|
+
### Test-Specific Implementations
|
181
|
+
|
182
|
+
You can also register test-specific implementations:
|
183
|
+
|
184
|
+
```ruby
|
185
|
+
class TestUserRepository
|
186
|
+
include UserRepositoryInterface
|
187
|
+
|
188
|
+
def initialize
|
189
|
+
@users = {}
|
190
|
+
end
|
191
|
+
|
192
|
+
def find(id)
|
193
|
+
@users[id]
|
194
|
+
end
|
195
|
+
end
|
196
|
+
|
197
|
+
RSpec.describe UserService do
|
198
|
+
before do
|
199
|
+
Mortymer.register_implementation(UserRepositoryInterface, TestUserRepository)
|
200
|
+
end
|
201
|
+
end
|
202
|
+
```
|
203
|
+
|
204
|
+
## Best Practices
|
205
|
+
|
206
|
+
### 1. Keep Dependencies Explicit
|
207
|
+
|
208
|
+
Always use explicit constant references rather than strings for dependencies:
|
209
|
+
|
210
|
+
```ruby
|
211
|
+
# Good
|
212
|
+
inject UserRepository
|
213
|
+
|
214
|
+
# Avoid
|
215
|
+
inject "user_repository"
|
216
|
+
```
|
217
|
+
|
218
|
+
### 2. Use Meaningful Names
|
219
|
+
|
220
|
+
Choose descriptive names for your dependencies:
|
221
|
+
|
222
|
+
```ruby
|
223
|
+
# Good
|
224
|
+
inject UserRepository, as: :active_users_repository
|
225
|
+
|
226
|
+
# Less Clear
|
227
|
+
inject UserRepository, as: :repo
|
228
|
+
```
|
229
|
+
|
230
|
+
### 3. Group Related Dependencies
|
231
|
+
|
232
|
+
Keep related dependencies together and organize them logically:
|
233
|
+
|
234
|
+
```ruby
|
235
|
+
class UserService
|
236
|
+
include Mortymer::DependenciesDsl
|
237
|
+
|
238
|
+
# Authentication dependencies
|
239
|
+
inject AuthenticationService
|
240
|
+
inject TokenGenerator
|
241
|
+
|
242
|
+
# User management dependencies
|
243
|
+
inject UserRepository
|
244
|
+
inject UserMailer
|
245
|
+
|
246
|
+
# Logging/Monitoring
|
247
|
+
inject Logger
|
248
|
+
inject MetricsCollector
|
249
|
+
end
|
250
|
+
```
|
251
|
+
|
252
|
+
### 4. Interface Segregation
|
253
|
+
|
254
|
+
Create focused interfaces for your dependencies:
|
255
|
+
|
256
|
+
```ruby
|
257
|
+
module UserReader
|
258
|
+
def find(id); end
|
259
|
+
def list; end
|
260
|
+
end
|
261
|
+
|
262
|
+
module UserWriter
|
263
|
+
def create(attributes); end
|
264
|
+
def update(id, attributes); end
|
265
|
+
end
|
266
|
+
|
267
|
+
class UserRepository
|
268
|
+
include UserReader
|
269
|
+
include UserWriter
|
270
|
+
end
|
271
|
+
|
272
|
+
class UserService
|
273
|
+
include Mortymer::DependenciesDsl
|
274
|
+
|
275
|
+
inject UserReader # Only inject what you need
|
276
|
+
end
|
277
|
+
```
|
278
|
+
|
279
|
+
## Common Patterns
|
280
|
+
|
281
|
+
### Service Objects
|
282
|
+
|
283
|
+
```ruby
|
284
|
+
class CreateUser
|
285
|
+
include Mortymer::DependenciesDsl
|
286
|
+
|
287
|
+
inject UserRepository
|
288
|
+
inject UserMailer
|
289
|
+
inject UserValidator
|
290
|
+
|
291
|
+
def call(params)
|
292
|
+
@user_validator.validate!(params)
|
293
|
+
user = @user_repository.create(params)
|
294
|
+
@user_mailer.send_welcome_email(user)
|
295
|
+
user
|
296
|
+
end
|
297
|
+
end
|
298
|
+
```
|
299
|
+
|
300
|
+
### Decorators
|
301
|
+
|
302
|
+
```ruby
|
303
|
+
class LoggedUserRepository
|
304
|
+
include Mortymer::DependenciesDsl
|
305
|
+
|
306
|
+
inject UserRepository
|
307
|
+
inject Logger
|
308
|
+
|
309
|
+
def find(id)
|
310
|
+
@logger.info("Finding user: #{id}")
|
311
|
+
result = @user_repository.find(id)
|
312
|
+
@logger.info("Found user: #{result&.id}")
|
313
|
+
result
|
314
|
+
end
|
315
|
+
end
|
316
|
+
```
|
317
|
+
|
318
|
+
## Configuration
|
319
|
+
|
320
|
+
### Global Configuration
|
321
|
+
|
322
|
+
```ruby
|
323
|
+
Mortymer.configure do |config|
|
324
|
+
# Register default implementations
|
325
|
+
config.register_implementation(UserRepositoryInterface, PostgresUserRepository)
|
326
|
+
|
327
|
+
# Register factories
|
328
|
+
config.register_factory(Logger) do
|
329
|
+
Logger.new($stdout).tap do |logger|
|
330
|
+
logger.level = Rails.env.production? ? :info : :debug
|
331
|
+
end
|
332
|
+
end
|
333
|
+
|
334
|
+
# Configure scopes
|
335
|
+
config.set_scope(UserSession, :request)
|
336
|
+
end
|
337
|
+
```
|
338
|
+
|
339
|
+
### Environment-Specific Configuration
|
340
|
+
|
341
|
+
```ruby
|
342
|
+
# config/initializers/mortymer.rb
|
343
|
+
Mortymer.configure do |config|
|
344
|
+
if Rails.env.test?
|
345
|
+
config.register_implementation(UserRepositoryInterface, TestUserRepository)
|
346
|
+
elsif Rails.env.development?
|
347
|
+
config.register_implementation(UserRepositoryInterface, DevUserRepository)
|
348
|
+
else
|
349
|
+
config.register_implementation(UserRepositoryInterface, ProductionUserRepository)
|
350
|
+
end
|
351
|
+
end
|
352
|
+
```
|
353
|
+
|
354
|
+
## Troubleshooting
|
355
|
+
|
356
|
+
### Common Issues
|
357
|
+
|
358
|
+
1. **Circular Dependencies**
|
359
|
+
|
360
|
+
```ruby
|
361
|
+
# This will raise a CircularDependencyError
|
362
|
+
class UserService
|
363
|
+
include Mortymer::DependenciesDsl
|
364
|
+
inject AccountService
|
365
|
+
end
|
366
|
+
|
367
|
+
class AccountService
|
368
|
+
include Mortymer::DependenciesDsl
|
369
|
+
inject UserService # Circular dependency!
|
370
|
+
end
|
371
|
+
```
|
372
|
+
|
373
|
+
Solution: Refactor to remove the circular dependency or use method injection.
|
374
|
+
|
375
|
+
2. **Missing Dependencies**
|
376
|
+
|
377
|
+
```ruby
|
378
|
+
# This will raise a DependencyNotFoundError
|
379
|
+
class UserService
|
380
|
+
include Mortymer::DependenciesDsl
|
381
|
+
inject NonExistentService
|
382
|
+
end
|
383
|
+
```
|
384
|
+
|
385
|
+
Solution: Ensure all dependencies are properly registered or the constants exist.
|
386
|
+
|
387
|
+
3. **Scope Conflicts**
|
388
|
+
|
389
|
+
```ruby
|
390
|
+
# This might cause issues
|
391
|
+
class UserService
|
392
|
+
include Mortymer::DependenciesDsl
|
393
|
+
inject SessionManager, scope: :request # Request-scoped
|
394
|
+
inject UserRepository # Singleton-scoped
|
395
|
+
end
|
396
|
+
```
|
397
|
+
|
398
|
+
Solution: Be careful mixing different scopes and ensure it makes sense for your use case.
|
data/lib/mortymer/container.rb
CHANGED
@@ -108,9 +108,7 @@ module Mortymer
|
|
108
108
|
def resolve_implementation(implementation, key, resolution_stack)
|
109
109
|
case implementation
|
110
110
|
when Proc
|
111
|
-
|
112
|
-
registry[key] = result
|
113
|
-
result
|
111
|
+
instance_exec(&implementation)
|
114
112
|
when Class
|
115
113
|
resolve_class(implementation, key, resolution_stack)
|
116
114
|
else
|
@@ -119,14 +117,12 @@ module Mortymer
|
|
119
117
|
end
|
120
118
|
|
121
119
|
# Resolve a class implementation with its dependencies
|
122
|
-
def resolve_class(klass,
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
registry[key] = instance
|
129
|
-
instance
|
120
|
+
def resolve_class(klass, _key, resolution_stack)
|
121
|
+
if klass.respond_to?(:dependencies)
|
122
|
+
inject_dependencies(klass, resolution_stack)
|
123
|
+
else
|
124
|
+
klass.new
|
125
|
+
end
|
130
126
|
end
|
131
127
|
|
132
128
|
# Inject dependencies into a new instance
|
data/lib/mortymer/contract.rb
CHANGED
@@ -4,11 +4,14 @@ require_relative "moldeable"
|
|
4
4
|
require "dry/validation"
|
5
5
|
require "dry/validation/contract"
|
6
6
|
require_relative "generator"
|
7
|
+
require_relative "types"
|
8
|
+
require_relative "struct_compiler"
|
7
9
|
|
8
10
|
module Mortymer
|
9
11
|
# A base model for defining schemas
|
10
12
|
class Contract < Dry::Validation::Contract
|
11
13
|
include Mortymer::Moldeable
|
14
|
+
include Mortymer::Types
|
12
15
|
|
13
16
|
# Exception raised when an error occours in a contract
|
14
17
|
class ContractError < StandardError
|
@@ -20,15 +23,28 @@ module Mortymer
|
|
20
23
|
end
|
21
24
|
end
|
22
25
|
|
26
|
+
def self.__internal_struct_repr__
|
27
|
+
@__internal_struct_repr__ || StructCompiler.new.compile(schema.json_schema)
|
28
|
+
end
|
29
|
+
|
23
30
|
def self.json_schema
|
24
31
|
Generator.new.from_validation(self)
|
25
32
|
end
|
26
33
|
|
27
34
|
def self.structify(params)
|
28
35
|
result = new.call(params)
|
29
|
-
raise ContractError
|
36
|
+
raise ContractError, result.errors.to_h unless result.errors.empty?
|
37
|
+
|
38
|
+
__internal_struct_repr__.new(**result.to_h)
|
39
|
+
end
|
30
40
|
|
31
|
-
|
41
|
+
def self.compile!
|
42
|
+
# Force eager compilation of the internal struct representation.
|
43
|
+
# This provides an optimization by precompiling the struct when
|
44
|
+
# the class is defined rather than waiting for the first use.
|
45
|
+
# The compilation result is memoized, so subsequent accesses
|
46
|
+
# will reuse the compiled struct.
|
47
|
+
__internal_struct_repr__
|
32
48
|
end
|
33
49
|
end
|
34
50
|
end
|
data/lib/mortymer/model.rb
CHANGED
@@ -9,14 +9,14 @@ module Mortymer
|
|
9
9
|
# A base model for defining schemas
|
10
10
|
class Model < Dry::Struct
|
11
11
|
include Mortymer::Moldeable
|
12
|
-
include
|
12
|
+
include Mortymer::Types
|
13
13
|
|
14
14
|
def self.json_schema
|
15
15
|
Generator.new.from_struct(self)
|
16
16
|
end
|
17
17
|
|
18
18
|
def self.structify(params)
|
19
|
-
|
19
|
+
call(params)
|
20
20
|
end
|
21
21
|
end
|
22
22
|
end
|
@@ -0,0 +1,92 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Mortymer
|
4
|
+
# Sigil provides symbolic type checking for input and outputs
|
5
|
+
# of method calls using dry-types
|
6
|
+
module Sigil
|
7
|
+
class TypeError < StandardError; end
|
8
|
+
|
9
|
+
# Class methods to be included as part of the dsl
|
10
|
+
module ClassMethods
|
11
|
+
# Store type signatures for methods before they are defined
|
12
|
+
def sign(*positional_types, returns: nil, **keyword_types)
|
13
|
+
@pending_type_signature = {
|
14
|
+
positional_types: positional_types,
|
15
|
+
keyword_types: keyword_types,
|
16
|
+
returns: returns
|
17
|
+
}
|
18
|
+
end
|
19
|
+
|
20
|
+
# Hook called when a method is defined
|
21
|
+
def method_added(method_name)
|
22
|
+
return super if @pending_type_signature.nil?
|
23
|
+
return super if @processing_type_check
|
24
|
+
|
25
|
+
signature = @pending_type_signature
|
26
|
+
@pending_type_signature = nil
|
27
|
+
|
28
|
+
# Get the original method
|
29
|
+
original_method = instance_method(method_name)
|
30
|
+
|
31
|
+
@processing_type_check = true
|
32
|
+
|
33
|
+
# Redefine the method with type checking
|
34
|
+
define_method(method_name) do |*args, **kwargs|
|
35
|
+
# Validate positional arguments
|
36
|
+
procced_args = []
|
37
|
+
procced_kwargs = {}
|
38
|
+
args.each_with_index do |arg, idx|
|
39
|
+
unless (type = signature[:positional_types][idx])
|
40
|
+
procced_args << arg
|
41
|
+
next
|
42
|
+
end
|
43
|
+
|
44
|
+
begin
|
45
|
+
procced_args << (type.respond_to?(:structify) ? type.structify(arg) : type.call(arg))
|
46
|
+
rescue Dry::Types::CoercionError => e
|
47
|
+
raise TypeError, "Invalid type for argument #{idx}: expected #{type}, got #{arg.class} - #{e.message}"
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
# Validate keyword arguments
|
52
|
+
kwargs.each do |key, value|
|
53
|
+
unless (type = signature[:keyword_types][key])
|
54
|
+
procced_kwargs[key] = value
|
55
|
+
next
|
56
|
+
end
|
57
|
+
|
58
|
+
begin
|
59
|
+
procced_kwargs[key] = (type.respond_to?(:structify) ? type.structify(value) : type.call(value))
|
60
|
+
rescue Dry::Types::CoercionError => e
|
61
|
+
raise TypeError,
|
62
|
+
"Invalid type for keyword argument #{key}: expected #{type}, got #{value.class} - #{e.message}"
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
# Call the original method
|
67
|
+
result = original_method.bind(self).call(*procced_args, **procced_kwargs)
|
68
|
+
|
69
|
+
# Validate return type if specified
|
70
|
+
if (return_type = signature[:returns])
|
71
|
+
begin
|
72
|
+
return return_type.respond_to?(:structify) ? return_type.structify(result) : return_type.call(result)
|
73
|
+
rescue Dry::Types::CoercionError => e
|
74
|
+
raise TypeError, "Invalid return type: expected #{return_type}, got #{result.class} - #{e.message}"
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
result
|
79
|
+
end
|
80
|
+
|
81
|
+
@processing_type_check = false
|
82
|
+
|
83
|
+
# Call super to maintain compatibility with other method hooks
|
84
|
+
super
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
def self.included(base)
|
89
|
+
base.extend(ClassMethods)
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
@@ -0,0 +1,110 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "dry-struct"
|
4
|
+
require "securerandom"
|
5
|
+
|
6
|
+
module Mortymer
|
7
|
+
class StructCompiler
|
8
|
+
PRIMITIVE_TYPE_MAP = {
|
9
|
+
"string" => Mortymer::Model::String,
|
10
|
+
"integer" => Mortymer::Model::Integer,
|
11
|
+
"number" => Mortymer::Model::Float,
|
12
|
+
"boolean" => Mortymer::Model::Bool,
|
13
|
+
"null" => Mortymer::Model::Nil,
|
14
|
+
string: Mortymer::Model::String,
|
15
|
+
integer: Mortymer::Model::Integer,
|
16
|
+
number: Mortymer::Model::Float,
|
17
|
+
boolean: Mortymer::Model::Bool,
|
18
|
+
null: Mortymer::Model::Nil
|
19
|
+
}.freeze
|
20
|
+
|
21
|
+
def initialize(class_name = "GeneratedStruct#{SecureRandom.hex(4)}")
|
22
|
+
@class_name = class_name
|
23
|
+
@types = {}
|
24
|
+
end
|
25
|
+
|
26
|
+
def compile(schema)
|
27
|
+
build_type(schema, @class_name)
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
def build_type(schema, type_name)
|
33
|
+
schema = normalize_schema(schema)
|
34
|
+
case schema["type"]
|
35
|
+
when "object"
|
36
|
+
build_object_type(schema, type_name)
|
37
|
+
when "array"
|
38
|
+
build_array_type(schema)
|
39
|
+
else
|
40
|
+
build_primitive_type(schema)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
def normalize_schema(schema)
|
45
|
+
return {} if schema.nil?
|
46
|
+
|
47
|
+
schema = schema.transform_keys(&:to_s)
|
48
|
+
if schema["properties"]
|
49
|
+
schema["properties"] = schema["properties"].transform_keys(&:to_s)
|
50
|
+
schema["properties"].each_value do |prop_schema|
|
51
|
+
normalize_schema(prop_schema)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
schema["items"] = normalize_schema(schema["items"]) if schema["items"]
|
55
|
+
schema["required"] = schema["required"].map(&:to_s) if schema["required"]
|
56
|
+
if schema["enum"]
|
57
|
+
schema["enum"] = schema["enum"].map { |v| v.is_a?(Symbol) ? v.to_s : v }
|
58
|
+
end
|
59
|
+
schema
|
60
|
+
end
|
61
|
+
|
62
|
+
def build_object_type(schema, type_name)
|
63
|
+
return {} unless schema["properties"]
|
64
|
+
|
65
|
+
# Build attribute definitions
|
66
|
+
attributes = schema["properties"].map do |name, property_schema|
|
67
|
+
name = name.to_s # Ensure name is a string
|
68
|
+
nested_type_name = camelize("#{type_name}#{camelize(name)}")
|
69
|
+
type = if property_schema["type"] == "object"
|
70
|
+
build_type(property_schema, nested_type_name)
|
71
|
+
else
|
72
|
+
build_type(property_schema, nil)
|
73
|
+
end
|
74
|
+
|
75
|
+
required = schema["required"]&.include?(name)
|
76
|
+
[name, required ? type : type.optional, required]
|
77
|
+
end
|
78
|
+
|
79
|
+
# Create a new Struct class for this object
|
80
|
+
Class.new(Mortymer::Model) do
|
81
|
+
attributes.each do |name, type, required|
|
82
|
+
if required
|
83
|
+
attribute name.to_sym, type
|
84
|
+
else
|
85
|
+
attribute? name.to_sym, type
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
def build_array_type(schema)
|
92
|
+
item_type = build_type(schema["items"], nil)
|
93
|
+
Mortymer::Model::Array.of(item_type)
|
94
|
+
end
|
95
|
+
|
96
|
+
def build_primitive_type(schema)
|
97
|
+
type_class = PRIMITIVE_TYPE_MAP[schema["type"]] || Mortymer::Model::Any
|
98
|
+
|
99
|
+
if schema["enum"]
|
100
|
+
type_class.enum(*schema["enum"])
|
101
|
+
else
|
102
|
+
type_class
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
def camelize(string)
|
107
|
+
string.split(/[^a-zA-Z0-9]/).map(&:capitalize).join
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
data/lib/mortymer/version.rb
CHANGED
data/lib/mortymer.rb
CHANGED
@@ -2,6 +2,8 @@
|
|
2
2
|
# typed: true
|
3
3
|
|
4
4
|
require "dry/struct"
|
5
|
+
require "dry/schema"
|
6
|
+
Dry::Schema.load_extensions(:json_schema)
|
5
7
|
require "mortymer/types"
|
6
8
|
require "mortymer/uploaded_file"
|
7
9
|
require "mortymer/uploaded_files"
|
@@ -18,5 +20,7 @@ require "mortymer/openapi_generator"
|
|
18
20
|
require "mortymer/container"
|
19
21
|
require "mortymer/dependencies_dsl"
|
20
22
|
require "mortymer/security_schemes"
|
23
|
+
require "mortymer/struct_compiler"
|
24
|
+
require "mortymer/sigil"
|
21
25
|
require "mortymer/rails" if defined?(Rails)
|
22
26
|
require "mortymer/railtie" if defined?(Rails::Railtie)
|
metadata
CHANGED
@@ -1,13 +1,13 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: mortymer
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.0.
|
4
|
+
version: 0.0.11
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Adrian Gonzalez
|
8
8
|
bindir: exe
|
9
9
|
cert_chain: []
|
10
|
-
date: 2025-03-
|
10
|
+
date: 2025-03-31 00:00:00.000000000 Z
|
11
11
|
dependencies:
|
12
12
|
- !ruby/object:Gem::Dependency
|
13
13
|
name: dry-struct
|
@@ -90,8 +90,10 @@ files:
|
|
90
90
|
- docs/advanced/openapi.md
|
91
91
|
- docs/assets/swagg.png
|
92
92
|
- docs/guide/api-metadata.md
|
93
|
+
- docs/guide/dependency-injection.md
|
93
94
|
- docs/guide/introduction.md
|
94
95
|
- docs/guide/models.md
|
96
|
+
- docs/guide/mortymer-dependency-injection.md
|
95
97
|
- docs/guide/quick-start.md
|
96
98
|
- docs/index.md
|
97
99
|
- lib/mortymer.rb
|
@@ -113,6 +115,8 @@ files:
|
|
113
115
|
- lib/mortymer/rails/routes.rb
|
114
116
|
- lib/mortymer/railtie.rb
|
115
117
|
- lib/mortymer/security_schemes.rb
|
118
|
+
- lib/mortymer/sigil.rb
|
119
|
+
- lib/mortymer/struct_compiler.rb
|
116
120
|
- lib/mortymer/types.rb
|
117
121
|
- lib/mortymer/uploaded_file.rb
|
118
122
|
- lib/mortymer/uploaded_files.rb
|