end_point_blank 0.6.1 → 0.13.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +653 -0
  3. data/README.md +424 -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/management/client.rb +228 -0
  17. data/lib/end_point_blank/management/configuration.rb +56 -0
  18. data/lib/end_point_blank/management/error.rb +134 -0
  19. data/lib/end_point_blank/management/error_codes.rb +115 -0
  20. data/lib/end_point_blank/management/idempotency_key.rb +33 -0
  21. data/lib/end_point_blank/management/page.rb +55 -0
  22. data/lib/end_point_blank/management/resources/api_packages.rb +104 -0
  23. data/lib/end_point_blank/management/resources/applications.rb +167 -0
  24. data/lib/end_point_blank/management/resources/base.rb +117 -0
  25. data/lib/end_point_blank/management/resources/clients.rb +152 -0
  26. data/lib/end_point_blank/management/retry_policy.rb +65 -0
  27. data/lib/end_point_blank/management/transport.rb +167 -0
  28. data/lib/end_point_blank/management/url_path.rb +18 -0
  29. data/lib/end_point_blank/management.rb +52 -0
  30. data/lib/end_point_blank/rails/authenticated.rb +62 -7
  31. data/lib/end_point_blank/rails/authorized.rb +9 -13
  32. data/lib/end_point_blank/target_url.rb +57 -0
  33. data/lib/end_point_blank/token_unavailable_error.rb +102 -0
  34. data/lib/end_point_blank/unauthorized_error.rb +81 -1
  35. data/lib/end_point_blank/version.rb +1 -1
  36. data/lib/end_point_blank/writers/delayed_writer.rb +131 -21
  37. data/lib/end_point_blank/writers/direct_writer.rb +1 -1
  38. data/lib/end_point_blank/writers/exception_writer.rb +11 -2
  39. data/lib/end_point_blank/writers/log_writer.rb +1 -1
  40. data/lib/end_point_blank/writers/request_writer.rb +1 -0
  41. data/lib/end_point_blank/writers/response_writer.rb +1 -0
  42. data/lib/end_point_blank/writers/shared.rb +35 -4
  43. data/lib/end_point_blank.rb +240 -2
  44. metadata +29 -10
  45. data/lib/end_point_blank/loggers/logger.rb +0 -30
@@ -16,14 +16,46 @@ module EndPointBlank
16
16
  module DelayedWriter
17
17
  MAX_QUEUE_SIZE = 1000
18
18
  WARN_THROTTLE_SECONDS = 30
19
+ # Payloads per outbound request. One request per payload would multiply
20
+ # the host application's outbound traffic by its own request rate; the
21
+ # drain exists to amortise that.
22
+ BATCH_SIZE = 6
19
23
  # Fallback thread count used when Configuration#worker_count is unset,
20
24
  # preserving the previously-hardcoded pool size.
21
25
  DEFAULT_WORKER_COUNT = 2
26
+ # Applied after a worker iteration raises, doubling with each consecutive
27
+ # failure up to the cap. Surviving a defect must not mean retrying as fast
28
+ # as the CPU allows: a persistent failure should read as a slow, loud
29
+ # retry, not as a silent hot loop.
30
+ WORKER_BACKOFF_SECONDS = 0.1
31
+ MAX_WORKER_BACKOFF_SECONDS = 30
32
+
33
+ # sc-376: eager, so `queue`/`enqueue_mutex` are never nil for any
34
+ # worker thread. Every real includer (RequestWriter, ResponseWriter,
35
+ # ExceptionWriter, LogWriter) calls `super()` here before
36
+ # `start_threads`, so this always runs first, single-threaded, before
37
+ # any worker exists. An includer whose `initialize` skips `super`
38
+ # (some of this module's own specs, deliberately) never reaches this;
39
+ # `queue`/`enqueue_mutex` fall back to lazy `||=` for those.
40
+ def initialize
41
+ @queue = Queue.new
42
+ @enqueue_mutex = Mutex.new
43
+ end
22
44
 
23
45
  def direct_writer
24
46
  @direct_writer ||= DirectWriter.new(url)
25
47
  end
26
48
 
