end_point_blank 0.6.1 → 0.12.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.
Files changed (31) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +608 -0
  3. data/README.md +236 -19
  4. data/end_point_blank.gemspec +5 -3
  5. data/lib/end_point_blank/access_tokens.rb +244 -25
  6. data/lib/end_point_blank/authorization.rb +105 -20
  7. data/lib/end_point_blank/commands/authentication_cache.rb +141 -19
  8. data/lib/end_point_blank/commands/basic_authenticate.rb +66 -2
  9. data/lib/end_point_blank/commands/bearer_generate.rb +36 -0
  10. data/lib/end_point_blank/commands/endpoint_authorize.rb +46 -1
  11. data/lib/end_point_blank/commands/endpoint_update.rb +2 -2
  12. data/lib/end_point_blank/commands/generate_access_token.rb +241 -8
  13. data/lib/end_point_blank/commands/http.rb +20 -1
  14. data/lib/end_point_blank/configuration.rb +111 -4
  15. data/lib/end_point_blank/configuration_error.rb +18 -0
  16. data/lib/end_point_blank/rails/authenticated.rb +62 -7
  17. data/lib/end_point_blank/rails/authorized.rb +9 -13
  18. data/lib/end_point_blank/target_url.rb +57 -0
  19. data/lib/end_point_blank/token_unavailable_error.rb +102 -0
  20. data/lib/end_point_blank/unauthorized_error.rb +81 -1
  21. data/lib/end_point_blank/version.rb +1 -1
  22. data/lib/end_point_blank/writers/delayed_writer.rb +131 -21
  23. data/lib/end_point_blank/writers/direct_writer.rb +1 -1
  24. data/lib/end_point_blank/writers/exception_writer.rb +11 -2
  25. data/lib/end_point_blank/writers/log_writer.rb +1 -1
  26. data/lib/end_point_blank/writers/request_writer.rb +1 -0
  27. data/lib/end_point_blank/writers/response_writer.rb +1 -0
  28. data/lib/end_point_blank/writers/shared.rb +35 -4
  29. data/lib/end_point_blank.rb +239 -2
  30. metadata +15 -10
  31. data/lib/end_point_blank/loggers/logger.rb +0 -30
@@ -34,6 +34,8 @@ require_relative "end_point_blank/middleware/rack/report_interaction"
34
34
  require_relative "end_point_blank/rack/env_store"
35
35
  require_relative "end_point_blank/rack/headers"
36
36
  require_relative "end_point_blank/unauthorized_error"
37
+ require_relative "end_point_blank/token_unavailable_error"
38
+ require_relative "end_point_blank/configuration_error"
37
39
  if defined?(::Rails)
38
40
  require_relative "end_point_blank/rails/authenticated"
39
41
  require_relative "end_point_blank/rails/authorized"
@@ -44,10 +46,245 @@ end
44
46
  module EndPointBlank
45
47
  class Error < StandardError; end
46
48
 
