end_point_blank 0.2.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 (49) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +13 -0
  4. data/.ruby-version +1 -0
  5. data/README.md +64 -0
  6. data/Rakefile +12 -0
  7. data/build.sh +5 -0
  8. data/docs/superpowers/plans/2026-07-08-framework-agnostic-core.md +68 -0
  9. data/docs/superpowers/specs/2026-07-08-framework-agnostic-core-design.md +58 -0
  10. data/end_point_blank.gemspec +45 -0
  11. data/lib/end_point_blank/access_tokens.rb +72 -0
  12. data/lib/end_point_blank/authorization.rb +36 -0
  13. data/lib/end_point_blank/commands/authentication_cache.rb +95 -0
  14. data/lib/end_point_blank/commands/basic_authenticate.rb +47 -0
  15. data/lib/end_point_blank/commands/bearer_generate.rb +33 -0
  16. data/lib/end_point_blank/commands/endpoint_authorize.rb +71 -0
  17. data/lib/end_point_blank/commands/endpoint_update.rb +129 -0
  18. data/lib/end_point_blank/commands/generate_access_token.rb +46 -0
  19. data/lib/end_point_blank/commands/http.rb +45 -0
  20. data/lib/end_point_blank/commands/route_pattern_finder.rb +21 -0
  21. data/lib/end_point_blank/commands/version_finder.rb +70 -0
  22. data/lib/end_point_blank/configuration.rb +107 -0
  23. data/lib/end_point_blank/fast_json_truncator.rb +49 -0
  24. data/lib/end_point_blank/log_entry.rb +16 -0
  25. data/lib/end_point_blank/loggers/logger.rb +30 -0
  26. data/lib/end_point_blank/masking.rb +282 -0
  27. data/lib/end_point_blank/middleware/rack/report_interaction.rb +54 -0
  28. data/lib/end_point_blank/rack/env_store.rb +34 -0
  29. data/lib/end_point_blank/rack/headers.rb +11 -0
  30. data/lib/end_point_blank/rails/authenticated.rb +21 -0
  31. data/lib/end_point_blank/rails/authorized.rb +33 -0
  32. data/lib/end_point_blank/rails/railtie.rb +18 -0
  33. data/lib/end_point_blank/rails/versioned.rb +64 -0
  34. data/lib/end_point_blank/session_configuration.rb +44 -0
  35. data/lib/end_point_blank/string_truncator.rb +21 -0
  36. data/lib/end_point_blank/unauthorized_error.rb +10 -0
  37. data/lib/end_point_blank/version.rb +5 -0
  38. data/lib/end_point_blank/writers/delayed_writer.rb +110 -0
  39. data/lib/end_point_blank/writers/direct_writer.rb +18 -0
  40. data/lib/end_point_blank/writers/exception_writer.rb +50 -0
  41. data/lib/end_point_blank/writers/log_writer.rb +69 -0
  42. data/lib/end_point_blank/writers/request_writer.rb +61 -0
  43. data/lib/end_point_blank/writers/response_writer.rb +70 -0
  44. data/lib/end_point_blank/writers/shared.rb +39 -0
  45. data/lib/end_point_blank/xml_truncator.rb +89 -0
  46. data/lib/end_point_blank.rb +57 -0
  47. data/sig/end_point_blank_rack.rbs +4 -0
  48. data/test.sh +5 -0
  49. metadata +163 -0