49
+ # `@queue ||= Queue.new` is a read, a nil check, then a write -- not
50
+ # atomic. Two threads racing this before either assigns could each
51
+ # allocate their own Queue, and the loser's is an orphan nothing will
52
+ # ever read from again. In production only this class's own worker
53
+ # threads could ever race here (every writer is a Singleton whose
54
+ # `initialize`, and so `super()` above, completes before any caller
55
+ # can reach `enqueue`), and `initialize` above now sets `@queue`
56
+ # before `start_threads` spawns a single one of them. The `||=` stays
57
+ # only as a fallback for an includer that skips this module's
58
+ # `initialize` entirely -- see the comment there.
27
59
  def queue
28
60
  @queue ||= Queue.new
29
61
  end
@@ -36,27 +68,7 @@ module EndPointBlank
36
68
  @threads = []
37
69
 
38
70
  worker_count.times do
39
- @threads << Thread.new do
40
- loop do
41
- payload = queue.pop
42
- payloads = [payload]
43
- while (payload = pop_additional)
44
- payloads << payload
45
- end
46
-
47
- payloads.compact!
48
- while payloads.any?
49
- list = payloads[0..5]
50
- response = direct_writer.write(list)
51
- if response.status < 299
52
- on_success(response) if respond_to?(:on_success)
53
- elsif respond_to?(:on_failure)
54
- on_failure(response)
55
- end
56
- payloads -= list
57
- end
58
- end
59
- end
71
+ @threads << Thread.new { run_worker }
60
72
  end
61
73
  end
62
74
 
@@ -78,8 +90,106 @@ module EndPointBlank
78
90
  EndPointBlank.logger.warn(message)
79
91
  end
80
92
 
93
+ # Logs a delivery or worker-loop error, through the same seam. Everything
94
+ # a worker recovers from goes through here: recovering quietly would trade
95
+ # one silent failure for another.
96
+ def log_error(message)
97
+ EndPointBlank.logger.error(message)
98
+ end
99
+
81
100
  private
82
101
 
102
+ # The body of a worker thread.
103
+ #
104
+ # A thread that dies here takes every future payload in the process with
105
+ # it - silently, until a restart - which is far worse than losing the
106
+ # batch in flight. So the loop catches every StandardError, says what it
107
+ # caught, and carries on.
108
+ #
109
+ # What it deliberately does not catch is anything outside StandardError:
110
+ # SystemExit, Interrupt, SignalException, NoMemoryError. Those mean the
111
+ # process itself is going down or is already broken, and a telemetry
112
+ # worker has no business arguing with that.
113
+ def run_worker
114
+ consecutive_failures = 0
115
+
116
+ loop do
117
+ drain_once
118
+ consecutive_failures = 0
119
+ rescue StandardError => e
120
+ consecutive_failures += 1
121
+ note_worker_error(e, consecutive_failures)
122
+ sleep(worker_backoff(consecutive_failures))
123
+ end
124
+ end
125
+
126
+ # Blocks for the next payload, then takes everything else already waiting
127
+ # so a burst leaves as a few batches rather than one request per payload.
128
+ #
129
+ # The batches are cut by position, and that is the point. This used to
130
+ # take a prefix and then remove it with `payloads -= list`, and Array#-
131
+ # removes every element *equal to* one in the batch rather than the ones
132
+ # actually sent: seven byte-identical payloads with a batch size of six
133
+ # meant six delivered and all seven gone. Nothing counted the loss and the
134
+ # queue drained normally, so it read as if nothing had happened.
135
+ #
136
+ # Equal payloads are ordinary, not exotic. Payloads are hashes, and two
137
+ # requests to the same endpoint from the same application in the same
138
+ # environment differ only in high-cardinality fields; `sent_at` at
139
+ # millisecond precision is not a reliable discriminator at ingest volumes.
140
+ # Slicing by index makes equality irrelevant rather than merely handled.
141
+ def drain_once
142
+ payloads = [queue.pop]
143
+ while (payload = pop_additional)
144
+ payloads << payload
145
+ end
146
+
147
+ payloads.compact!
148
+ payloads.each_slice(BATCH_SIZE) { |list| deliver_batch(list) }
149
+ end
150
+
151
+ def deliver_batch(list)
152
+ response = direct_writer.write(list)
153
+ return note_unanswered(list) if response.nil?
154
+
155
+ if response.status < 299
156
+ on_success(response) if respond_to?(:on_success)
157
+ elsif respond_to?(:on_failure)
158
+ on_failure(response)
159
+ end
160
+ end
161
+
162
+ # nil is what Commands::Http returns once its retries are exhausted. It is
163
+ # not a status - it is the absence of an answer - so it is reported rather
164
+ # than compared: on_failure hears about it with nil, meaning "nothing
165
+ # answered", and the batch it cost us is named in the log either way.
166
+ def note_unanswered(list)
167
+ log_error(
168
+ "[EndPointBlank] no response from #{url} after retries; " \
169
+ "#{list.size} payload(s) in that batch are lost"
170
+ )
171
+ on_failure(nil) if respond_to?(:on_failure)
172
+ end
173
+
174
+ def note_worker_error(error, consecutive_failures)
175
+ log_error(
176
+ "[EndPointBlank] send worker recovered from #{error.class}: #{error.message} " \
177
+ "(consecutive failure #{consecutive_failures}); the batch in flight is lost, " \
178
+ "retrying in #{worker_backoff(consecutive_failures).round(1)}s at #{Array(error.backtrace).first}"
179
+ )
180
+ end
181
+
182
+ def worker_backoff(consecutive_failures)
183
+ [
184
+ WORKER_BACKOFF_SECONDS * (2**(consecutive_failures - 1)),
185
+ MAX_WORKER_BACKOFF_SECONDS
186
+ ].min
187
+ end
188
+
189
+ # Same shape and same fallback rationale as `queue` above, and worse in
190
+ # kind if it ever raced: two threads synchronizing on *different* Mutex
191
+ # objects are not synchronized at all, so the critical section in
192
+ # `enqueue_one` would silently stop being one.
83
193
  def enqueue_mutex