47
- # Your code goes here...
49
+ # Serializes {EndPointBlank.configure} calls end to end -- both the block
50
+ # and the commit -- so two calls can never overlap. Without this, two
51
+ # calls that both succeed could overlap: the second one can start while
52
+ # the first is still running, so both build their candidate from the same
53
+ # starting snapshot. Whichever one finishes last still decides what to
54
+ # write by diffing its own candidate against that same snapshot, not
55
+ # against whatever {Configuration} holds live by the time it actually
56
+ # commits (see {apply_configure_changes}) -- so its own change still looks
57
+ # like a change relative to its now-stale snapshot, and it writes that
58
+ # value over whatever the other call already committed, with no error.
59
+ # See {EndPointBlank.configure}.
60
+ @configure_mutex = Mutex.new
61
+
62
+ # Applies a block of configuration changes to the shared {Configuration}
63
+ # instance atomically: either every assignment the block makes through
64
+ # its block argument succeeds, or none of them are kept.
65
+ #
66
+ # The block receives a detached copy of the configuration, not the live
67
+ # singleton. Only the fields whose value on that copy differs from an
68
+ # independent deep copy taken before the block ran are written onto the
69
+ # live singleton afterward, and only once the block returns normally.
70
+ # That makes the atomicity structural rather than a rollback: if the
71
+ # block raises -- any exception, not only StandardError -- nothing is
72
+ # written at all. This also covers a field the block sets for the very
73
+ # first time (e.g. client_id on a fresh boot, before
74
+ # {Configuration#initialize} has ever assigned it): the assignment lands
75
+ # on the copy and is discarded with it.
76
+ #
77
+ # The yielded object (c, by convention) is valid only for the duration of
78
+ # the block. Once configure returns -- whether the block returned
79
+ # normally or raised -- it is frozen, and every String, Array or Hash
80
+ # value it holds is first replaced with its own frozen deep copy (see
81
+ # {freeze_candidate}). A write made through a reference to it retained
82
+ # past the block always raises FrozenError: a reassignment
83
+ # (`saved.app_name = "x"`) because the candidate itself is frozen, and an
84
+ # in-place edit (`saved.masking_rules << rule`, `saved.app_name << "x"`,
85
+ # editing a rule Hash in place) because the value it points to is frozen
86
+ # too, not just the candidate. Every field that gets committed is also
87
+ # written as a fresh copy, not the candidate's own object (see
88
+ # {apply_configure_changes}), so the live {Configuration} never ends up
89
+ # aliasing anything the candidate still holds.
90
+ #
91
+ # Inside the block, a read that bypasses the block argument -- e.g.
92
+ # Configuration.instance.app_name, or EndPointBlank.logger right after
93
+ # `c.logger = new_logger` earlier in the same block -- still returns the
94
+ # value from before this configure call started, not what the block has
95
+ # set on c so far: nothing is written to the live singleton until the
96
+ # block returns normally and the commit runs.
97
+ #
98
+ # Committing only the fields the block actually changed, rather than
99
+ # every field, matters because the live singleton can change out from
100
+ # under a configure call that never touches a given field -- a direct
101
+ # `Configuration.instance.some_field = ...` outside configure, or
102
+ # `EndPointBlank.logger=`, is not covered by {@configure_mutex}. Writing
103
+ # every field back unconditionally would silently revert that kind of
104
+ # concurrent change as soon as this call's block returned, even though
105
+ # this call never touched the field itself.
106
+ #
107
+ # String, Array and Hash values -- including {Configuration#masking_rules}
108
+ # and the Hashes in it -- are deep-copied, so an in-place edit
109
+ # (`c.masking_rules.first[:regex] << "|.*"`, `c.app_name << "-staging"`,
110
+ # `c.masking_rules << rule`) changes only the block's copy, never the
111
+ # live value, unless and until that field is committed. Assigning one of
112
+ # these fields through c copies the assigned value too, rather than
113
+ # storing the object itself: after `c.masking_rules = rules`, mutating
114
+ # the `rules` array the caller passed in no longer reaches the live
115
+ # config, whether that mutation happens while the block is still running
116
+ # or afterward. Objects the caller owns and hands in by reference --
117
+ # {Configuration#logger}, {Configuration#mask_hook},
118
+ # {Configuration#version_finder} -- are not String/Array/Hash, so they
119
+ # are copied by reference like any other field, both while the block
120
+ # runs and afterward through a retained c; EndPointBlank.configure
121
+ # cannot and does not roll back mutation the caller performs on those
122
+ # objects themselves, whether through c or directly.
123
+ #
124
+ # This is generic over every field {Configuration} has now or gains
125
+ # later (including a future sc-1265 cache_ttl upper bound): it copies
126
+ # every instance variable, so no new setter needs to be added here for
127
+ # its validation to be atomic.
128
+ #
129
+ # Calls are serialized with a module-level Mutex held across both the
130
+ # block and the commit (see {@configure_mutex}), so two calls from
131
+ # different threads can never interleave their reads and writes: the
132
+ # second one always starts from whatever the first one left behind,
133
+ # whether the first succeeded or raised. The lock only orders
134
+ # configure-against-configure; a reader elsewhere that is not going
135
+ # through configure can still observe the commit loop's writes one field
136
+ # at a time while it runs.
137
+ #
138
+ # Because Ruby's Mutex is not reentrant, calling EndPointBlank.configure
139
+ # again from inside a configure block, on the same thread, raises {Error}
140
+ # rather than running -- configure blocks are not meant to nest. A block
141
+ # that starts a different thread, has that thread call configure, and
142
+ # then joins it will hang instead of raising, since that thread is
143
+ # genuinely waiting on a lock this thread holds.
144
+ #
145
+ # @raise whatever the block raises, or {Error} if called while a
146
+ # configure call is already in progress on the same thread; the live
147
+ # configuration is left exactly as it was before the call
48
148
  def self.configure(&block)
