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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +13 -0
- data/.ruby-version +1 -0
- data/README.md +64 -0
- data/Rakefile +12 -0
- data/build.sh +5 -0
- data/docs/superpowers/plans/2026-07-08-framework-agnostic-core.md +68 -0
- data/docs/superpowers/specs/2026-07-08-framework-agnostic-core-design.md +58 -0
- data/end_point_blank.gemspec +45 -0
- data/lib/end_point_blank/access_tokens.rb +72 -0
- data/lib/end_point_blank/authorization.rb +36 -0
- data/lib/end_point_blank/commands/authentication_cache.rb +95 -0
- data/lib/end_point_blank/commands/basic_authenticate.rb +47 -0
- data/lib/end_point_blank/commands/bearer_generate.rb +33 -0
- data/lib/end_point_blank/commands/endpoint_authorize.rb +71 -0
- data/lib/end_point_blank/commands/endpoint_update.rb +129 -0
- data/lib/end_point_blank/commands/generate_access_token.rb +46 -0
- data/lib/end_point_blank/commands/http.rb +45 -0
- data/lib/end_point_blank/commands/route_pattern_finder.rb +21 -0
- data/lib/end_point_blank/commands/version_finder.rb +70 -0
- data/lib/end_point_blank/configuration.rb +107 -0
- data/lib/end_point_blank/fast_json_truncator.rb +49 -0
- data/lib/end_point_blank/log_entry.rb +16 -0
- data/lib/end_point_blank/loggers/logger.rb +30 -0
- data/lib/end_point_blank/masking.rb +282 -0
- data/lib/end_point_blank/middleware/rack/report_interaction.rb +54 -0
- data/lib/end_point_blank/rack/env_store.rb +34 -0
- data/lib/end_point_blank/rack/headers.rb +11 -0
- data/lib/end_point_blank/rails/authenticated.rb +21 -0
- data/lib/end_point_blank/rails/authorized.rb +33 -0
- data/lib/end_point_blank/rails/railtie.rb +18 -0
- data/lib/end_point_blank/rails/versioned.rb +64 -0
- data/lib/end_point_blank/session_configuration.rb +44 -0
- data/lib/end_point_blank/string_truncator.rb +21 -0
- data/lib/end_point_blank/unauthorized_error.rb +10 -0
- data/lib/end_point_blank/version.rb +5 -0
- data/lib/end_point_blank/writers/delayed_writer.rb +110 -0
- data/lib/end_point_blank/writers/direct_writer.rb +18 -0
- data/lib/end_point_blank/writers/exception_writer.rb +50 -0
- data/lib/end_point_blank/writers/log_writer.rb +69 -0
- data/lib/end_point_blank/writers/request_writer.rb +61 -0
- data/lib/end_point_blank/writers/response_writer.rb +70 -0
- data/lib/end_point_blank/writers/shared.rb +39 -0
- data/lib/end_point_blank/xml_truncator.rb +89 -0
- data/lib/end_point_blank.rb +57 -0
- data/sig/end_point_blank_rack.rbs +4 -0
- data/test.sh +5 -0
- 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
|