84
194
  @enqueue_mutex ||= Mutex.new
85
195
  end
@@ -10,7 +10,7 @@ module EndPointBlank
10
10
  end
11
11
 
12
12
  def write(list)
13
- auth = EndPointBlank::Authorization.header
13
+ auth = EndPointBlank::Authorization.intake_header
14
14
  EndPointBlank::Commands::Http.post(@url, auth, { payload: list })
15
15
  end
16
16
  end
@@ -12,6 +12,7 @@ module EndPointBlank
12
12
  attr_reader :url
13
13
 
14
14
  def initialize
15
+ super()
15
16
  @url = EndPointBlank::Configuration.instance.errors_url
16
17
  start_threads
17
18
  end
@@ -20,10 +21,18 @@ module EndPointBlank
20
21
  instance.write(exception)
21
22
  end
22
23
 
24
+ # Builds an error payload with or without an in-flight request. `env` is
25
+ # nil whenever an exception is reported from outside `EnvStore`'s scope
26
+ # (a background job, a worker, a boot-time failure) -- that is precisely
27
+ # the case this method exists to handle, not an edge case to special-case
28
+ # away. `Rack::Request.new(env)` was built unconditionally here even
29
+ # though nothing about an error report requires a request: version
30
+ # detection (Commands::VersionFinder) reads request params/path and is
31
+ # meaningless without one, so it is skipped rather than attempted and
32
+ # rescued. Its result was never even merged into the returned hash, so
33
+ # skipping it changes nothing for the in-request case either.
23
34
  def payload(exception)
24
35
  env = ::EndPointBlank::Rack::EnvStore.get
25
- request = ::Rack::Request.new(env)
26
- version = Commands::VersionFinder.new.find(request)
27
36
  {
28
37
  app_name: app_name,
29
38
  uuid: request_uuid(env),
@@ -12,6 +12,7 @@ module EndPointBlank
12
12
  attr_reader :url
13
13
 
14
14
  def initialize
15
+ super()
15
16
  @url = EndPointBlank::Configuration.instance.logs_url
16
17
  start_threads
17
18
  end
@@ -61,7 +62,6 @@ module EndPointBlank
61
62
  stamped_http_method: rack_req.request_method
62
63
  )
