ambassadors 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +12 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +237 -0
- data/Rakefile +25 -0
- data/lib/ambassadors/ambassador.rb +108 -0
- data/lib/ambassadors/ambassador_collection.rb +114 -0
- data/lib/ambassadors/ambassador_factory.rb +40 -0
- data/lib/ambassadors/errors.rb +24 -0
- data/lib/ambassadors/freezer.rb +20 -0
- data/lib/ambassadors/iterators/each_iterator.rb +11 -0
- data/lib/ambassadors/iterators/find_each_iterator.rb +23 -0
- data/lib/ambassadors/iterators/iterator.rb +24 -0
- data/lib/ambassadors/iterators/iterator_resolver.rb +25 -0
- data/lib/ambassadors/iterators.rb +19 -0
- data/lib/ambassadors/property_name_checker.rb +34 -0
- data/lib/ambassadors/railtie.rb +21 -0
- data/lib/ambassadors/version.rb +5 -0
- data/lib/ambassadors.rb +14 -0
- metadata +65 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 9b0c2c3ab1c86761e0943b3f7d04f22064c61fab01d79d09f46de1949989205d
|
|
4
|
+
data.tar.gz: fd23cf3f92d981992d09b8d68c66edd9dfd3a3e9f68dd10b4155ba6eef4083c9
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 94dfadc228c1e01b94654564ba821fd7026e2eca4f906b6e9101985b35cb81fd4b2caaa0d6e5ef45fc953711b6e396251ee630a1c865647a8ba7a7e427bfb095
|
|
7
|
+
data.tar.gz: 7d0fe8f56f03ff0b86ae654df50f4ec4a6b87f6e34b0bacfdda7c88421244a794923585bd6e0b4fcb3d6c69393fd029cb7f7d35686854886ecc996692683048b
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
## [Unreleased]
|
|
2
|
+
|
|
3
|
+
## [0.1.0] - 2026-09-23
|
|
4
|
+
|
|
5
|
+
Initial public release.
|
|
6
|
+
|
|
7
|
+
- `Ambassador` base class with the `expose` DSL for read-only, frozen properties.
|
|
8
|
+
- `AmbassadorCollection` for enumerating entities as ambassadors, with batched iteration for `ActiveRecord::Relation`.
|
|
9
|
+
- `ambassador_options:` for passing keyword arguments through to each ambassador's constructor.
|
|
10
|
+
- `cursor:` and `order:` options to control batched iteration.
|
|
11
|
+
- `Ambassador#to_ambassador` for polymorphic casting.
|
|
12
|
+
- Optional Rails integration that includes `ActiveModel::Serialization` in `Ambassador` when available.
|
data/CODE_OF_CONDUCT.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Code of Conduct
|
|
2
|
+
|
|
3
|
+
"ambassadors" follows [The Ruby Community Conduct Guideline](https://www.ruby-lang.org/en/conduct) in all "collaborative space", which is defined as community communications channels (such as mailing lists, submitted patches, commit comments, etc.):
|
|
4
|
+
|
|
5
|
+
* Participants will be tolerant of opposing views.
|
|
6
|
+
* Participants must ensure that their language and actions are free of personal attacks and disparaging personal remarks.
|
|
7
|
+
* When interpreting the words and actions of others, participants should always assume good intentions.
|
|
8
|
+
* Behaviour which can be reasonably considered harassment will not be tolerated.
|
|
9
|
+
|
|
10
|
+
If you have any concerns about behaviour within this project, please contact us at ["gavin@gavinmorrice.com"](mailto:"gavin@gavinmorrice.com").
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 CleoAI Ltd.
|
|
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,237 @@
|
|
|
1
|
+
[](https://github.com/meetcleo/ambassadors/actions/workflows/main.yml)
|
|
2
|
+
|
|
3
|
+
# Ambassadors
|
|
4
|
+
|
|
5
|
+
Immutable, read-only wrappers for your domain entities, designed for modular monoliths with explicit domain interfaces.
|
|
6
|
+
|
|
7
|
+
The core of this gem is the `Ambassador` class, with `AmbassadorCollection` as a supporting abstraction for iterating safely over many ambassadors.
|
|
8
|
+
|
|
9
|
+
- `Ambassador` - a frozen, read-only, stable façade over a domain entity.
|
|
10
|
+
- `AmbassadorCollection` - an `Enumerable` that always yields ambassador instances, with optional batching for heavy data sources (such as large ActiveRecord collections).
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Motivation
|
|
15
|
+
|
|
16
|
+
In a modular monolith, domain boundaries should be explicit.
|
|
17
|
+
|
|
18
|
+
Passing raw ORM models or deeply coupled objects across those boundaries makes refactors risky and leaks internal concerns everywhere.
|
|
19
|
+
|
|
20
|
+
**Ambassadors** are:
|
|
21
|
+
|
|
22
|
+
- Read-only views over your domain entities
|
|
23
|
+
- Explicit about what they expose
|
|
24
|
+
- Immutable once constructed
|
|
25
|
+
|
|
26
|
+
They are a safe value to return from **domain interface methods** to the outside world (other domains, adapters, controllers, etc.).
|
|
27
|
+
|
|
28
|
+
`AmbassadorCollection` then gives you a consistent way to work with many such ambassadors, regardless of the underlying source (e.g. `Array`, `ActiveRecord::Relation`, remote API results).
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
33
|
+
|
|
34
|
+
Add to your Gemfile:
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
gem "ambassadors"
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Then:
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
bundle install
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The Ruby namespace is `Ambassadors`.
|
|
47
|
+
For ergonomics, `Ambassador` and `AmbassadorCollection` are also defined as top-level constants.
|
|
48
|
+
|
|
49
|
+
## Defining an Ambassador
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
class UserAmbassador < Ambassador
|
|
53
|
+
expose :id, :email, :created_at
|
|
54
|
+
end
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
user = User.find(...)
|
|
59
|
+
ambassador = UserAmbassador.new(user)
|
|
60
|
+
ambassador.id # => 123
|
|
61
|
+
ambassador.email # => "user@example.com"
|
|
62
|
+
ambassador.created_at # => 2025-01-01 12:34:56 UTC
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Key properties
|
|
66
|
+
|
|
67
|
+
- The ambassador instance is frozen in the constructor.
|
|
68
|
+
- Only methods declared with `expose` are generated and forwarded to the entity.
|
|
69
|
+
- Values returned from exposed properties are frozen.
|
|
70
|
+
- Property names ending in `!` or `=` cannot be exposed, and raise `Ambassadors::InvalidPropertyError`.
|
|
71
|
+
|
|
72
|
+
### `expose` DSL
|
|
73
|
+
|
|
74
|
+
```ruby
|
|
75
|
+
class CardAmbassador < Ambassador
|
|
76
|
+
expose :id, :last4, :status
|
|
77
|
+
end
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Custom methods
|
|
81
|
+
|
|
82
|
+
Ambassador subclasses are ordinary Ruby classes, so you can define your own methods alongside exposed properties.
|
|
83
|
+
The wrapped entity is available via `entity`.
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
class UserAmbassador < Ambassador
|
|
87
|
+
expose :id, :first_name, :last_name
|
|
88
|
+
|
|
89
|
+
def full_name
|
|
90
|
+
"#{entity.first_name} #{entity.last_name}"
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### `#to_ambassador`
|
|
96
|
+
|
|
97
|
+
Every `Ambassador` responds to `#to_ambassador` and returns itself.
|
|
98
|
+
Entities may also implement `#to_ambassador` to control how they are cast (see [Ambassador casting](#ambassador-casting)).
|
|
99
|
+
|
|
100
|
+
## AmbassadorCollection
|
|
101
|
+
|
|
102
|
+
`AmbassadorCollection` is a thin wrapper that:
|
|
103
|
+
|
|
104
|
+
- Includes `Enumerable`
|
|
105
|
+
- Iterates using a strategy (plain `each`, `find_each`, etc.)
|
|
106
|
+
- Always yields ambassador instances
|
|
107
|
+
|
|
108
|
+
### Basic usage
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
users = [User.new(name: "user A"), User.new(name: "user B")] # users is an Array of User objects
|
|
112
|
+
collection = AmbassadorCollection.new(users, ambassador_class: UserAmbassador)
|
|
113
|
+
|
|
114
|
+
collection.each do |ambassador|
|
|
115
|
+
puts ambassador.email
|
|
116
|
+
end
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### With ActiveRecord
|
|
120
|
+
|
|
121
|
+
Will safely iterate over `ActiveRecord::Relation` objects, without exposing dangerous methods (e.g. `delete_all`) or loading too many results at once.
|
|
122
|
+
|
|
123
|
+
```ruby
|
|
124
|
+
users = User.where(active: true) # users is an ActiveRecord::Relation
|
|
125
|
+
collection = AmbassadorCollection.new(users, ambassador_class: UserAmbassador)
|
|
126
|
+
|
|
127
|
+
collection.each do |ambassador| # loads in batches of 1000 by default
|
|
128
|
+
puts ambassador.email
|
|
129
|
+
end
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Specify an optional `batch_size:`:
|
|
133
|
+
|
|
134
|
+
```ruby
|
|
135
|
+
users = User.where(active: true)
|
|
136
|
+
collection = AmbassadorCollection.new(users, ambassador_class: UserAmbassador, batch_size: 50)
|
|
137
|
+
|
|
138
|
+
collection.each do |ambassador| # loads in batches of 50
|
|
139
|
+
puts ambassador.email
|
|
140
|
+
end
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Batches are loaded with `find_each`, ordered by the primary key ascending by default.
|
|
144
|
+
Pass `cursor:` and `order:` to batch on a different column or direction.
|
|
145
|
+
See the [ActiveRecord::Batches documentation](https://api.rubyonrails.org/classes/ActiveRecord/Batches.html#method-i-find_each) for the constraints on cursor columns.
|
|
146
|
+
|
|
147
|
+
```ruby
|
|
148
|
+
users = User.where(active: true)
|
|
149
|
+
collection = AmbassadorCollection.new(
|
|
150
|
+
users,
|
|
151
|
+
ambassador_class: UserAmbassador,
|
|
152
|
+
cursor: :created_at,
|
|
153
|
+
order: :desc
|
|
154
|
+
)
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### Passing options to ambassadors
|
|
158
|
+
|
|
159
|
+
If your ambassador's constructor accepts keyword arguments, pass them to every ambassador in the collection with `ambassador_options:`.
|
|
160
|
+
|
|
161
|
+
```ruby
|
|
162
|
+
class UserAmbassador < Ambassador
|
|
163
|
+
expose :id, :email
|
|
164
|
+
|
|
165
|
+
def initialize(entity, include_profile: false)
|
|
166
|
+
@include_profile = include_profile
|
|
167
|
+
super(entity)
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
collection = AmbassadorCollection.new(
|
|
172
|
+
users,
|
|
173
|
+
ambassador_class: UserAmbassador,
|
|
174
|
+
ambassador_options: { include_profile: true }
|
|
175
|
+
)
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
### Collection helpers
|
|
179
|
+
|
|
180
|
+
Alongside the `Enumerable` methods, `AmbassadorCollection` provides:
|
|
181
|
+
|
|
182
|
+
- `#length`, `#size`, `#count` - the number of items in the underlying collection
|
|
183
|
+
- `#empty?` - whether the underlying collection has no items
|
|
184
|
+
- `#take` - the first ambassador, or the first `n` ambassadors when given a count
|
|
185
|
+
- `#all` - returns the collection itself
|
|
186
|
+
|
|
187
|
+
## Ambassador casting
|
|
188
|
+
|
|
189
|
+
Ambassadors will be cast based on the following strategies, in the order listed:
|
|
190
|
+
|
|
191
|
+
- Call `#to_ambassador` on the entity in the current iteration (if it responds to it).
|
|
192
|
+
- Use the provided `ambassador_class` to initialize a new ambassador for the entity.
|
|
193
|
+
- Infer the ambassador class name from the entity's class name (e.g. `User` => `UserAmbassador`).
|
|
194
|
+
|
|
195
|
+
If no strategy succeeds, the collection will raise `Ambassadors::UresolvedAmbassadorError`.
|
|
196
|
+
|
|
197
|
+
## Rails integration
|
|
198
|
+
|
|
199
|
+
When loaded inside a Rails application, the gem registers a Railtie that includes `ActiveModel::Serialization` in `Ambassador` once ActiveModel has loaded.
|
|
200
|
+
Ambassador subclasses that define an `attributes` method can then be serialised with the standard ActiveModel API (`serializable_hash`, `as_json`, and so on).
|
|
201
|
+
|
|
202
|
+
---
|
|
203
|
+
|
|
204
|
+
## Development
|
|
205
|
+
|
|
206
|
+
Run tests:
|
|
207
|
+
|
|
208
|
+
```sh
|
|
209
|
+
bundle exec rake test
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Run guard:
|
|
213
|
+
|
|
214
|
+
```sh
|
|
215
|
+
bundle exec guard
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Lint:
|
|
219
|
+
|
|
220
|
+
```sh
|
|
221
|
+
bundle exec rubocop
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
Run everything (tests and lint):
|
|
225
|
+
|
|
226
|
+
```sh
|
|
227
|
+
bundle exec rake
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
## Contributing
|
|
231
|
+
|
|
232
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/meetcleo/ambassadors.
|
|
233
|
+
This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](CODE_OF_CONDUCT.md).
|
|
234
|
+
|
|
235
|
+
## License
|
|
236
|
+
|
|
237
|
+
MIT. See [LICENSE](LICENSE.txt) for details.
|
data/Rakefile
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/gem_tasks"
|
|
4
|
+
require "rake/testtask"
|
|
5
|
+
|
|
6
|
+
begin
|
|
7
|
+
require "yard"
|
|
8
|
+
YARD::Rake::YardocTask.new(:doc) do |t|
|
|
9
|
+
t.options = ["--yardopts", ".yardopts"] # optional, YARD uses this by default
|
|
10
|
+
end
|
|
11
|
+
rescue LoadError
|
|
12
|
+
puts "Ignoring YARD doc task. Yard not installed."
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
Rake::TestTask.new(:test) do |t|
|
|
16
|
+
t.libs << "test"
|
|
17
|
+
t.libs << "lib"
|
|
18
|
+
t.test_files = FileList["test/**/*_test.rb"]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
require "rubocop/rake_task"
|
|
22
|
+
|
|
23
|
+
RuboCop::RakeTask.new
|
|
24
|
+
|
|
25
|
+
task default: %i[test rubocop]
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ambassadors
|
|
4
|
+
##
|
|
5
|
+
# Read-only wrapper around a domain entity.
|
|
6
|
+
#
|
|
7
|
+
# An Ambassador forms a stable, immutable interface that exposes selected
|
|
8
|
+
# attributes of an entity to the outside world. Domain interface methods
|
|
9
|
+
# return Ambassadors instead of the underlying entities, so internal models
|
|
10
|
+
# are protected from direct access and can evolve safely.
|
|
11
|
+
#
|
|
12
|
+
# @note Although this class is defined under the {Ambassadors} namespace,
|
|
13
|
+
# it defines the top-level constant {::Ambassador} for ergonomics.
|
|
14
|
+
#
|
|
15
|
+
# @example Defining a user ambassador
|
|
16
|
+
# class UserAmbassador < Ambassador
|
|
17
|
+
# expose :id, :email, :created_at
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# user = User.find(...)
|
|
21
|
+
# ambassador = UserAmbassador.new(user)
|
|
22
|
+
# ambassador.email # => "user@example.com" (frozen)
|
|
23
|
+
# ambassador.payment_cards # => NoMethodError
|
|
24
|
+
class ::Ambassador
|
|
25
|
+
require "ambassadors/freezer"
|
|
26
|
+
require "ambassadors/property_name_checker"
|
|
27
|
+
|
|
28
|
+
class << self
|
|
29
|
+
# Declares one or more methods that should be publicly exposed as read-only properties
|
|
30
|
+
# Values returned from these methods are frozen.
|
|
31
|
+
#
|
|
32
|
+
# @param properties [Array<Symbol>] list of method names to expose
|
|
33
|
+
# @return [void]
|
|
34
|
+
#
|
|
35
|
+
# @example
|
|
36
|
+
# class CardAmbassador < Ambassador
|
|
37
|
+
# expose :id, :email
|
|
38
|
+
# end
|
|
39
|
+
def expose(*properties)
|
|
40
|
+
properties = properties.map(&:to_sym)
|
|
41
|
+
# Remove any methods that are already defined from the set of properties
|
|
42
|
+
(properties - instance_methods).each do |property_name|
|
|
43
|
+
check_property_name_is_exposable!(property_name)
|
|
44
|
+
exposed_property_names.add(property_name)
|
|
45
|
+
|
|
46
|
+
define_method(:"#{property_name}") do
|
|
47
|
+
Freezer.new(entity.public_send(property_name)).value
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private :instance_eval, :instance_exec
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
# @return [Set<Symbol>] the set of all exposed property names
|
|
57
|
+
def exposed_property_names
|
|
58
|
+
@exposed_property_names ||= Set.new
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @param property_name [Symbol]
|
|
62
|
+
# @raise [InvalidPropertyError]
|
|
63
|
+
# @return [void]
|
|
64
|
+
def check_property_name_is_exposable!(property_name)
|
|
65
|
+
PropertyNameChecker.new(property_name).check!
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# @param entity [Object] the internal domain object being wrapped
|
|
70
|
+
def initialize(entity)
|
|
71
|
+
@__entity__ = entity
|
|
72
|
+
freeze
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @return [Object] the wrapped domain entity
|
|
76
|
+
def entity
|
|
77
|
+
@__entity__
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def ==(other)
|
|
81
|
+
other.respond_to?(:entity) && entity == other.entity
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# String representation showing exposed fields and their values.
|
|
85
|
+
#
|
|
86
|
+
# @return [String]
|
|
87
|
+
def inspect
|
|
88
|
+
"<#{self.class.name} #{exposed_properties.map { |k, v| "#{k}=#{v.inspect}" }.join(", ").strip}>"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
##
|
|
92
|
+
# Returns this Ambassador. (Used for polymorphic consistency)
|
|
93
|
+
# @return [Ambassador]
|
|
94
|
+
def to_ambassador
|
|
95
|
+
self
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def exposed_properties
|
|
101
|
+
hash = {}
|
|
102
|
+
self.class.send(:exposed_property_names).each do |name|
|
|
103
|
+
hash[name] = public_send(name)
|
|
104
|
+
end
|
|
105
|
+
hash
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ambassadors
|
|
4
|
+
##
|
|
5
|
+
# Collection wrapper for objects that can be represented as {Ambassadors::Ambassador}
|
|
6
|
+
# instances.
|
|
7
|
+
#
|
|
8
|
+
# @note Although this class is defined under the {Ambassadors} namespace,
|
|
9
|
+
# it reopens the top-level constant {::AmbassadorCollection}.
|
|
10
|
+
#
|
|
11
|
+
# @note The +#each+ method will use batch loading behind the scenes when
|
|
12
|
+
# enumerable is an ActiveRecord::Relation. This is to protect the database
|
|
13
|
+
# against loading enormous queries.
|
|
14
|
+
#
|
|
15
|
+
# @example Wrap an Array of models
|
|
16
|
+
# collection = AmbassadorCollection.new(users, ambassador_class: UserAmbassador)
|
|
17
|
+
# collection.each do |ambassador|
|
|
18
|
+
# puts ambassador.id
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# @example Use with an ActiveRecord::Relation
|
|
22
|
+
# relation = User.where(active: true)
|
|
23
|
+
# collection = AmbassadorCollection.new(relation, ambassador_class: UserAmbassador)
|
|
24
|
+
# collection.map(&:id)
|
|
25
|
+
#
|
|
26
|
+
# @example Enumerate lazily via Enumerator
|
|
27
|
+
# collection = AmbassadorCollection.new(users, ambassador_class: UserAmbassador)
|
|
28
|
+
# enum = collection.each
|
|
29
|
+
# first_two = [enum.next, enum.next]
|
|
30
|
+
#
|
|
31
|
+
class ::AmbassadorCollection
|
|
32
|
+
require_relative "iterators"
|
|
33
|
+
require "ambassadors/ambassador_factory"
|
|
34
|
+
|
|
35
|
+
include Enumerable
|
|
36
|
+
|
|
37
|
+
##
|
|
38
|
+
# If a batching strategy is used, load this number of records per batch
|
|
39
|
+
# @return [Integer]
|
|
40
|
+
DEFAULT_BATCH_SIZE = 1_000
|
|
41
|
+
|
|
42
|
+
##
|
|
43
|
+
# Wraps an enumerable collection of entities in Ambassadors
|
|
44
|
+
# @param enumerable [Enumerable]
|
|
45
|
+
# @param ambassador_class [nil, Class]
|
|
46
|
+
# @param ambassador_options [Hash]
|
|
47
|
+
# @param batch_size [Integer] The number of records to load per batch (if not loading from memory)
|
|
48
|
+
# @param iterator [Ambassadors::Iterators::IterationStrategy] Determines how to iterate over each item
|
|
49
|
+
# @param cursor [Symbol] The cursor to order batches on (@see https://api.rubyonrails.org/classes/ActiveRecord/Batches.html#method-i-find_each)
|
|
50
|
+
# @param order [Symbol<asc|desc>] The order by direction for batching (@see https://api.rubyonrails.org/classes/ActiveRecord/Batches.html#method-i-find_each)
|
|
51
|
+
def initialize(enumerable,
|
|
52
|
+
ambassador_class: nil,
|
|
53
|
+
ambassador_options: {},
|
|
54
|
+
batch_size: DEFAULT_BATCH_SIZE,
|
|
55
|
+
iterator: Ambassadors::Iterators.resolve(
|
|
56
|
+
enumerable:
|
|
57
|
+
),
|
|
58
|
+
**)
|
|
59
|
+
@enumerable = enumerable
|
|
60
|
+
@iterator = iterator.new(
|
|
61
|
+
enumerable: @enumerable,
|
|
62
|
+
batch_size: batch_size,
|
|
63
|
+
**
|
|
64
|
+
)
|
|
65
|
+
@ambassador_factory = AmbassadorFactory.new(
|
|
66
|
+
default_ambassador_class: ambassador_class,
|
|
67
|
+
ambassador_options: ambassador_options
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def each
|
|
72
|
+
return enum_for(:each) unless block_given?
|
|
73
|
+
|
|
74
|
+
@iterator.to_enum.each do |item|
|
|
75
|
+
yield(@ambassador_factory.cast(item))
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
##
|
|
80
|
+
# The number of items in the current collection
|
|
81
|
+
# @return [Integer]
|
|
82
|
+
def length
|
|
83
|
+
@enumerable.length
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
alias size length
|
|
87
|
+
alias count length
|
|
88
|
+
|
|
89
|
+
##
|
|
90
|
+
# Whether the current collection has no items
|
|
91
|
+
# @return [Boolean]
|
|
92
|
+
def empty?
|
|
93
|
+
@enumerable.empty?
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
##
|
|
97
|
+
# The entire collection. Returns self.
|
|
98
|
+
# @return [self]
|
|
99
|
+
def all
|
|
100
|
+
# TODO: Revisit this decision?
|
|
101
|
+
# This makes sense in terms of the entire collection _is_ the collection,
|
|
102
|
+
# but it might not be the best implementation
|
|
103
|
+
self
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def take(index = nil)
|
|
107
|
+
if index.nil?
|
|
108
|
+
@enumerable.take(1)[0]
|
|
109
|
+
else
|
|
110
|
+
super
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ambassadors
|
|
4
|
+
class AmbassadorFactory # :nodoc:
|
|
5
|
+
def initialize(default_ambassador_class: nil, ambassador_options: {})
|
|
6
|
+
@default_ambassador_class = default_ambassador_class
|
|
7
|
+
@ambassador_options = ambassador_options
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
##
|
|
11
|
+
# Cast the given entity with the most appropriate Ambassador class.
|
|
12
|
+
# Will prioritise in the following order:
|
|
13
|
+
# - +#to_ambassador+ called on the entity
|
|
14
|
+
# - +@ambassador_class+ if defined
|
|
15
|
+
# - Infer based on the class name (e.g. +User+ => +UserAmbassador+)
|
|
16
|
+
# @return [Ambassador] Instance of an Ambassador class for this entity
|
|
17
|
+
# @raise [UresolvedAmbassadorError]
|
|
18
|
+
def cast(entity)
|
|
19
|
+
return entity.to_ambassador if entity.respond_to?(:to_ambassador)
|
|
20
|
+
|
|
21
|
+
return @default_ambassador_class.new(entity, **@ambassador_options) if @default_ambassador_class
|
|
22
|
+
|
|
23
|
+
raise UresolvedAmbassadorError, "Cannot infer ambassador for #{entity}" unless entity.class.name
|
|
24
|
+
|
|
25
|
+
inferred_ambassador_class_for_entity(entity)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
protected
|
|
29
|
+
|
|
30
|
+
def inferred_ambassador_class_for_entity(entity)
|
|
31
|
+
inferred_ambassador_class_name = "#{entity.class.name}Ambassador"
|
|
32
|
+
|
|
33
|
+
unless Module.const_defined?(inferred_ambassador_class_name)
|
|
34
|
+
raise UresolvedAmbassadorError, "Cannot infer ambassador for #{entity}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
Module.const_get(inferred_ambassador_class_name)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ambassadors
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
##
|
|
7
|
+
# Raised when we are unable to determine which +Ambassador+ class to load
|
|
8
|
+
class UresolvedAmbassadorError < Error; end
|
|
9
|
+
|
|
10
|
+
##
|
|
11
|
+
# Raised when trying to expose an unsafe property
|
|
12
|
+
class InvalidPropertyError < Error
|
|
13
|
+
# @param property_name [String, Symbol]
|
|
14
|
+
def initialize(property_name)
|
|
15
|
+
super
|
|
16
|
+
@property_name = property_name
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @return [String]
|
|
20
|
+
def message
|
|
21
|
+
"Cannot define a read-only property named #{@property_name}"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ambassadors
|
|
4
|
+
##
|
|
5
|
+
# Ensures any value returned from the entity is deeply frozen.
|
|
6
|
+
# @abstract
|
|
7
|
+
# @private
|
|
8
|
+
class Freezer
|
|
9
|
+
# @return [Object] the frozen value
|
|
10
|
+
attr_reader :value
|
|
11
|
+
|
|
12
|
+
# @param value [Object] the object to freeze
|
|
13
|
+
def initialize(value)
|
|
14
|
+
@value = value.frozen? ? value : value.dup.freeze
|
|
15
|
+
freeze
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private_constant :Freezer
|
|
20
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ambassadors
|
|
4
|
+
module Iterators
|
|
5
|
+
##
|
|
6
|
+
# Strategy to use in +AmbassadorCollection+ when it's safer to paginate
|
|
7
|
+
# over batches of the data.
|
|
8
|
+
class FindEachIterator < Iterator
|
|
9
|
+
def initialize(batch_size:, cursor: nil, order: nil, **)
|
|
10
|
+
super(**)
|
|
11
|
+
@batch_size = batch_size
|
|
12
|
+
@cursor = cursor || @enumerable.primary_key
|
|
13
|
+
@order = order || :asc
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def each(&)
|
|
17
|
+
return enum_for(:each) unless block_given?
|
|
18
|
+
|
|
19
|
+
@enumerable.find_each(batch_size: @batch_size, cursor: @cursor, order: @order, &)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ambassadors
|
|
4
|
+
module Iterators
|
|
5
|
+
##
|
|
6
|
+
# Base class for custom iteration strategies.
|
|
7
|
+
# Pass in the enumerable collection to iterate. Subclasses should define a
|
|
8
|
+
# custom implementation of +#each+.
|
|
9
|
+
#
|
|
10
|
+
class Iterator
|
|
11
|
+
##
|
|
12
|
+
# @param enumerable [Enumerable<Object>]
|
|
13
|
+
def initialize(enumerable:, **_kwargs)
|
|
14
|
+
@enumerable = enumerable
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def each(&)
|
|
18
|
+
return enum_for(:each) unless block_given?
|
|
19
|
+
|
|
20
|
+
@enumerable.each(&)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ambassadors
|
|
4
|
+
module Iterators
|
|
5
|
+
class IteratorResolver # :nodoc:
|
|
6
|
+
def self.resolve(...)
|
|
7
|
+
new(...).iterator_class
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def initialize(enumerable:)
|
|
11
|
+
@enumerable = enumerable
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
##
|
|
15
|
+
# @return [Iterator]
|
|
16
|
+
def iterator_class
|
|
17
|
+
if defined?(ActiveRecord::Relation) && @enumerable.is_a?(ActiveRecord::Relation)
|
|
18
|
+
FindEachIterator
|
|
19
|
+
else
|
|
20
|
+
EachIterator
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ambassadors
|
|
4
|
+
##
|
|
5
|
+
# Iteration strategies are used to allow an +AmbassadorCollection+ to define
|
|
6
|
+
# how it should iterate over its enumerable. This might include
|
|
7
|
+
# setting limits on how many records are loaded from a DB at a time, or
|
|
8
|
+
# how to safely paginate records on a 3rd party API.
|
|
9
|
+
module Iterators # :nodoc:
|
|
10
|
+
require_relative "iterators/iterator"
|
|
11
|
+
require_relative "iterators/each_iterator"
|
|
12
|
+
require_relative "iterators/find_each_iterator"
|
|
13
|
+
require_relative "iterators/iterator_resolver"
|
|
14
|
+
|
|
15
|
+
def self.resolve(enumerable:)
|
|
16
|
+
IteratorResolver.resolve(enumerable: enumerable)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ambassadors
|
|
4
|
+
##
|
|
5
|
+
# Validates that exposed property names are safe
|
|
6
|
+
class PropertyNameChecker
|
|
7
|
+
# @return [Symbol]
|
|
8
|
+
attr_reader :property_name
|
|
9
|
+
|
|
10
|
+
# @param property_name [Symbol]
|
|
11
|
+
def initialize(property_name)
|
|
12
|
+
@property_name = property_name
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# @raise [InvalidPropertyError] if method is not something we want to expose
|
|
16
|
+
# @return [void]
|
|
17
|
+
def check!
|
|
18
|
+
raise Ambassadors::InvalidPropertyError, property_name if property_name_is_bang?
|
|
19
|
+
raise Ambassadors::InvalidPropertyError, property_name if property_name_is_setter?
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def property_name_is_bang?
|
|
25
|
+
property_name.to_s.end_with?("!")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def property_name_is_setter?
|
|
29
|
+
property_name.to_s.end_with?("=")
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private_constant :PropertyNameChecker
|
|
34
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/railtie"
|
|
4
|
+
|
|
5
|
+
module Ambassadors
|
|
6
|
+
##
|
|
7
|
+
# Hooks into the Rails boot process when the gem is loaded inside a Rails application.
|
|
8
|
+
# @see https://api.rubyonrails.org/classes/Rails/Railtie.html
|
|
9
|
+
class Railtie < Rails::Railtie
|
|
10
|
+
##
|
|
11
|
+
# Includes +ActiveModel::Serialization+ in {::Ambassador} when ActiveModel is
|
|
12
|
+
# available, so exposed properties can be serialised.
|
|
13
|
+
initializer "ambassadors.active_model_serialization" do
|
|
14
|
+
ActiveSupport.on_load(:active_model) do
|
|
15
|
+
if defined?(::Ambassador) && defined?(::ActiveModel::Serialization)
|
|
16
|
+
::Ambassador.include(ActiveModel::Serialization)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
data/lib/ambassadors.rb
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
##
|
|
4
|
+
# Root module for Ambassadors behaviour.
|
|
5
|
+
# @note That two of the public classes defined in this module are actually
|
|
6
|
+
# cast to the top-level-namespace. This is purely for ergonomics.
|
|
7
|
+
# @see README.md for more information.
|
|
8
|
+
module Ambassadors
|
|
9
|
+
require_relative "ambassadors/version"
|
|
10
|
+
require_relative "ambassadors/ambassador"
|
|
11
|
+
require_relative "ambassadors/ambassador_collection"
|
|
12
|
+
require_relative "ambassadors/errors"
|
|
13
|
+
require_relative "ambassadors/railtie" if defined?(Rails)
|
|
14
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: ambassadors
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Gavin Morrice
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: |
|
|
13
|
+
Ambassadors are frozen, read-only wrappers around domain entities, providing a stable interface
|
|
14
|
+
that is safe to pass across domain boundaries in a modular monolith.
|
|
15
|
+
email:
|
|
16
|
+
- gavin@gavinmorrice.com
|
|
17
|
+
executables: []
|
|
18
|
+
extensions: []
|
|
19
|
+
extra_rdoc_files: []
|
|
20
|
+
files:
|
|
21
|
+
- CHANGELOG.md
|
|
22
|
+
- CODE_OF_CONDUCT.md
|
|
23
|
+
- LICENSE.txt
|
|
24
|
+
- README.md
|
|
25
|
+
- Rakefile
|
|
26
|
+
- lib/ambassadors.rb
|
|
27
|
+
- lib/ambassadors/ambassador.rb
|
|
28
|
+
- lib/ambassadors/ambassador_collection.rb
|
|
29
|
+
- lib/ambassadors/ambassador_factory.rb
|
|
30
|
+
- lib/ambassadors/errors.rb
|
|
31
|
+
- lib/ambassadors/freezer.rb
|
|
32
|
+
- lib/ambassadors/iterators.rb
|
|
33
|
+
- lib/ambassadors/iterators/each_iterator.rb
|
|
34
|
+
- lib/ambassadors/iterators/find_each_iterator.rb
|
|
35
|
+
- lib/ambassadors/iterators/iterator.rb
|
|
36
|
+
- lib/ambassadors/iterators/iterator_resolver.rb
|
|
37
|
+
- lib/ambassadors/property_name_checker.rb
|
|
38
|
+
- lib/ambassadors/railtie.rb
|
|
39
|
+
- lib/ambassadors/version.rb
|
|
40
|
+
homepage: https://github.com/meetcleo/ambassadors
|
|
41
|
+
licenses:
|
|
42
|
+
- MIT
|
|
43
|
+
metadata:
|
|
44
|
+
source_code_uri: https://github.com/meetcleo/ambassadors
|
|
45
|
+
changelog_uri: https://github.com/meetcleo/ambassadors/blob/main/CHANGELOG.md
|
|
46
|
+
bug_tracker_uri: https://github.com/meetcleo/ambassadors/issues
|
|
47
|
+
rubygems_mfa_required: 'true'
|
|
48
|
+
rdoc_options: []
|
|
49
|
+
require_paths:
|
|
50
|
+
- lib
|
|
51
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
52
|
+
requirements:
|
|
53
|
+
- - ">="
|
|
54
|
+
- !ruby/object:Gem::Version
|
|
55
|
+
version: 3.2.0
|
|
56
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - ">="
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '0'
|
|
61
|
+
requirements: []
|
|
62
|
+
rubygems_version: 4.0.13
|
|
63
|
+
specification_version: 4
|
|
64
|
+
summary: Immutable, read-only wrappers for domain entities.
|
|
65
|
+
test_files: []
|