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,282 @@
1
+ require "json"
2
+
3
+ module EndPointBlank
4
+ # Client-side masking. Applies configured rules to an outgoing payload's
5
+ # maskable fields for the given record_type, then runs the optional user hook.
6
+ # Payload keys are SYMBOLS matching the writers' payloads / intake wire keys.
7
+ #
8
+ # Rule shape (string-keyed-or-symbol-keyed hash):
9
+ # :target — one of request_body, request_headers, path,
10
+ # response_body, error_message; mapped to a wire key
11
+ # per FIELD_MAP for the record_type.
12
+ # :path — a JSONPath (constrained subset); may be nil/"".
13
+ # :regex — a regex source string; may be nil/"".
14
+ # :replacement_value — literal replacement string.
15
+ #
16
+ # Matching semantics ("path scopes, regex matches within"):
17
+ # path only — replace each node selected by the path entirely.
18
+ # regex only — global regex substitution on every string leaf.
19
+ # path + regex — within each selected node, regex-substitute its string leaves.
20
+ module Masking
21
+ FIELD_MAP = {
22
+ request: { "request_body" => :request, "request_headers" => :headers, "path" => :path },
23
+ response: { "response_body" => :body },
24
+ error: { "error_message" => :message },
25
+ log: {}
26
+ }.freeze
27
+
28
+ # Targets whose wire value is a JSON string body (decode/apply/re-encode).
29
+ JSON_TARGETS = %w[request_body response_body].freeze
30
+
31
+ module_function
32
+
33
+ def apply(payload, record_type, rules, hook)
34
+ masked = (rules || []).reduce(payload) { |acc, rule| apply_rule(acc, record_type, rule) }
35
+ hook ? hook.call(masked, record_type.to_s) : masked
36
+ end
37
+
38
+ def apply_rule(payload, record_type, rule)
39
+ field_map = FIELD_MAP.fetch(record_type, {})
40
+ target = rule[:target]
41
+ key = field_map[target]
42
+ return payload unless key && payload.key?(key)
43
+
44
+ payload.merge(key => mask_field(payload[key], rule, target))
45
+ end
46
+
47
+ # Body targets: JSON string. Decode, apply on the decoded value, re-encode.
48
+ # On non-JSON: path no-ops; regex (if present) applies to the raw string.
49
+ # request_headers: a Hash. path applies; regex applies to string leaves.
50
+ # path / error_message: plain strings — path no-ops, only regex applies.
51
+ def mask_field(value, rule, target)
52
+ case value
53
+ when String
54
+ if JSON_TARGETS.include?(target)
55
+ begin
56
+ decoded = JSON.parse(value)
57
+ rescue JSON::ParserError
58
+ return apply_to_raw_string(value, rule)
59
+ end
60
+ JSON.generate(apply_to_value(decoded, rule))
61
+ else
62
+ apply_to_raw_string(value, rule)
63
+ end
64
+ when Hash
65
+ apply_to_value(value, rule)
66
+ else
67
+ value
68
+ end
69
+ end
70
+
71
+ # A plain, non-JSON string target: path cannot apply (no-op); regex applies.
72
+ def apply_to_raw_string(value, rule)
73
+ re = compiled_regex(rule)
74
+ return value unless re
75
+
76
+ regex_replace_all(re, value, replacement(rule))
77
+ end
78
+
79
+ # Applies the rule to a structured value (decoded JSON or header Hash).
80
+ def apply_to_value(value, rule)
81
+ path = rule[:path] || rule["path"]
82
+ tokens = parse_path(path)
83
+ return value if tokens.nil? && path.is_a?(String) && path != ""
84
+
85
+ re = compiled_regex(rule)
86
+ repl = replacement(rule)
87
+
88
+ if tokens && re
89
+ # path + regex: select nodes, apply regex to leaves within each.
90
+ transform(value, tokens) { |old| regex_replace_leaves(old, re, repl) }
91
+ elsif tokens
92
+ # path only: replace each selected node entirely.
93
+ transform(value, tokens) { |_old| repl }
94
+ elsif re
95
+ # regex only: substitute across every string leaf.
96
+ regex_replace_leaves(value, re, repl)
97
+ else
98
+ value
99
+ end
100
+ end
101
+
102
+ def replacement(rule)
103
+ (rule[:replacement_value] || rule["replacement_value"] || "...").to_s
104
+ end
105
+
106
+ # Compiles rule[:regex]; blank/nil/invalid ⇒ nil (regex step no-ops).
107
+ def compiled_regex(rule)
108
+ source = rule[:regex] || rule["regex"]
109
+ return nil if source.nil? || source == ""
110
+
111
+ Regexp.new(source)
112
+ rescue RegexpError, TypeError
113
+ nil
114
+ end
115
+
116
+ # Recurse over containers; substitute on every string leaf.
117
+ def regex_replace_leaves(node, re, repl)
118
+ case node
119
+ when String then regex_replace_all(re, node, repl)
120
+ when Hash then node.each_with_object({}) { |(k, v), out| out[k] = regex_replace_leaves(v, re, repl) }
121
+ when Array then node.map { |e| regex_replace_leaves(e, re, repl) }
122
+ else node
123
+ end
124
+ end
125
+
126
+ # --- Replacement backreferences (shared contract) --------------------------
127
+ #
128
+ # In a regex substitution, replacement_value is a TEMPLATE. For each match we
129
+ # build the replacement ourselves (NOT Ruby's native \N substitution):
130
+ # $$ → literal "$"
131
+ # $<digits> → capture group N (full consecutive digit run); 0 = whole
132
+ # match; missing/non-participating group → "".
133
+ # lone/trailing $ before a non-digit → literal "$".
134
+ # groups is 0-indexed: groups[0] = whole match, groups[n] = nth capture.
135
+
136
+ def regex_replace_all(regexp, string, template)
137
+ string.gsub(regexp) do
138
+ m = Regexp.last_match
139
+ groups = (0...m.size).map { |i| m[i] }
140
+ expand(template, groups)
141
+ end
142
+ end
143
+
144
+ def expand(template, groups)
145
+ out = +""
146
+ i = 0
147
+ len = template.length
148
+ while i < len
149
+ ch = template[i]
150
+ if ch != "$"
151
+ out << ch
152
+ i += 1
153
+ elsif template[i + 1] == "$"
154
+ out << "$"
155
+ i += 2
156
+ elsif template[i + 1] =~ /\d/
157
+ j = i + 1
158
+ j += 1 while j < len && template[j] =~ /\d/
159
+ n = template[(i + 1)...j].to_i
160
+ out << (groups[n] || "")
161
+ i = j
162
+ else
163
+ out << "$"
164
+ i += 1
165
+ end
166
+ end
167
+ out
168
+ end
169
+
170
+ # --- Constrained JSONPath subset (mirrors intake's JsonPath) ---------------
171
+ #
172
+ # Tokens: [:child, name] / [:index, n] / [:wildcard] / [:descendant, name].
173
+ # parse_path returns a token array, or nil for blank/unsupported/garbled
174
+ # input (caller treats nil as "matches nothing"). Never raises.
175
+
176
+ def parse_path(string)
177
+ return nil unless string.is_a?(String)
178
+ return nil unless string.start_with?("$")
179
+
180
+ parse_tokens(string[1..], [])
181
+ end
182
+
183
+ def parse_tokens(rest, acc)
184
+ return acc if rest.empty?
185
+
186
+ if rest.start_with?("..")
187
+ name, remaining = take_name(rest[2..])
188
+ return nil if name.empty?
189
+
190
+ parse_tokens(remaining, acc + [[:descendant, name]])
191
+ elsif rest.start_with?(".*")
192
+ parse_tokens(rest[2..], acc + [[:wildcard]])
193
+ elsif rest.start_with?(".")
194
+ name, remaining = take_name(rest[1..])
195
+ return nil if name.empty?
196
+
197
+ parse_tokens(remaining, acc + [[:child, name]])
198
+ elsif rest.start_with?("[")
199
+ result = parse_bracket(rest[1..])
200
+ return nil unless result
201
+
202
+ token, remaining = result
203
+ parse_tokens(remaining, acc + [token])
204
+ end
205
+ end
206
+
207
+ def parse_bracket(rest)
208
+ if rest.start_with?("*]")
209
+ [[:wildcard], rest[2..]]
210
+ elsif rest.start_with?("'")
211
+ parse_quoted(rest[1..], "'")
212
+ elsif rest.start_with?('"')
213
+ parse_quoted(rest[1..], '"')
214
+ elsif (m = rest.match(/\A(\d+)\](.*)\z/m))
215
+ [[:index, m[1].to_i], m[2]]
216
+ end
217
+ end
218
+
219
+ def parse_quoted(rest, quote_char)
220
+ name, remaining = rest.split("#{quote_char}]", 2)
221
+ return nil if remaining.nil?
222
+
223
+ [[:child, name], remaining]
224
+ end
225
+
226
+ # Consumes a leading [A-Za-z0-9_]+ run; returns [name, remaining].
227
+ def take_name(string)
228
+ m = string.match(/\A([A-Za-z0-9_]+)(.*)\z/m)
229
+ return ["", string] unless m
230
+
231
+ [m[1], m[2]]
232
+ end
233
+
234
+ # Walks value following tokens, replacing each fully-matched location with
235
+ # the block's result and rebuilding parents immutably. Never raises.
236
+ def transform(value, tokens, &block)
237
+ return block.call(value) if tokens.empty?
238
+
239
+ token, *rest = tokens
240
+ case token[0]
241
+ when :child
242
+ key = token[1]
243
+ return value unless value.is_a?(Hash) && value.key?(key)
244
+
245
+ value.merge(key => transform(value[key], rest, &block))
246
+ when :index
247
+ i = token[1]
248
+ return value unless value.is_a?(Array) && i >= 0 && i < value.length
249
+
250
+ out = value.dup
251
+ out[i] = transform(out[i], rest, &block)
252
+ out
253
+ when :wildcard
254
+ case value
255
+ when Hash then value.each_with_object({}) { |(k, v), o| o[k] = transform(v, rest, &block) }
256
+ when Array then value.map { |e| transform(e, rest, &block) }
257
+ else value
258
+ end
259
+ when :descendant
260
+ descend(value, token[1], rest, &block)
261
+ else
262
+ value
263
+ end
264
+ end
265
+
266
+ # Recursive descent: at this node and every nested node, any entry whose key
267
+ # is `key` matches the remaining tokens.
268
+ def descend(value, key, rest, &block)
269
+ case value
270
+ when Hash
271
+ value.each_with_object({}) do |(k, v), out|
272
+ v = descend(v, key, rest, &block)
273
+ out[k] = k == key ? transform(v, rest, &block) : v
274
+ end
275
+ when Array
276
+ value.map { |e| descend(e, key, rest, &block) }
277
+ else
278
+ value
279
+ end
280
+ end
281
+ end
282
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+
5
+ module EndPointBlank
6
+ module Middleware
7
+ module Rack
8
+ class ReportInteraction
9
+ attr_reader :options, :url
10
+
11
+ def initialize(app, options = {})
12
+ @app = app
13
+ @mapping = options
14
+ end
15
+
16
+ def call(env)
17
+ ::EndPointBlank::Rack::EnvStore.set(env)
18
+ ::EndPointBlank::Writers::RequestWriter.write
19
+
20
+ status, headers, body = @app.call(env)
21
+
22
+ [status, headers, body]
23
+ rescue ::EndPointBlank::UnauthorizedError => e
24
+ # We don't want to log unauthorized errors as they are expected to happen
25
+ status ||= e.respond_to?(:status) && e.status || 401
26
+ body ||= e.message
27
+ raise e
28
+ rescue Exception => e
29
+ # The exception will be rendered by ActionDispatch::DebugExceptions
30
+ # (or ShowExceptions in prod), which sits outside this middleware,
31
+ # so we never see the rendered status / body. Synthesize them so the
32
+ # response row still gets recorded — intake requires a non-nil status.
33
+ status ||= 500
34
+ body ||= "#{e.class}: #{e.message}"
35
+ Writers::ExceptionWriter.write(e)
36
+
37
+ raise e
38
+ ensure
39
+ headers = ::EndPointBlank::Rack::Headers.extract
40
+ ::EndPointBlank::Writers::ResponseWriter.write(status:, headers:, body:)
41
+ ::EndPointBlank::Rack::EnvStore.clear
42
+ end
43
+
44
+ def on_success(response)
45
+ EndPointBlank.logger.debug("Successfully reported interaction: #{response.body}")
46
+ end
47
+
48
+ def on_failure(response)
49
+ EndPointBlank.logger.error("Failed to report interaction: #{response.body}")
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,34 @@
1
+ module EndPointBlank
2
+ module Rack
3
+ class EnvStore
4
+ KEY = 'end_point_blank.rack_env'.freeze
5
+ SOURCE_ENV_ID_KEY = 'end_point_blank.source_application_environment_id'.freeze
6
+
7
+ def self.set(env)
8
+ Thread.current[KEY] = env
9
+ end
10
+
11
+ def self.get
12
+ Thread.current[KEY]
13
+ end
14
+
15
+ def self.request
16
+ env = get
17
+ env && ::Rack::Request.new(env)
18
+ end
19
+
20
+ def self.set_source_application_environment_id(id)
21
+ Thread.current[SOURCE_ENV_ID_KEY] = id
22
+ end
23
+
24
+ def self.source_application_environment_id
25
+ Thread.current[SOURCE_ENV_ID_KEY]
26
+ end
27
+
28
+ def self.clear
29
+ Thread.current[KEY] = nil
30
+ Thread.current[SOURCE_ENV_ID_KEY] = nil
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,11 @@
1
+ module EndPointBlank
2
+ module Rack
3
+ module Headers
4
+ def self.extract
5
+ env = ::EndPointBlank::Rack::EnvStore.get
6
+ env.select { |k,v| k.start_with? 'HTTP_'}.
7
+ transform_keys { |k| k.sub(/^HTTP_/, '').split('_').map(&:capitalize).join('-') }
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,21 @@
1
+ require "active_support/concern"
2
+
3
+ module EndPointBlank
4
+ module Rails
5
+ module Authenticated
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ before_action :authenticate!
10
+ end
11
+
12
+ def authenticate!
13
+ result = EndPointBlank::Commands::EndpointAuthenticate.authenticate(request)
14
+ result_json = JSON.parse(result.body)
15
+ if !result || result.status != 201
16
+ raise UnauthorizedError, "Authentication failed: #{result_json['error']}"
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,33 @@
1
+ require "active_support/concern"
2
+
3
+ module EndPointBlank
4
+ module Rails
5
+ module Authorized
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ before_action :authorize!
10
+ end
11
+
12
+ def authorize!
13
+ result = EndPointBlank::Commands::EndpointAuthorize.authorize(request)
14
+ if result.nil? || result.status != 201
15
+ raise UnauthorizedError.new(authorize_error_message(result), result&.status || 503)
16
+ end
17
+ result_json = JSON.parse(result.body)
18
+ app_env_id = result_json['data'][0]['source_application_environment_id']
19
+ ::EndPointBlank::Rack::EnvStore.set_source_application_environment_id(app_env_id)
20
+ end
21
+
22
+ private
23
+
24
+ def authorize_error_message(result)
25
+ return "Authorization service unavailable" if result.nil?
26
+
27
+ parsed = JSON.parse(result.body) rescue nil
28
+ detail = parsed.is_a?(Hash) ? parsed["error"] : nil
29
+ "Authorization failed: #{detail || result.body}"
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,18 @@
1
+ require 'rails/railtie' if defined?(::Rails)
2
+
3
+ module EndPointBlank
4
+ module Rails
5
+ class Railtie < ::Rails::Railtie
6
+ initializer 'endpointblank.middleware.rails' do |app|
7
+ require 'end_point_blank/rails/railtie'
8
+
9
+ app.config.middleware.insert_after ActionDispatch::DebugExceptions,
10
+ EndPointBlank::Middleware::Rack::ReportInteraction
11
+ end
12
+
13
+ initializer 'endpointblank.logger.rails' do
14
+ EndPointBlank::Configuration.instance.logger ||= ::Rails.logger
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,64 @@
1
+ module EndPointBlank
2
+ module Rails
3
+ module Versioned
4
+ extend ActiveSupport::Concern
5
+
6
+ class_methods do
7
+ # Define versioning for specific actions
8
+ # @param versions [Array<String>] List of version strings (e.g., ["v1", "v2"])
9
+ # @param options [Hash] Options hash with :only or :except keys
10
+ # @option options [Array<Symbol>] :only Actions to include in versioning
11
+ # @option options [Array<Symbol>] :except Actions to exclude from versioning
12
+ #
13
+ # Example:
14
+ # version ["v1", "v2"], only: [:index], state: "Current"
15
+ # version ["v3"], except: [:destroy], state: "Deprecated"
16
+ def version(values, options = {})
17
+ versions = Array(values)
18
+ @versioning_config ||= {}
19
+
20
+ actions = determine_actions(options)
21
+
22
+ actions.each do |action|
23
+ @versioning_config[action] ||= {}
24
+ state = options[:state] || "__default__"
25
+ @versioning_config[action][state] ||= []
26
+ @versioning_config[action][state] = (@versioning_config[action][state] + versions).uniq
27
+ end
28
+ end
29
+
30
+ # Returns the versioning configuration hash
31
+ # @return [Hash] Hash mapping action names to their version arrays
32
+ #
33
+ # Example return value:
34
+ # {
35
+ # index: ["v1", "v2"],
36
+ # show: ["v1"]
37
+ # }
38
+ def versions(action)
39
+ @versioning_config&.fetch(action, {}) || {}
40
+ end
41
+
42
+ private
43
+
44
+ # Determine which actions should be affected based on options
45
+ # @param options [Hash] Options hash with :only or :except keys
46
+ # @return [Array<Symbol>] Array of action symbols
47
+ def determine_actions(options)
48
+ if options[:only]
49
+ Array(options[:only]).map(&:to_sym)
50
+ elsif options[:except]
51
+ # If :except is specified, we need to determine all possible actions
52
+ # and exclude the specified ones. Get all public methods of the controller.
53
+ all_actions = self.public_instance_methods(false).map(&:to_sym)
54
+ excluded_actions = Array(options[:except]).map(&:to_sym)
55
+ all_actions - excluded_actions
56
+ else
57
+ # If no options specified, apply to all common REST actions
58
+ self.public_instance_methods(false).map(&:to_sym)
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EndPointBlank
4
+ # Resolves per-request session state (the current Rack env, and the name
5
+ # of the environment the app is running in) without depending on any
6
+ # particular framework or app server.
7
+ class SessionConfiguration
8
+ def self.env
9
+ ::EndPointBlank::Rack::EnvStore.get
10
+ end
11
+
12
+ # Resolves the name of the environment the app is running in, without
13
+ # assuming any particular framework or app server.
14
+ #
15
+ # Resolution order (first non-nil wins):
16
+ # 1. Configuration.instance.env_name (explicit config, or the
17
+ # ENDPOINTBLANK_ENV environment variable)
18
+ # 2. RACK_ENV
19
+ # 3. APP_ENV
20
+ # 4. A Puma "puma.config" entry in the current Rack env, if present
21
+ # (back-compat with earlier Puma-only behavior)
22
+ # 5. ::Rails.env, if Rails is defined
23
+ # 6. "production" as a final default
24
+ #
25
+ # Never raises, even when there is no Rack env at all (e.g. Sinatra /
26
+ # plain Rack apps outside of a request), and always returns a String.
27
+ def self.env_name
28
+ Configuration.instance.env_name ||
29
+ ENV["RACK_ENV"] ||
30
+ ENV["APP_ENV"] ||
31
+ puma_env_name ||
32
+ (::Rails.env.to_s if defined?(::Rails)) ||
33
+ "production"
34
+ end
35
+
36
+ # Nil-safe read of the environment name from a Puma "puma.config" entry
37
+ # in the Rack env, if one exists. Never raises, even when there is no
38
+ # Rack env at all, or the env has no "puma.config" key.
39
+ def self.puma_env_name
40
+ env&.[]("puma.config")&.options&.[](:environment)
41
+ end
42
+ private_class_method :puma_env_name
43
+ end
44
+ end
@@ -0,0 +1,21 @@
1
+ class StringTruncator
2
+ DEFAULT_LIMIT = 1000
3
+ DEFAULT_SUFFIX = "<truncated>"
4
+
5
+ def self.truncate(str, limit: DEFAULT_LIMIT, suffix: DEFAULT_SUFFIX)
6
+ return "" if str.nil?
7
+ return str if str.bytesize <= limit
8
+
9
+ suffix_bytes = suffix.bytesize
10
+ max_bytes = limit - suffix_bytes
11
+
12
+ truncated = str.byteslice(0, max_bytes)
13
+
14
+ # Ensure valid UTF-8
15
+ while !truncated.valid_encoding?
16
+ truncated = truncated.byteslice(0, truncated.bytesize - 1)
17
+ end
18
+
19
+ truncated + suffix
20
+ end
21
+ end
@@ -0,0 +1,10 @@
1
+ module EndPointBlank
2
+ class UnauthorizedError < StandardError
3
+ attr_reader :status
4
+
5
+ def initialize(message = nil, status = 401)
6
+ super(message)
7
+ @status = status
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EndPointBlank
4
+ VERSION = "0.2.0"
5
+ end