63
64
  end
64
- puts "Writing log: #{json}"
65
65
  enqueue(json)
66
66
  end
67
67
  end
@@ -12,6 +12,7 @@ module EndPointBlank
12
12
  attr_reader :url
13
13
 
14
14
  def initialize
15
+ super()
15
16
  @url = EndPointBlank::Configuration.instance.requests_url
16
17
  start_threads
17
18
  end
@@ -12,6 +12,7 @@ module EndPointBlank
12
12
  attr_reader :url
13
13
 
14
14
  def initialize
15
+ super()
15
16
  @url = EndPointBlank::Configuration.instance.responses_url
16
17
  start_threads
17
18
  end
@@ -1,8 +1,17 @@
1
+ require "securerandom"
2
+
1
3
  module EndPointBlank
2
4
  module Writers
3
5
  module Shared
4
6
  attr_accessor :url
5
7
 
8
+ # Where a minted request_uuid is cached on the Rack env, when the env
9
+ # carries no request id of its own. Distinct from
10
+ # "action_dispatch.request_id" so a later Rails middleware that sets
11
+ # the real key is never shadowed by ours -- we only ever read that key
12
+ # first and fall back to this one.
13
+ GENERATED_UUID_KEY = "end_point_blank.generated_request_uuid".freeze
14
+
6
15
  def configuration
7
16
  Configuration.instance
8
17
  end
@@ -24,11 +33,33 @@ module EndPointBlank
24
33
  end
25
34
 
26
35
  # Rails' ActionDispatch::Request#uuid simply reads this same Rack env
27
- # key, so this is behavior-preserving when running under Rails, and
28
- # gracefully returns nil when it isn't (plain Rack / Sinatra), instead
29
- # of requiring actionpack's ActionDispatch::Request to be loaded.
36
+ # key, so reading it directly is behavior-preserving when running under
37
+ # Rails, without requiring actionpack's ActionDispatch::Request to be
38
+ # loaded.
39
+ #
40
+ # Outside Rails (plain Rack / Sinatra) nothing sets that key. Falling
41
+ # through to nil there used to be described as "graceful", but it is
42
+ # not: intake requires `uuid` on every error/log/request/response row
43
+ # and refuses the ones that lack it, so a nil uuid is a silently
44
+ # dropped row, not a tolerated one. Minting a fresh uuid instead means
45
+ # the row still lands -- correlating with nothing is strictly better
46
+ # than not existing.
47
+ #
48
+ # The mint is cached back onto `env`, not just returned. RequestWriter
49
+ # and ResponseWriter both call this with the *same* env object for one
50
+ # HTTP request -- EnvStore hands back whatever `set/1` was given at the
51
+ # top of the middleware, unchanged, for the life of the request. Without
52
+ # caching, each writer's call under plain Rack would mint its own fresh
53
+ # uuid, and the request row and the response row for a single
54
+ # interaction would carry two different, unrelated ids -- silently
55
+ # defeating the one thing these columns exist for. When `env` is nil
56
+ # (an exception reported with no request in flight at all) there is no
57
+ # shared object to cache onto and nothing to correlate across, so a
58
+ # standalone mint every time is correct.
30
59
  def request_uuid(env)
31
- env && env["action_dispatch.request_id"]
60
+ return SecureRandom.uuid unless env
61
+
62
+ env["action_dispatch.request_id"] || (env[GENERATED_UUID_KEY] ||= SecureRandom.uuid)
32
63
  end
33
64
 
34
65
  def apply_masking(payload, record_type)
@@ -34,6 +34,9 @@ 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"
39
+ require_relative "end_point_blank/management"
37
40
  if defined?(::Rails)
38
41
  require_relative "end_point_blank/rails/authenticated"
39
42
  require_relative "end_point_blank/rails/authorized"
@@ -44,10 +47,245 @@ end
44
47
  module EndPointBlank
45
48
  class Error < StandardError; end
46
49
 