49
- yield Configuration.instance
149
+ raise Error, "EndPointBlank.configure cannot be called from inside a configure block" if @configure_mutex.owned?
150
+
151
+ @configure_mutex.synchronize { configure_and_commit(&block) }
152
+ end
153
+
154
+ # Builds the candidate and comparison snapshot for the current live
155
+ # +config+, runs +block+ against the candidate, and commits the result --
156
+ # freezing the candidate once the block returns, whether it succeeded or
157
+ # raised (see {EndPointBlank.configure}). Must only be called while
158
+ # {@configure_mutex} is held.
159
+ def self.configure_and_commit(&block)
160
+ config = Configuration.instance
161
+ original = configure_snapshot_for(config)
162
+ candidate = configure_candidate_for(config)
163
+
164
+ begin
165
+ block.call(candidate)
166
+ apply_configure_changes(config, original, candidate)
167
+ ensure
168
+ freeze_candidate(candidate)
169
+ end
170
+ end
171
+ private_class_method :configure_and_commit
172
+
173
+ # Deep-copies every current instance variable of +config+ into a Hash
174
+ # keyed by ivar name (see {configure_deep_dup}), so the result shares no
175
+ # mutable object with +config+. {EndPointBlank.configure} calls this
176
+ # twice per call -- once directly, for the comparison snapshot, and once
177
+ # more via {configure_candidate_for}, for the copy the block mutates --
178
+ # so an in-block edit to one can never be mistaken for "unchanged" by
179
+ # comparing it back to the other.
180
+ def self.configure_snapshot_for(config)
181
+ config.instance_variables.each_with_object({}) do |ivar, memo|
182
+ memo[ivar] = configure_deep_dup(config.instance_variable_get(ivar))
183
+ end
184
+ end
185
+ private_class_method :configure_snapshot_for
186
+
187
+ # Recursively duplicates plain data (String, Array, Hash). Every other
188
+ # value -- Integer, Symbol, true/false/nil, and an object the caller owns
189
+ # and handed in by reference such as {Configuration#logger},
190
+ # {Configuration#mask_hook} or {Configuration#version_finder} -- is
191
+ # returned as-is, since none of those can be edited in place through
192
+ # {Configuration}'s documented API the way a String or a rule Hash can.
193
+ def self.configure_deep_dup(value)
194
+ case value
195
+ when String then value.dup
196
+ when Array then value.map { |element| configure_deep_dup(element) }
197
+ when Hash
198
+ value.each_with_object({}) { |(k, v), memo| memo[configure_deep_dup(k)] = configure_deep_dup(v) }
199
+ else
200
+ value
201
+ end
202
+ end
203
+ private_class_method :configure_deep_dup
204
+
205
+ # Recursively duplicates and freezes plain data (String, Array, Hash), the
206
+ # same shape {configure_deep_dup} walks. The copy is built first and
207
+ # frozen after, so this never freezes +value+ itself, only the new copy --
208
+ # a caller who still holds +value+ (e.g. the Array they passed to
209
+ # `c.masking_rules = rules`) keeps a fully mutable object; only the copy
210
+ # {freeze_candidate} puts on the frozen candidate is locked. Every other
211
+ # value -- Integer, Symbol, true/false/nil, and an object the caller owns
212
+ # and handed in by reference such as {Configuration#logger},
213
+ # {Configuration#mask_hook} or {Configuration#version_finder} -- is
214
+ # returned as-is, unfrozen, exactly as {configure_deep_dup} leaves it.
215
+ def self.configure_deep_freeze(value)
216
+ case value
217
+ when String then value.dup.freeze
218
+ when Array then value.map { |element| configure_deep_freeze(element) }.freeze
219
+ when Hash
220
+ value.each_with_object({}) do |(k, v), memo|
221
+ memo[configure_deep_freeze(k)] = configure_deep_freeze(v)
222
+ end.freeze
223
+ else
224
+ value
225
+ end
226
+ end
227
+ private_class_method :configure_deep_freeze
228
+
229
+ # Builds the detached copy {EndPointBlank.configure} yields to its block:
230
+ # a bare {Configuration} instance -- built with
231
+ # `Configuration.send(:allocate)` since {Configuration} is a Singleton
232
+ # and its `.new`/`.allocate` are private -- carrying its own independent
233
+ # deep copy of +config+'s current ivars, so it stays a private scratch
234
+ # object, not a second singleton, and mutating it can never reach
235
+ # +config+.
236
+ def self.configure_candidate_for(config)
237
+ candidate = Configuration.send(:allocate)
238
+ configure_snapshot_for(config).each { |ivar, value| candidate.instance_variable_set(ivar, value) }
239
+ candidate
240
+ end
241
+ private_class_method :configure_candidate_for
242
+
243
+ # Locks down +candidate+ once the block is done with it (see
244
+ # {configure_and_commit}). Freezing +candidate+ alone only blocks a
245
+ # *reassignment* through a reference to it retained past the block
246
+ # (`saved.app_name = "x"`); it does nothing to the Array, Hash or String
247
+ # objects its ivars point to, so an in-place edit through that same
248
+ # reference (`saved.masking_rules << rule`, `saved.app_name << "x"`,
249
+ # editing a rule Hash in place) would return normally and silently never
250
+ # reach the live config -- the same failure {apply_configure_changes}
251
+ # committing a fresh copy already prevents, one level down. Each ivar is
252
+ # therefore replaced with its own {configure_deep_freeze} copy before
253
+ # +candidate+ itself is frozen, so every one of those in-place edits
254
+ # raises FrozenError too. This replaces the ivar's value on +candidate+,
255
+ # not on the value itself: the object the caller handed to +candidate+
256
+ # (e.g. via `c.masking_rules = rules`) is never frozen in place, so it
257
+ # stays fully mutable for the caller's own further use.
258
+ def self.freeze_candidate(candidate)
259
+ candidate.instance_variables.each do |ivar|
260
+ candidate.instance_variable_set(ivar, configure_deep_freeze(candidate.instance_variable_get(ivar)))
261
+ end
262
+ candidate.freeze
263
+ end
264
+ private_class_method :freeze_candidate
265
+
266
+ # Writes onto the live +config+ only the ivars whose value on +candidate+
267
+ # differs from +original+, the independent deep copy taken before the
268
+ # block ran. A field the block never touched is left exactly as +config+
269
+ # has it now, even if something else changed it while the block was
270
+ # running. Only reached after the block has returned normally.
271
+ #
272
+ # Commits a fresh {configure_deep_dup} of the value, not +candidate+'s own
273
+ # object: when the block assigns a String, Array or Hash straight through
274
+ # (`c.masking_rules = mine`), +candidate+'s ivar *is* the caller's own
275
+ # object at this point -- {freeze_candidate} has not run yet -- so
276
+ # committing it as-is would make the live +config+ literally the same
277
+ # object as +mine+, and the caller mutating +mine+ afterward (`mine <<
278
+ # rule`, no retained +candidate+ needed at all) would reach +config+
279
+ # directly -- bypassing {@configure_mutex} and any validation entirely,
280
+ # silently.
281
+ def self.apply_configure_changes(config, original, candidate)
282
+ candidate.instance_variables.each do |ivar|
283
+ value = candidate.instance_variable_get(ivar)
284
+ config.instance_variable_set(ivar, configure_deep_dup(value)) unless value == original[ivar]
285
+ end
50
286
  end
