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 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.
@@ -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
+ [![Ruby](https://github.com/meetcleo/ambassadors/actions/workflows/main.yml/badge.svg)](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,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ambassadors
4
+ module Iterators
5
+ ##
6
+ # Strategy to use in +AmbassadorCollection+ when it's safe to
7
+ # iterate over each item in the set.
8
+ class EachIterator < Iterator
9
+ end
10
+ end
11
+ 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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ambassadors
4
+ VERSION = "0.1.0"
5
+ end
@@ -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: []