47
- # Your code goes here...
50
+ # Serializes {EndPointBlank.configure} calls end to end -- both the block
51
+ # and the commit -- so two calls can never overlap. Without this, two
52
+ # calls that both succeed could overlap: the second one can start while
53
+ # the first is still running, so both build their candidate from the same
54
+ # starting snapshot. Whichever one finishes last still decides what to
55
+ # write by diffing its own candidate against that same snapshot, not
56
+ # against whatever {Configuration} holds live by the time it actually
57
+ # commits (see {apply_configure_changes}) -- so its own change still looks
58
+ # like a change relative to its now-stale snapshot, and it writes that
59
+ # value over whatever the other call already committed, with no error.
60
+ # See {EndPointBlank.configure}.
61
+ @configure_mutex = Mutex.new
62
+
63
+ # Applies a block of configuration changes to the shared {Configuration}
64
+ # instance atomically: either every assignment the block makes through
65
+ # its block argument succeeds, or none of them are kept.
66
+ #
67
+ # The block receives a detached copy of the configuration, not the live
68
+ # singleton. Only the fields whose value on that copy differs from an
69
+ # independent deep copy taken before the block ran are written onto the
70
+ # live singleton afterward, and only once the block returns normally.
71
+ # That makes the atomicity structural rather than a rollback: if the
72
+ # block raises -- any exception, not only StandardError -- nothing is
73
+ # written at all. This also covers a field the block sets for the very
74
+ # first time (e.g. client_id on a fresh boot, before
75
+ # {Configuration#initialize} has ever assigned it): the assignment lands
76
+ # on the copy and is discarded with it.
77
+ #
78
+ # The yielded object (c, by convention) is valid only for the duration of
79
+ # the block. Once configure returns -- whether the block returned
80
+ # normally or raised -- it is frozen, and every String, Array or Hash
81
+ # value it holds is first replaced with its own frozen deep copy (see
82
+ # {freeze_candidate}). A write made through a reference to it retained
83
+ # past the block always raises FrozenError: a reassignment
84
+ # (`saved.app_name = "x"`) because the candidate itself is frozen, and an
85
+ # in-place edit (`saved.masking_rules << rule`, `saved.app_name << "x"`,
86
+ # editing a rule Hash in place) because the value it points to is frozen
87
+ # too, not just the candidate. Every field that gets committed is also
88
+ # written as a fresh copy, not the candidate's own object (see
89
+ # {apply_configure_changes}), so the live {Configuration} never ends up
90
+ # aliasing anything the candidate still holds.
91
+ #
92
+ # Inside the block, a read that bypasses the block argument -- e.g.
93
+ # Configuration.instance.app_name, or EndPointBlank.logger right after
94
+ # `c.logger = new_logger` earlier in the same block -- still returns the
95
+ # value from before this configure call started, not what the block has
96
+ # set on c so far: nothing is written to the live singleton until the
97
+ # block returns normally and the commit runs.
98
+ #
99
+ # Committing only the fields the block actually changed, rather than
100
+ # every field, matters because the live singleton can change out from
101
+ # under a configure call that never touches a given field -- a direct
102
+ # `Configuration.instance.some_field = ...` outside configure, or
103
+ # `EndPointBlank.logger=`, is not covered by {@configure_mutex}. Writing
104
+ # every field back unconditionally would silently revert that kind of
105
+ # concurrent change as soon as this call's block returned, even though
106
+ # this call never touched the field itself.
107
+ #
108
+ # String, Array and Hash values -- including {Configuration#masking_rules}
109
+ # and the Hashes in it -- are deep-copied, so an in-place edit
110
+ # (`c.masking_rules.first[:regex] << "|.*"`, `c.app_name << "-staging"`,
111
+ # `c.masking_rules << rule`) changes only the block's copy, never the
112
+ # live value, unless and until that field is committed. Assigning one of
113
+ # these fields through c copies the assigned value too, rather than
114
+ # storing the object itself: after `c.masking_rules = rules`, mutating
115
+ # the `rules` array the caller passed in no longer reaches the live
116
+ # config, whether that mutation happens while the block is still running
117
+ # or afterward. Objects the caller owns and hands in by reference --
118
+ # {Configuration#logger}, {Configuration#mask_hook},
119
+ # {Configuration#version_finder} -- are not String/Array/Hash, so they
120
+ # are copied by reference like any other field, both while the block
121
+ # runs and afterward through a retained c; EndPointBlank.configure
122
+ # cannot and does not roll back mutation the caller performs on those
123
+ # objects themselves, whether through c or directly.
124
+ #
125
+ # This is generic over every field {Configuration} has now or gains
126
+ # later (including a future sc-1265 cache_ttl upper bound): it copies
127
+ # every instance variable, so no new setter needs to be added here for
128
+ # its validation to be atomic.
129
+ #
130
+ # Calls are serialized with a module-level Mutex held across both the
131
+ # block and the commit (see {@configure_mutex}), so two calls from
132
+ # different threads can never interleave their reads and writes: the
133
+ # second one always starts from whatever the first one left behind,
134
+ # whether the first succeeded or raised. The lock only orders
135
+ # configure-against-configure; a reader elsewhere that is not going
136
+ # through configure can still observe the commit loop's writes one field
137
+ # at a time while it runs.
138
+ #
139
+ # Because Ruby's Mutex is not reentrant, calling EndPointBlank.configure
140
+ # again from inside a configure block, on the same thread, raises {Error}
141
+ # rather than running -- configure blocks are not meant to nest. A block
142
+ # that starts a different thread, has that thread call configure, and
143
+ # then joins it will hang instead of raising, since that thread is
144
+ # genuinely waiting on a lock this thread holds.
145
+ #
146
+ # @raise whatever the block raises, or {Error} if called while a
147
+ # configure call is already in progress on the same thread; the live
148
+ # configuration is left exactly as it was before the call
48
149
  def self.configure(&block)