287
+ private_class_method :apply_configure_changes
51
288
 
52
289
  # Defaults to stderr, not stdout. This logger belongs to a library running
53
290
  # inside someone else's process: anything it writes to stdout lands in the
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: end_point_blank
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.1
4
+ version: 0.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Robert A. Lasch
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-08-14 00:00:00.000000000 Z
10
+ date: 2026-10-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: excon
@@ -79,9 +79,10 @@ dependencies:
79
79
  - - ">="
80
80
  - !ruby/object:Gem::Version
81
81
  version: 1.0.0
82
- description: EndPointBlank client library for Ruby. A framework-agnostic core runs
83
- in plain Ruby / Sinatra, with Rails supported as an auto-loaded adapter. Provides
84
- API endpoint tracking, authorization, and error/request/response/log reporting.
82
+ description: 'Ruby and Rails SDK for EndPointBlank: authorize service-to-service (machine-to-machine)
83
+ API calls, report endpoint versions, and see which clients still call deprecated
84
+ API versions before you sunset them. A framework-agnostic core runs in plain Ruby
85
+ / Sinatra, with Rails supported as an auto-loaded adapter.'
85
86
  email:
86
87
  - rlasch@gmail.com
87
88
  executables: []
@@ -115,10 +116,10 @@ files:
115
116
  - lib/end_point_blank/commands/route_pattern_finder.rb