@@ -0,0 +1,110 @@
1
+ require 'singleton'
2
+
3
+ module EndPointBlank
4
+ module Writers
5
+ # Drains a background send queue with a fixed pool of worker threads.
6
+ #
7
+ # The queue is bounded (see MAX_QUEUE_SIZE) so that during an intake
8
+ # outage - when the worker threads can't drain as fast as the host
9
+ # application enqueues payloads - memory usage stays capped instead of
10
+ # growing without bound toward an OOM. When the queue is full, the
11
+ # *oldest* item is dropped to make room for the newest one, and a
12
+ # warning is logged, throttled so a sustained outage does not itself
13
+ # become a logging flood.
14
+ module DelayedWriter
15
+ MAX_QUEUE_SIZE = 1000
16
+ WARN_THROTTLE_SECONDS = 30
17
+
18
+ def direct_writer
19
+ @direct_writer ||= DirectWriter.new(url)
20
+ end
21
+
22
+ def queue
23
+ @queue ||= Queue.new
24
+ end
25
+
26
+ def start_threads
27
+ @threads = []
28
+
29
+ 2.times do
30
+ @threads << Thread.new do
31
+ loop do
32
+ payload = queue.pop
33
+ payloads = [payload]
34
+ while (payload = pop_additional) do
35
+ payloads << payload
36
+ end
37
+
38
+ payloads.compact!
39
+ while payloads.any? do
40
+ list = payloads[0..5]
41
+ response = direct_writer.write(list)
42
+ if response.status < 299
43
+ on_success(response) if respond_to?(:on_success)
44
+ else
45
+ on_failure(response) if respond_to?(:on_failure)
46
+ end
47
+ payloads = payloads - list
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+
54
+ def pop_additional
55
+ queue.pop(true)
56
+ rescue ThreadError
57
+ nil
58
+ end
59
+
60
+ def enqueue(list)
61
+ items = list.is_a?(Array) ? list : [list]
62
+ items.each { |payload| enqueue_one(payload) }
63
+ end
64
+
65
+ # Logs a drop warning. Overridable/stubbable in tests; routed through
66
+ # the pluggable EndPointBlank.logger seam (this lib is fire-and-forget
67
+ # and must never raise).
68
+ def log_warning(message)
69
+ EndPointBlank.logger.warn(message)
70
+ end
71
+
72
+ private
73
+
74
+ def enqueue_mutex
75
+ @enqueue_mutex ||= Mutex.new
76
+ end
77
+
78
+ def enqueue_one(payload)
79
+ enqueue_mutex.synchronize do
80
+ drop_oldest_and_note if queue.size >= MAX_QUEUE_SIZE
81
+ queue << payload
82
+ end
83
+ end
84
+
85
+ def drop_oldest_and_note
86
+ pop_additional
87
+ note_drop
88
+ end
89
+
90
+ def note_drop
91
+ @dropped_since_last_warning = (@dropped_since_last_warning || 0) + 1
92
+ now = Time.now
93
+ return if @last_drop_warning_at && (now - @last_drop_warning_at) < WARN_THROTTLE_SECONDS
94
+
95
+ warn_dropped_items(now)
96
+ end
97
+
98
+ def warn_dropped_items(now)
99
+ dropped = @dropped_since_last_warning
100
+ @dropped_since_last_warning = 0
101
+ @last_drop_warning_at = now
102
+
103
+ log_warning(
104
+ "[EndPointBlank] send queue full (max #{MAX_QUEUE_SIZE}); " \
105
+ "dropped #{dropped} oldest item(s) since last warning"
106
+ )
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,18 @@
1
+ require_relative '../commands/http'
2
+
3
+ module EndPointBlank
4
+ module Writers
5
+ class DirectWriter
6
+ attr_accessor :url
7
+
8
+ def initialize(url)
9
+ @url = url
10
+ end
11
+
12
+ def write(list)
13
+ auth = EndPointBlank::Authorization.header
14
+ EndPointBlank::Commands::Http.post(@url, auth, { payload: list })
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "singleton"
4
+
5
+ module EndPointBlank
6
+ module Writers
7
+ class ExceptionWriter
8
+ include Singleton
9
+ include DelayedWriter
10
+ include Shared
11
+
12
+ attr_reader :url
13
+
14
+ def initialize
15
+ @url = EndPointBlank::Configuration.instance.errors_url
16
+ start_threads
17
+ end
18
+
19
+ def self.write(exception)
20
+ instance.write(exception)
21
+ end
22
+
23
+ def payload(exception)
24
+ env = ::EndPointBlank::Rack::EnvStore.get
25
+ request = ::Rack::Request.new(env)
26
+ version = Commands::VersionFinder.new.find(request)
27
+ {
28
+ app_name: app_name,
29
+ uuid: request_uuid(env),
30
+ message: exception.message,
31
+ stacktrace: exception.backtrace,
32
+ sent_at: Time.now.utc.iso8601(3),
33
+ source_application_environment_id: source_application_environment_id
34
+ }
35
+ end
36
+
37
+ def write(exception)
38
+ p = payload(exception)
39
+ rack_req = ::EndPointBlank::Rack::EnvStore.request
40
+ if rack_req
41
+ p = p.merge(
42
+ stamped_path: rack_req.path,
43
+ stamped_http_method: rack_req.request_method
44
+ )
45
+ end
46
+ enqueue(apply_masking(p, :error))
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "singleton"
4
+
5
+ module EndPointBlank
6
+ module Writers
7
+ class LogWriter
8
+ include Singleton
9
+ include DelayedWriter
10
+ include Shared
11
+
12
+ attr_reader :url
13
+
14
+ def initialize
15
+ @url = EndPointBlank::Configuration.instance.logs_url
16
+ start_threads
17
+ end
18
+
19
+ def self.info(message, data = {})
20
+ instance.write(message, :info, data)
21
+ end
22
+
23
+ def self.warn(message, data = {})
24
+ instance.write(message, :warn, data)
25
+ end
26
+
27
+ def self.error(message, data = {})
28
+ instance.write(message, :error, data)
29
+ end
30
+
31
+ def self.fatal(message, data = {})
32
+ instance.write(message, :fatal, data)
33
+ end
34
+
35
+ def self.write(message, level, data = {})
36
+ instance.write(message, level, data)
37
+ end
38
+
39
+ def payload(message: message, level: level, data: data)
40
+ env = ::EndPointBlank::Rack::EnvStore.get
41
+ uuid = request_uuid(env)
42
+
43
+ {
44
+ message: message,
45
+ log_level: level,
46
+ sent_at: Time.now.utc.iso8601(3),
47
+ app_name: app_name,
48
+ uuid: uuid,
49
+ data: data,
50
+ source_application_environment_id: source_application_environment_id
51
+ }
52
+ end
53
+
54
+
55
+ def write(message, level, data = {})
56
+ json = payload(message: message, level: level, data: data)
57
+ rack_req = ::EndPointBlank::Rack::EnvStore.request
58
+ if rack_req
59
+ json = json.merge(
60
+ stamped_path: rack_req.path,
61
+ stamped_http_method: rack_req.request_method
62
+ )
63
+ end
64
+ puts "Writing log: #{json}"
65
+ enqueue(json)
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "singleton"
4
+
5
+ module EndPointBlank
6
+ module Writers
7
+ class RequestWriter
8
+ include Singleton
9
+ include DelayedWriter
10
+ include Shared
11
+
12
+ attr_reader :url
13
+
14
+ def initialize
15
+ @url = EndPointBlank::Configuration.instance.requests_url
16
+ start_threads
17
+ end
18
+
19
+ def self.write(data = {})
20
+ instance.write()
21
+ end
22
+
23
+ def payload
24
+ env = ::EndPointBlank::Rack::EnvStore.get
25
+ request = ::Rack::Request.new(env)
26
+ version = Commands::VersionFinder.new.find(request)
27
+ headers = ::EndPointBlank::Rack::Headers.extract
28
+
29
+ payload = {
30
+ app_name: EndPointBlank::Configuration.instance.app_name,
31
+ env: SessionConfiguration.env_name,
32
+ uuid: request_uuid(env),
33
+ host: request.host,
34
+ status: request_status(request),
35
+ headers: headers,
36
+ path: request.path,
37
+ http_method: request.request_method,
38
+ endpoint_version: version,
39
+ request: request_body(request),
40
+ sent_at: Time.now.utc.iso8601(3)
41
+ }
42
+ end
43
+
44
+ def write()
45
+ enqueue(apply_masking(payload, :request))
46
+ end
47
+
48
+ private
49
+
50
+ def request_status(request)
51
+ request.respond_to?(:status) ? request.status : nil
52
+ end
53
+
54
+ def request_body(request)
55
+ body = request.body&.read
56
+ request.body.rewind if request.body&.respond_to?(:rewind)
57
+ body
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "singleton"
4
+
5
+ module EndPointBlank
6
+ module Writers
7
+ class ResponseWriter
8
+ include Singleton
9
+ include DelayedWriter
10
+ include Shared
11
+
12
+ attr_reader :url
13
+
14
+ def initialize
15
+ @url = EndPointBlank::Configuration.instance.responses_url
16
+ start_threads
17
+ end
18
+
19
+ def self.write(status:, headers: {}, body: nil, data: {})
20
+ instance.write(status:, headers:, body:, data:)
21
+ end
22
+
23
+ def payload(status:, headers:, body:, data: {})
24
+ request = ::EndPointBlank::Rack::EnvStore.request
25
+ env = ::EndPointBlank::Rack::EnvStore.get
26
+ headers = ::EndPointBlank::Rack::Headers.extract
27
+ version = request ? Commands::VersionFinder.new.find(request) : nil
28
+ route = request ? Commands::RoutePatternFinder.find(request) : nil
29
+
30
+ {
31
+ app_name: app_name,
32
+ env: env_name,
33
+ uuid: request_uuid(env),
34
+ status: status,
35
+ headers: headers,
36
+ body: truncate(normalize_body(body)),
37
+ sent_at: Time.now.utc.iso8601(3),
38
+ route: route,
39
+ data: data,
40
+ source_application_environment_id: source_application_environment_id
41
+ }
42
+ end
43
+
44
+ def write(status:, headers: {}, body: nil, data: {})
45
+ enqueue(apply_masking(payload(status:, headers:, body:, data:), :response))
46
+ end
47
+
48
+ private
49
+ def truncate(body)
50
+ return body if body.nil? || body.length <= 1024
51
+
52
+ body[0...1024] + "..."
53
+ end
54
+
55
+ def normalize_body(body)
56
+ return nil if body.nil?
57
+
58
+ if body.respond_to?(:body) && !body.is_a?(Array)
59
+ body.body
60
+ elsif body.respond_to?(:each)
61
+ result = +""
62
+ body.each { |chunk| result << chunk }
63
+ result
64
+ else
65
+ body.to_s
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,39 @@
1
+ module EndPointBlank
2
+ module Writers
3
+ module Shared
4
+ attr_accessor :url
5
+
6
+ def configuration
7
+ Configuration.instance
8
+ end
9
+
10
+ def app_name
11
+ configuration.app_name
12
+ end
13
+
14
+ def env_name
15
+ SessionConfiguration.env_name
16
+ end
17
+
18
+ def env
19
+ ::EndPointBlank::Rack::EnvStore.get
20
+ end
21
+
22
+ def source_application_environment_id
23
+ ::EndPointBlank::Rack::EnvStore.source_application_environment_id
24
+ end
25
+
26
+ # 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.
30
+ def request_uuid(env)
31
+ env && env["action_dispatch.request_id"]
32
+ end
33
+
34
+ def apply_masking(payload, record_type)
35
+ EndPointBlank::Masking.apply(payload, record_type, configuration.masking_rules, configuration.mask_hook)
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,89 @@
1
+ require "rexml/document"
2
+ require "rexml/formatters/default"
3
+
4
+ class XmlTruncator
5
+ MAX_BYTES = 10000
6
+ MAX_DEPTH = 6
7
+ MAX_CHILDREN = 20
8
+ MAX_ATTRIBUTES = 20
9
+ MAX_TEXT = 200
10
+ TEXT_SUFFIX = "..."
11
+
12
+ def self.truncate(xml, limit: MAX_BYTES)
13
+ input = xml.to_s
14
+ return "" if input.empty?
15
+ return input if input.bytesize <= limit
16
+
17
+ document = parse_xml(input)
18
+ return StringTruncator.truncate(input, limit:, suffix: "<truncated/>") unless document&.root
19
+
20
+ pruned_root = prune_element(document.root, depth: 0)
21
+ output = render_element(pruned_root)
22
+ return output if output.bytesize <= limit
23
+
24
+ compact = compact_fallback(document.root.expanded_name)
25
+ return compact if compact.bytesize <= limit
26
+
27
+ "<truncated/>"
28
+ end
29
+
30
+ def self.parse_xml(input)
31
+ REXML::Document.new(input)
32
+ rescue REXML::ParseException
33
+ nil
34
+ end
35
+
36
+ def self.prune_element(element, depth:)
37
+ pruned = REXML::Element.new(element.expanded_name)
38
+
39
+ element.attributes.each_with_index do |(name, value), idx|
40
+ break if idx >= MAX_ATTRIBUTES
41
+ pruned.add_attribute(name, truncate_text(value, max: 100))
42
+ end
43
+
44
+ if depth >= MAX_DEPTH
45
+ pruned.add_element("truncated")
46
+ return pruned
47
+ end
48
+
49
+ element_count = 0
50
+
51
+ element.children.each do |child|
52
+ case child
53
+ when REXML::Element
54
+ if element_count >= MAX_CHILDREN
55
+ pruned.add_element("truncated")
56
+ break
57
+ end
58
+
59
+ pruned.add_element(prune_element(child, depth: depth + 1))
60
+ element_count += 1
61
+ when REXML::CData
62
+ pruned.add(REXML::CData.new(truncate_text(child.value)))
63
+ when REXML::Text
64
+ text = truncate_text(child.value)
65
+ pruned.add_text(text) unless text.empty?
66
+ end
67
+ end
68
+
69
+ pruned
70
+ end
71
+
72
+ def self.render_element(element)
73
+ formatter = REXML::Formatters::Default.new
74
+ output = +""
75
+ formatter.write(element, output)
76
+ output
77
+ end
78
+
79
+ def self.compact_fallback(root_name)
80
+ "<#{root_name}><truncated/></#{root_name}>"
81
+ end
82
+
83
+ def self.truncate_text(text, max: MAX_TEXT)
84
+ value = text.to_s
85
+ return value if value.bytesize <= max
86
+
87
+ value.byteslice(0, max - TEXT_SUFFIX.bytesize) + TEXT_SUFFIX
88
+ end
89
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+ require "rack"
5
+
6
+ require_relative "end_point_blank/access_tokens"
7
+ require_relative "end_point_blank/authorization"
8
+ require_relative "end_point_blank/version"
9
+ require_relative "end_point_blank/configuration"
10
+ require_relative "end_point_blank/session_configuration"
11
+ require_relative "end_point_blank/log_entry"
12
+ require_relative "end_point_blank/string_truncator"
13
+ require_relative "end_point_blank/fast_json_truncator"
14
+ require_relative "end_point_blank/xml_truncator"
15
+ require_relative "end_point_blank/masking"
16
+ require_relative "end_point_blank/writers/shared"
17
+ require_relative "end_point_blank/writers/delayed_writer"
18
+ require_relative "end_point_blank/writers/direct_writer"
19
+ require_relative "end_point_blank/writers/log_writer"
20
+ require_relative "end_point_blank/writers/exception_writer"
21
+ require_relative "end_point_blank/writers/request_writer"
22
+ require_relative "end_point_blank/writers/response_writer"
23
+ require_relative "end_point_blank/commands/generate_access_token"
24
+ require_relative "end_point_blank/commands/authentication_cache"
25
+ require_relative "end_point_blank/commands/basic_authenticate"
26
+ require_relative "end_point_blank/commands/bearer_generate"
27
+ require_relative "end_point_blank/commands/endpoint_authorize"
28
+ require_relative "end_point_blank/commands/route_pattern_finder"
29
+ require_relative "end_point_blank/commands/endpoint_update"
30
+ require_relative "end_point_blank/commands/version_finder"
31
+ require_relative "end_point_blank/middleware/rack/report_interaction"
32
+ require_relative "end_point_blank/rack/env_store"
33
+ require_relative "end_point_blank/rack/headers"
34
+ require_relative "end_point_blank/unauthorized_error"
35
+ if defined?(::Rails)
36
+ require_relative "end_point_blank/rails/authenticated"
37
+ require_relative "end_point_blank/rails/authorized"
38
+ require_relative "end_point_blank/rails/versioned"
39
+ require_relative "end_point_blank/rails/railtie"
40
+ end
41
+
42
+ module EndPointBlank
43
+ class Error < StandardError; end
44
+
45
+ # Your code goes here...
46
+ def self.configure(&block)
47
+ yield Configuration.instance
48
+ end
49
+
50
+ def self.logger
51
+ Configuration.instance.logger || (@default_logger ||= ::Logger.new($stdout, level: ::Logger::INFO))
52
+ end
53
+
54
+ def self.logger=(logger)
55
+ Configuration.instance.logger = logger
56
+ end
57
+ end
@@ -0,0 +1,4 @@
1
+ module EndPointBlankRack
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
data/test.sh ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env bash
2
+ # Run the test suite for end_point_blank (Ruby gem).
3
+ set -euo pipefail
4
+ cd "$(dirname "$0")"
5
+ exec bundle exec rspec "$@"