49
- yield Configuration.instance
150
+ raise Error, "EndPointBlank.configure cannot be called from inside a configure block" if @configure_mutex.owned?
151
+
152
+ @configure_mutex.synchronize { configure_and_commit(&block) }
153
+ end
154
+
155
+ # Builds the candidate and comparison snapshot for the current live
156
+ # +config+, runs +block+ against the candidate, and commits the result --
157
+ # freezing the candidate once the block returns, whether it succeeded or
158
+ # raised (see {EndPointBlank.configure}). Must only be called while
159
+ # {@configure_mutex} is held.
160
+ def self.configure_and_commit(&block)
161
+ config = Configuration.instance
162
+ original = configure_snapshot_for(config)
163
+ candidate = configure_candidate_for(config)
164
+
165
+ begin
166
+ block.call(candidate)
167
+ apply_configure_changes(config, original, candidate)
168
+ ensure
169
+ freeze_candidate(candidate)
170
+ end
171
+ end
172
+ private_class_method :configure_and_commit
173
+
174
+ # Deep-copies every current instance variable of +config+ into a Hash
175
+ # keyed by ivar name (see {configure_deep_dup}), so the result shares no
176
+ # mutable object with +config+. {EndPointBlank.configure} calls this
177
+ # twice per call -- once directly, for the comparison snapshot, and once
178
+ # more via {configure_candidate_for}, for the copy the block mutates --
179
+ # so an in-block edit to one can never be mistaken for "unchanged" by
180
+ # comparing it back to the other.
181
+ def self.configure_snapshot_for(config)
182
+ config.instance_variables.each_with_object({}) do |ivar, memo|
183
+ memo[ivar] = configure_deep_dup(config.instance_variable_get(ivar))
184
+ end
185
+ end
186
+ private_class_method :configure_snapshot_for
187
+
188
+ # Recursively duplicates plain data (String, Array, Hash). Every other
189
+ # value -- Integer, Symbol, true/false/nil, and an object the caller owns
190
+ # and handed in by reference such as {Configuration#logger},
191
+ # {Configuration#mask_hook} or {Configuration#version_finder} -- is
192
+ # returned as-is, since none of those can be edited in place through
193
+ # {Configuration}'s documented API the way a String or a rule Hash can.
194
+ def self.configure_deep_dup(value)
195
+ case value
196
+ when String then value.dup
197
+ when Array then value.map { |element| configure_deep_dup(element) }
198
+ when Hash
199
+ value.each_with_object({}) { |(k, v), memo| memo[configure_deep_dup(k)] = configure_deep_dup(v) }
200
+ else
201
+ value
202
+ end
203
+ end
204
+ private_class_method :configure_deep_dup
205
+
206
+ # Recursively duplicates and freezes plain data (String, Array, Hash), the
207
+ # same shape {configure_deep_dup} walks. The copy is built first and
208
+ # frozen after, so this never freezes +value+ itself, only the new copy --
209
+ # a caller who still holds +value+ (e.g. the Array they passed to
210
+ # `c.masking_rules = rules`) keeps a fully mutable object; only the copy
211
+ # {freeze_candidate} puts on the frozen candidate is locked. Every other
212
+ # value -- Integer, Symbol, true/false/nil, and an object the caller owns
213
+ # and handed in by reference such as {Configuration#logger},
214
+ # {Configuration#mask_hook} or {Configuration#version_finder} -- is
215
+ # returned as-is, unfrozen, exactly as {configure_deep_dup} leaves it.
216
+ def self.configure_deep_freeze(value)
217
+ case value
218
+ when String then value.dup.freeze
219
+ when Array then value.map { |element| configure_deep_freeze(element) }.freeze
220
+ when Hash
221
+ value.each_with_object({}) do |(k, v), memo|
222
+ memo[configure_deep_freeze(k)] = configure_deep_freeze(v)
223
+ end.freeze
224
+ else
225
+ value
226
+ end
227
+ end
228
+ private_class_method :configure_deep_freeze
229
+
230
+ # Builds the detached copy {EndPointBlank.configure} yields to its block:
231
+ # a bare {Configuration} instance -- built with
232
+ # `Configuration.send(:allocate)` since {Configuration} is a Singleton
233
+ # and its `.new`/`.allocate` are private -- carrying its own independent
234
+ # deep copy of +config+'s current ivars, so it stays a private scratch
235
+ # object, not a second singleton, and mutating it can never reach
236
+ # +config+.
237
+ def self.configure_candidate_for(config)
238
+ candidate = Configuration.send(:allocate)
239
+ configure_snapshot_for(config).each { |ivar, value| candidate.instance_variable_set(ivar, value) }
240
+ candidate
241
+ end
242
+ private_class_method :configure_candidate_for
243
+
244
+ # Locks down +candidate+ once the block is done with it (see
245
+ # {configure_and_commit}). Freezing +candidate+ alone only blocks a
246
+ # *reassignment* through a reference to it retained past the block
247
+ # (`saved.app_name = "x"`); it does nothing to the Array, Hash or String
248
+ # objects its ivars point to, so an in-place edit through that same
249
+ # reference (`saved.masking_rules << rule`, `saved.app_name << "x"`,
250
+ # editing a rule Hash in place) would return normally and silently never
251
+ # reach the live config -- the same failure {apply_configure_changes}
252
+ # committing a fresh copy already prevents, one level down. Each ivar is
253
+ # therefore replaced with its own {configure_deep_freeze} copy before
254
+ # +candidate+ itself is frozen, so every one of those in-place edits
255
+ # raises FrozenError too. This replaces the ivar's value on +candidate+,
256
+ # not on the value itself: the object the caller handed to +candidate+
257
+ # (e.g. via `c.masking_rules = rules`) is never frozen in place, so it
258
+ # stays fully mutable for the caller's own further use.
259
+ def self.freeze_candidate(candidate)
260
+ candidate.instance_variables.each do |ivar|
261
+ candidate.instance_variable_set(ivar, configure_deep_freeze(candidate.instance_variable_get(ivar)))
262
+ end
263
+ candidate.freeze
264
+ end
265
+ private_class_method :freeze_candidate
266
+
267
+ # Writes onto the live +config+ only the ivars whose value on +candidate+
268
+ # differs from +original+, the independent deep copy taken before the
269
+ # block ran. A field the block never touched is left exactly as +config+
270
+ # has it now, even if something else changed it while the block was
271
+ # running. Only reached after the block has returned normally.
272
+ #
273
+ # Commits a fresh {configure_deep_dup} of the value, not +candidate+'s own
274
+ # object: when the block assigns a String, Array or Hash straight through
275
+ # (`c.masking_rules = mine`), +candidate+'s ivar *is* the caller's own
276
+ # object at this point -- {freeze_candidate} has not run yet -- so
277
+ # committing it as-is would make the live +config+ literally the same
278
+ # object as +mine+, and the caller mutating +mine+ afterward (`mine <<
279
+ # rule`, no retained +candidate+ needed at all) would reach +config+
280
+ # directly -- bypassing {@configure_mutex} and any validation entirely,
281
+ # silently.
282
+ def self.apply_configure_changes(config, original, candidate)
283
+ candidate.instance_variables.each do |ivar|
284
+ value = candidate.instance_variable_get(ivar)
285
+ config.instance_variable_set(ivar, configure_deep_dup(value)) unless value == original[ivar]
286
+ end
50
287
  end