116
117
  - lib/end_point_blank/commands/version_finder.rb
117
118
  - lib/end_point_blank/configuration.rb
119
+ - lib/end_point_blank/configuration_error.rb
118
120
  - lib/end_point_blank/deprecation_headers.rb
119
121
  - lib/end_point_blank/fast_json_truncator.rb
120
122
  - lib/end_point_blank/log_entry.rb
121
- - lib/end_point_blank/loggers/logger.rb
122
123
  - lib/end_point_blank/masking.rb
123
124
  - lib/end_point_blank/middleware/rack/report_interaction.rb
124
125
  - lib/end_point_blank/rack/env_store.rb
@@ -129,6 +130,8 @@ files:
129
130
  - lib/end_point_blank/rails/versioned.rb
130
131
  - lib/end_point_blank/session_configuration.rb
131
132
  - lib/end_point_blank/string_truncator.rb
133
+ - lib/end_point_blank/target_url.rb
134
+ - lib/end_point_blank/token_unavailable_error.rb
132
135
  - lib/end_point_blank/unauthorized_error.rb
133
136
  - lib/end_point_blank/version.rb
134
137
  - lib/end_point_blank/writers/delayed_writer.rb
@@ -141,13 +144,15 @@ files:
141
144
  - lib/end_point_blank/xml_truncator.rb
142
145
  - sig/end_point_blank_rack.rbs
143
146
  - test.sh
144
- homepage: https://github.com/EndPointBlank/end_point_blank_rails
147
+ homepage: https://endpointblank.com
145
148
  licenses:
146
149
  - Nonstandard
147
150
  metadata:
148
151
  allowed_push_host: https://rubygems.org
149
- homepage_uri: https://github.com/EndPointBlank/end_point_blank_rails
152
+ homepage_uri: https://endpointblank.com
150
153
  source_code_uri: https://github.com/EndPointBlank/end_point_blank_rails
154
+ documentation_uri: https://endpointblank.com/docs/sdk-setup
155
+ bug_tracker_uri: https://github.com/EndPointBlank/end_point_blank_rails/issues
151
156
  changelog_uri: https://github.com/EndPointBlank/end_point_blank_rails/releases
152
157
  rdoc_options: []
153
158
  require_paths:
@@ -165,6 +170,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
165
170
  requirements: []
166
171
  rubygems_version: 3.6.2
167
172
  specification_version: 4
168
- summary: Ruby/Rails client for EndPointBlank — endpoint tracking, authorization, and
169
- error/request/response/log reporting.
173
+ summary: 'Ruby and Rails SDK for EndPointBlank: authorize service-to-service API calls,
174
+ report endpoint versions, and see which clients still call deprecated API versions.'
170
175
  test_files: []
@@ -1,30 +0,0 @@
1
- module EndPointBlank
2
- module Loggers
3
- class Logger
4
-
5
- def self.info(message)
6
- end
7
-
8
- def self.debug(message)
9
- Writers::Writer.new(:debug).write(message: message)
10
- end
11
-
12
- def self.error(message)
13
- EndPointBlank.logger.error(message)
14
- end
15
-
16
- def self.warn(message)
17
- EndPointBlank.logger.warn(message)
18
- end
19
-
20
- def self.fatal(message)
21
- EndPointBlank.logger.fatal(message)
22
- end
23
-
24
- private
25
- def self.write(message:, level: )
26
- Writers::Writer.new(:info).write(message: message)
27
- end
28
- end
29
- end
30
- end