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,129 @@
1
+ #!/bin/ruby
2
+
3
+ require 'excon'
4
+ require_relative 'http'
5
+
6
+ module EndPointBlank
7
+ module Commands
8
+ # Collects and sends application endpoint information to a remote registry service.
9
+ # Scans all Rails routes and extracts details including path, HTTP verb, API version,
10
+ # and deprecation status from controller versioning concerns. Sends this data along
11
+ # with application metadata (name, hostname, environment) to the configured endpoint_update_url
12
+ # for centralized endpoint tracking and documentation.
13
+ class EndpointUpdate
14
+ def initialize
15
+ end
16
+
17
+ def configuration
18
+ EndPointBlank::Configuration.instance
19
+ end
20
+
21
+ def auth
22
+ Authorization.header
23
+ end
24
+
25
+ def write(data)
26
+ EndPointBlank.logger.info "[EndPointBlank] Sending application update: " \
27
+ "application=#{data[:application]} environment=#{data[:environment]} " \
28
+ "app_version=#{data[:app_version]}"
29
+
30
+ response = Excon.post(configuration.endpoint_update_url,
31
+ headers: {'Authorization' => auth, 'Content-Type' => 'application/json'},
32
+ body: data.to_json,
33
+ **EndPointBlank::Commands::Http::TIMEOUT_OPTIONS
34
+ )
35
+ if response.status > 299
36
+ EndPointBlank.logger.error "Failed to update endpoint: #{response.status} - #{response.body}"
37
+ else
38
+ EndPointBlank.logger.info "Endpoint updated successfully: #{response.status}"
39
+ end
40
+ rescue Excon::Error => e
41
+ # Was `rescue Excon::Error::Socket, Excon::Error::Connection` -
42
+ # Excon::Error::Connection does not exist in this excon version, so
43
+ # that clause raised NameError instead of catching anything, and a
44
+ # timeout (Excon::Error::Timeout) would have crashed the caller.
45
+ # Rescuing Excon::Error catches every transport failure, including
46
+ # timeouts, without letting this fire-and-forget call ever raise.
47
+ EndPointBlank.logger.warn "EndPointBlank: could not reach intake to update endpoints (#{e.message})"
48
+ end
49
+
50
+ def self.update
51
+ new.update
52
+ end
53
+
54
+ def update
55
+ write(application_info)
56
+ end
57
+
58
+ def application_info
59
+ {
60
+ application: application_name,
61
+ hostname: hostname,
62
+ lib_version: EndPointBlank::VERSION,
63
+ environment: environment,
64
+ endpoints: ,
65
+ app_version: ,
66
+ }
67
+ end
68
+
69
+ private
70
+
71
+ def app_version
72
+ return configuration.application_version if configuration.respond_to?(:application_version) && configuration.application_version.present?
73
+
74
+ nil
75
+ end
76
+
77
+ def application_name
78
+ ::Rails.application.class.module_parent_name.underscore
79
+ end
80
+
81
+ def hostname
82
+ # Try to get hostname from various sources
83
+ if defined?(::Rails) && ::Rails.application.config.respond_to?(:force_ssl) && ::Rails.application.config.force_ssl
84
+ protocol = 'https'
85
+ else
86
+ protocol = 'http'
87
+ end
88
+
89
+ host = if ::Rails.application.config.respond_to?(:host)
90
+ ::Rails.application.config.host
91
+ elsif defined?(ActionMailer) && ActionMailer::Base.default_url_options[:host]
92
+ ActionMailer::Base.default_url_options[:host]
93
+ else
94
+ 'localhost'
95
+ end
96
+
97
+ port = ::Rails.application.config.respond_to?(:port) ? ::Rails.application.config.port : nil
98
+ port_suffix = port && port != 80 && port != 443 ? ":#{port}" : ""
99
+
100
+ "#{host}#{port_suffix}"
101
+ end
102
+
103
+ def environment
104
+ ::Rails.env
105
+ end
106
+
107
+ def endpoints
108
+ ::Rails.application.routes.routes.map do |route|
109
+ if route.defaults[:controller].nil? || route.defaults[:action].nil?
110
+ nil
111
+ else
112
+ controller_class = "#{route.defaults[:controller].camelize}Controller".constantize
113
+ versions = controller_class.respond_to?(:versions) ? controller_class.versions(route.defaults[:action].to_sym) : {}
114
+ {
115
+ path: route.path.spec.to_s.gsub(/\([^)]*\)/, ''),
116
+ http_method: route.verb,
117
+ endpoint_versions: versions,
118
+ }
119
+ end
120
+ end.compact.uniq.filter { |e| e[:path].present? && !(e[:path].start_with?('/rails/') || e[:path] == '/assets' || e[:endpoint_versions].empty?)}
121
+ end
122
+
123
+ def extract_version_from_path(path)
124
+ match = path.match(/\/v(\d+)\//)
125
+ match ? "v#{match[1]}" : nil
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,46 @@
1
+ #!/bin/ruby
2
+
3
+ require 'excon'
4
+ require "json"
5
+ require_relative 'http'
6
+
7
+ module EndPointBlank
8
+ module Commands
9
+ module GenerateAccessTokenMethods
10
+ module ClassMethods
11
+ def configuration
12
+ EndPointBlank::Configuration.instance
13
+ end
14
+
15
+ def token(hostname)
16
+ body = {hostname: hostname}
17
+ if configuration.token_ttl
18
+ body[:token_ttl] = configuration.token_ttl
19
+ end
20
+ auth = Authorization.header
21
+ response = Excon.post(configuration.access_token_url,
22
+ headers: {'Authorization' => auth, 'Content-Type' => 'application/json'},
23
+ body: body.to_json,
24
+ **EndPointBlank::Commands::Http::TIMEOUT_OPTIONS
25
+ )
26
+ EndPointBlank.logger.info "Authentication response: #{response.status} - #{response.body}"
27
+ parsed_body = response.body.is_a?(String) ? JSON.parse(response.body) : response.body
28
+ parsed_body.transform_keys(&:to_sym)
29
+ rescue => e
30
+ EndPointBlank.logger.error "Error occurred during authentication: #{e.message}\n #{e.backtrace.join("\n")}"
31
+ nil
32
+ end
33
+ end
34
+
35
+ def self.included(base)
36
+ base.extend(ClassMethods)
37
+ end
38
+ end
39
+
40
+ # Generates an access token by sending a request to a remote authorization service.
41
+ # Returns the access token from the authorization service or nil if an error occurs.
42
+ class GenerateAccessToken
43
+ include GenerateAccessTokenMethods
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,45 @@
1
+ require 'excon'
2
+
3
+ module EndPointBlank
4
+ module Commands
5
+ module Http
6
+ MAX_ATTEMPTS = 3
7
+ RETRY_DELAY = 0.2
8
+
9
+ # Per-attempt timeouts (seconds). Kept short because every call site in
10
+ # this lib is fire-and-forget telemetry: a slow/unreachable intake must
11
+ # never block the host application's request thread.
12
+ CONNECT_TIMEOUT = 3
13
+ READ_TIMEOUT = 5
14
+
15
+ # Shared Excon options so the timeout values live in exactly one place.
16
+ # Merge this into any `Excon.post`/`Excon.new` call in the lib.
17
+ TIMEOUT_OPTIONS = { connect_timeout: CONNECT_TIMEOUT, read_timeout: READ_TIMEOUT }.freeze
18
+
19
+ def self.post(url, auth, body)
20
+ attempt = 0
21
+ begin
22
+ attempt += 1
23
+ Excon.post(
24
+ url,
25
+ headers: { 'Authorization' => auth, 'Content-Type' => 'application/json' },
26
+ body: body.to_json,
27
+ **TIMEOUT_OPTIONS
28
+ )
29
+ rescue Excon::Error => e
30
+ # Excon::Error::Timeout (raised for both connect_timeout and
31
+ # read_timeout) is a subclass of Excon::Error, so it is handled
32
+ # here the same as any other transport error: retried, then
33
+ # swallowed with a log line rather than raised to the caller.
34
+ if attempt < MAX_ATTEMPTS
35
+ sleep RETRY_DELAY
36
+ retry
37
+ end
38
+ EndPointBlank.logger.error \
39
+ "[EndPointBlank] HTTP POST to #{url} failed after #{MAX_ATTEMPTS} attempts: #{e.message}"
40
+ nil
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EndPointBlank
4
+ module Commands
5
+ class RoutePatternFinder
6
+ def self.find(request)
7
+ return nil unless defined?(::Rails)
8
+
9
+ matched = nil
10
+ ::Rails.application.routes.router.recognize(request) do |route, _params|
11
+ matched = route.path.spec.to_s
12
+ break
13
+ end
14
+ matched
15
+ rescue StandardError => e
16
+ EndPointBlank.logger.debug("Error finding route pattern: #{e.message}")
17
+ nil
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,70 @@
1
+ module EndPointBlank
2
+ module Commands
3
+ # Finds the API version from an incoming request by checking multiple sources:
4
+ # - Custom version finder configured in Configuration.instance.version_finder
5
+ # - Accept header (e.g., application/vnd.api.v1+json)
6
+ # - X-Api-Version header (e.g., v1)
7
+ # - Content-Type header (e.g., application/vnd.api.v1+json)
8
+ # - Query parameter 'version' (e.g., ?version=v1)
9
+ # - URL path segment (e.g., /v1/resource)
10
+ # - Controller versioning configuration (via Versioned concern)
11
+ # Returns the version number as a string (e.g., "1") or nil if no version is found.
12
+ class VersionFinder
13
+ def headers
14
+ @headers ||= ::EndPointBlank::Rack::Headers.extract
15
+ end
16
+ def find(request)
17
+ return Configuration.instance.version_finder.call(request) if Configuration.instance.version_finder
18
+
19
+ return version if respond_to?(:version)
20
+
21
+ # Logic to determine version from request
22
+ # This could involve checking headers, query parameters, etc.
23
+ headers["Accept"]&.match(%r{application/vnd\.\w+\.v(\d+)}) do |m|
24
+ return m[1]
25
+ end
26
+
27
+ headers["X-Api-Version"]&.match(/v(\d+)/) do |m|
28
+ return m[1]
29
+ end
30
+
31
+ headers["Content-Type"]&.match(%r{application/vnd\.\w+\.v(\d+)}) do |m|
32
+ return m[1]
33
+ end
34
+
35
+ request.params["version"]&.match(/v(\d+)/) do |m|
36
+ return m[1]
37
+ end
38
+
39
+ request.path.match(%r{/v(\d+)/}) do |m|
40
+ return m[1]
41
+ end
42
+
43
+ # Try to find version from controller versioning configuration
44
+ version_from_controller(request)
45
+ end
46
+
47
+ private
48
+
49
+ def version_from_controller(request)
50
+ return nil unless defined?(::Rails)
51
+ return nil unless request.respond_to?(:path_parameters)
52
+
53
+ controller_name = request.path_parameters[:controller]
54
+ action_name = request.path_parameters[:action]
55
+
56
+ return nil if controller_name.nil? || action_name.nil?
57
+
58
+ controller_class = "#{controller_name.camelize}Controller".constantize
59
+ return nil unless controller_class.respond_to?(:versions)
60
+
61
+ versions = controller_class.versions(action_name.to_sym)
62
+ return nil if versions.empty?
63
+
64
+ versions.values.flatten.first&.match(/v?(\d+)/)&.[](1)
65
+ rescue NameError
66
+ nil
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "singleton"
4
+
5
+ module EndPointBlank
6
+ # Singleton configuration object for the EndPointBlank client.
7
+ #
8
+ # Values may be set explicitly via {EndPointBlank.configure}. Several
9
+ # settings also fall back to ENDPOINTBLANK_* environment variables when
10
+ # not explicitly configured, in order to support Rails-free deployments.
11
+ class Configuration
12
+ include Singleton
13
+
14
+ attr_writer :client_id, :client_secret, :base_url, :log_base_url, :app_name, :env_name
15
+
16
+ attr_accessor :environment, :worker_count, :log_mode,
17
+ :version_finder, :application_version, :token_ttl, :cache_ttl,
18
+ :masking_rules, :mask_hook, :logger
19
+
20
+ def initialize
21
+ @worker_count = 4
22
+ @token_ttl = nil
23
+ @cache_ttl = 300
24
+ @masking_rules = []
25
+ @mask_hook = nil
26
+ end
27
+
28
+ # Returns the configured client id, falling back to the
29
+ # ENDPOINTBLANK_CLIENT_ID environment variable when not explicitly set.
30
+ def client_id
31
+ @client_id || ENV["ENDPOINTBLANK_CLIENT_ID"]
32
+ end
33
+
34
+ # Returns the configured client secret, falling back to the
35
+ # ENDPOINTBLANK_CLIENT_SECRET environment variable when not explicitly set.
36
+ def client_secret
37
+ @client_secret || ENV["ENDPOINTBLANK_CLIENT_SECRET"]
38
+ end
39
+
40
+ # Returns the configured base URL, falling back to the
41
+ # ENDPOINTBLANK_BASE_URL environment variable, then a built-in default.
42
+ def base_url
43
+ @base_url || ENV["ENDPOINTBLANK_BASE_URL"] || "https://in.endpointblank.com"
44
+ end
45
+
46
+ # Returns the configured log base URL, falling back to the
47
+ # ENDPOINTBLANK_LOG_BASE_URL environment variable, then a built-in default.
48
+ def log_base_url
49
+ @log_base_url || ENV["ENDPOINTBLANK_LOG_BASE_URL"] || "https://log.endpointblank.com"
50
+ end
51
+
52
+ def endpoint_update_url
53
+ "#{base_url}/api/application_updates"
54
+ end
55
+
56
+ def access_token_url
57
+ "#{base_url}/api/access_token"
58
+ end
59
+
60
+ def authorize_url
61
+ "#{base_url}/api/authorize"
62
+ end
63
+
64
+ def errors_url
65
+ "#{log_base_url}/api/application_errors"
66
+ end
67
+
68
+ def requests_url
69
+ "#{log_base_url}/api/application_requests"
70
+ end
71
+
72
+ def responses_url
73
+ "#{log_base_url}/api/application_responses"
74
+ end
75
+
76
+ def logs_url
77
+ "#{log_base_url}/api/application_logs"
78
+ end
79
+
80
+ # Returns the name of the application.
81
+ #
82
+ # If {#app_name=} is called, then that value is returned.
83
+ #
84
+ # Otherwise, falls back to the ENDPOINTBLANK_APP_NAME environment
85
+ # variable.
86
+ #
87
+ # Otherwise, if the application is a Rails application, then
88
+ # {::Rails.application.name} is returned.
89
+ #
90
+ # Otherwise, nil is returned.
91
+ def app_name
92
+ if @app_name
93
+ @app_name
94
+ elsif ENV["ENDPOINTBLANK_APP_NAME"]
95
+ ENV["ENDPOINTBLANK_APP_NAME"]
96
+ elsif defined?(::Rails)
97
+ ::Rails.application.name.underscore
98
+ end
99
+ end
100
+
101
+ # Returns the configured environment name, falling back to the
102
+ # ENDPOINTBLANK_ENV environment variable when not explicitly set.
103
+ def env_name
104
+ @env_name || ENV["ENDPOINTBLANK_ENV"]
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,49 @@
1
+ require "json"
2
+
3
+ class FastJsonTruncator
4
+ MAX_BYTES = 10000
5
+ MAX_DEPTH = 5
6
+ MAX_LIST = 20
7
+ MAX_STRING = 200
8
+ MAX_KEYS = 20
9
+
10
+ def self.truncate(data, limit = MAX_BYTES)
11
+ pruned = prune(data, 0)
12
+ json = JSON.generate(pruned)
13
+ ensure_limit(json, limit)
14
+ end
15
+
16
+ def self.prune(value, depth)
17
+ return "[truncated]" if depth > MAX_DEPTH
18
+
19
+ case value
20
+ when Hash
21
+ result = {}
22
+ value.each_with_index do |(k, v), i|
23
+ break if i >= MAX_KEYS
24
+ result[k] = prune(v, depth + 1)
25
+ end
26
+ result
27
+
28
+ when Array
29
+ value.first(MAX_LIST).map { |v| prune(v, depth + 1) }
30
+
31
+ when String
32
+ if value.bytesize > MAX_STRING
33
+ value.byteslice(0, MAX_STRING) + "..."
34
+ else
35
+ value
36
+ end
37
+
38
+ else
39
+ value
40
+ end
41
+ end
42
+
43
+ def self.ensure_limit(json, limit)
44
+ return json if json.bytesize <= limit
45
+
46
+ truncated = json.byteslice(0, limit - 20)
47
+ truncated + '...,"truncated":true}'
48
+ end
49
+ end
@@ -0,0 +1,16 @@
1
+ module EndPointBlank
2
+ class LogEntry
3
+ attr_accessor :message, :stacktrace, :app, :status, :headers, :body, :env, :sent_at
4
+
5
+ def initialize(message:, env: , stacktrace:, app:, status:, headers:, body:, sent_at: Time.now)
6
+ @message = message
7
+ @env = env
8
+ @stacktrace = stacktrace
9
+ @app = app
10
+ @status = status
11
+ @headers = headers
12
+ @body = body
13
+ @sent_at = sent_at
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,30 @@
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