288
+ private_class_method :apply_configure_changes
51
289
 
52
290
  # Defaults to stderr, not stdout. This logger belongs to a library running
53
291
  # 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.13.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,24 @@ 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
123
+ - lib/end_point_blank/management.rb
124
+ - lib/end_point_blank/management/client.rb
125
+ - lib/end_point_blank/management/configuration.rb
126
+ - lib/end_point_blank/management/error.rb
127
+ - lib/end_point_blank/management/error_codes.rb
128
+ - lib/end_point_blank/management/idempotency_key.rb
129
+ - lib/end_point_blank/management/page.rb
130
+ - lib/end_point_blank/management/resources/api_packages.rb
131
+ - lib/end_point_blank/management/resources/applications.rb
132
+ - lib/end_point_blank/management/resources/base.rb
133
+ - lib/end_point_blank/management/resources/clients.rb
134
+ - lib/end_point_blank/management/retry_policy.rb
135
+ - lib/end_point_blank/management/transport.rb
136
+ - lib/end_point_blank/management/url_path.rb
122
137
  - lib/end_point_blank/masking.rb
123
138
  - lib/end_point_blank/middleware/rack/report_interaction.rb
124
139
  - lib/end_point_blank/rack/env_store.rb
@@ -129,6 +144,8 @@ files:
129
144
  - lib/end_point_blank/rails/versioned.rb
130
145
  - lib/end_point_blank/session_configuration.rb
131
146
  - lib/end_point_blank/string_truncator.rb
147
+ - lib/end_point_blank/target_url.rb
148
+ - lib/end_point_blank/token_unavailable_error.rb
132
149
  - lib/end_point_blank/unauthorized_error.rb
133
150
  - lib/end_point_blank/version.rb
134
151
  - lib/end_point_blank/writers/delayed_writer.rb
@@ -141,13 +158,15 @@ files:
141
158
  - lib/end_point_blank/xml_truncator.rb
142
159
  - sig/end_point_blank_rack.rbs
143
160
  - test.sh
144
- homepage: https://github.com/EndPointBlank/end_point_blank_rails
161
+ homepage: https://endpointblank.com
145
162
  licenses:
146
163
  - Nonstandard
147
164
  metadata:
148
165
  allowed_push_host: https://rubygems.org
149
- homepage_uri: https://github.com/EndPointBlank/end_point_blank_rails
166
+ homepage_uri: https://endpointblank.com
150
167
  source_code_uri: https://github.com/EndPointBlank/end_point_blank_rails
168
+ documentation_uri: https://endpointblank.com/docs/sdk-setup
169
+ bug_tracker_uri: https://github.com/EndPointBlank/end_point_blank_rails/issues
151
170
  changelog_uri: https://github.com/EndPointBlank/end_point_blank_rails/releases
152
171
  rdoc_options: []
153
172
  require_paths:
@@ -165,6 +184,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
165
184
  requirements: []
166
185
  rubygems_version: 3.6.2
167
186
  specification_version: 4
168
- summary: Ruby/Rails client for EndPointBlank — endpoint tracking, authorization, and
169
- error/request/response/log reporting.
187
+ summary: 'Ruby and Rails SDK for EndPointBlank: authorize service-to-service API calls,
188
+ report endpoint versions, and see which clients still call deprecated API versions.'
170
189
  test_files: []