binocs 0.1.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/MIT-LICENSE +21 -0
- data/README.md +528 -0
- data/Rakefile +7 -0
- data/app/assets/javascripts/binocs/application.js +105 -0
- data/app/assets/stylesheets/binocs/application.css +67 -0
- data/app/channels/binocs/application_cable/channel.rb +8 -0
- data/app/channels/binocs/application_cable/connection.rb +8 -0
- data/app/channels/binocs/requests_channel.rb +13 -0
- data/app/controllers/binocs/application_controller.rb +62 -0
- data/app/controllers/binocs/requests_controller.rb +69 -0
- data/app/helpers/binocs/application_helper.rb +61 -0
- data/app/models/binocs/application_record.rb +7 -0
- data/app/models/binocs/request.rb +198 -0
- data/app/views/binocs/requests/_empty_list.html.erb +9 -0
- data/app/views/binocs/requests/_request.html.erb +61 -0
- data/app/views/binocs/requests/index.html.erb +115 -0
- data/app/views/binocs/requests/show.html.erb +227 -0
- data/app/views/layouts/binocs/application.html.erb +109 -0
- data/config/importmap.rb +6 -0
- data/config/routes.rb +11 -0
- data/db/migrate/20240101000000_create_binocs_requests.rb +36 -0
- data/exe/binocs +86 -0
- data/lib/binocs/agent.rb +153 -0
- data/lib/binocs/agent_context.rb +165 -0
- data/lib/binocs/agent_manager.rb +302 -0
- data/lib/binocs/configuration.rb +65 -0
- data/lib/binocs/engine.rb +61 -0
- data/lib/binocs/log_subscriber.rb +56 -0
- data/lib/binocs/middleware/request_recorder.rb +264 -0
- data/lib/binocs/swagger/client.rb +100 -0
- data/lib/binocs/swagger/path_matcher.rb +118 -0
- data/lib/binocs/tui/agent_output.rb +163 -0
- data/lib/binocs/tui/agents_list.rb +195 -0
- data/lib/binocs/tui/app.rb +726 -0
- data/lib/binocs/tui/colors.rb +115 -0
- data/lib/binocs/tui/filter_menu.rb +162 -0
- data/lib/binocs/tui/help_screen.rb +93 -0
- data/lib/binocs/tui/request_detail.rb +899 -0
- data/lib/binocs/tui/request_list.rb +268 -0
- data/lib/binocs/tui/spirit_animal.rb +235 -0
- data/lib/binocs/tui/window.rb +98 -0
- data/lib/binocs/tui.rb +24 -0
- data/lib/binocs/version.rb +5 -0
- data/lib/binocs.rb +27 -0
- data/lib/generators/binocs/install/install_generator.rb +61 -0
- data/lib/generators/binocs/install/templates/create_binocs_requests.rb +36 -0
- data/lib/generators/binocs/install/templates/initializer.rb +25 -0
- data/lib/tasks/binocs_tasks.rake +38 -0
- metadata +149 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "turbo-rails"
|
|
4
|
+
require "stimulus-rails"
|
|
5
|
+
|
|
6
|
+
module Binocs
|
|
7
|
+
class Engine < ::Rails::Engine
|
|
8
|
+
isolate_namespace Binocs
|
|
9
|
+
|
|
10
|
+
config.generators do |g|
|
|
11
|
+
g.test_framework :rspec
|
|
12
|
+
g.assets false
|
|
13
|
+
g.helper false
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
initializer "binocs.middleware" do |app|
|
|
17
|
+
next unless Binocs.enabled?
|
|
18
|
+
|
|
19
|
+
require_relative "middleware/request_recorder"
|
|
20
|
+
app.middleware.use Binocs::Middleware::RequestRecorder
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
initializer "binocs.log_subscriber" do
|
|
24
|
+
next unless Binocs.enabled?
|
|
25
|
+
|
|
26
|
+
require_relative "log_subscriber"
|
|
27
|
+
Binocs::LogSubscriber.attach_to :action_controller
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
initializer "binocs.assets" do |app|
|
|
31
|
+
next unless Binocs.enabled?
|
|
32
|
+
|
|
33
|
+
app.config.assets.precompile += %w[binocs/application.css binocs/application.js] if app.config.respond_to?(:assets)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
initializer "binocs.importmap", before: "importmap" do |app|
|
|
37
|
+
next unless Binocs.enabled?
|
|
38
|
+
next unless app.config.respond_to?(:importmap)
|
|
39
|
+
|
|
40
|
+
app.config.importmap.paths << Engine.root.join("config/importmap.rb")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
initializer "binocs.helpers" do
|
|
44
|
+
ActiveSupport.on_load(:action_controller_base) do
|
|
45
|
+
helper Binocs::ApplicationHelper
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
config.after_initialize do
|
|
50
|
+
if Rails.env.production? && Binocs.configuration.enabled
|
|
51
|
+
Rails.logger.warn "[Binocs] WARNING: Binocs is disabled in production for security reasons."
|
|
52
|
+
Binocs.configuration.enabled = false
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Configure renderer for Turbo broadcasts (no request context available)
|
|
56
|
+
if defined?(ApplicationController)
|
|
57
|
+
ApplicationController.renderer.defaults[:http_host] = ENV.fetch("DOMAIN", "localhost:3000")
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Binocs
|
|
4
|
+
class LogSubscriber < ActiveSupport::LogSubscriber
|
|
5
|
+
def process_action(event)
|
|
6
|
+
return unless Thread.current[:binocs_logs]
|
|
7
|
+
|
|
8
|
+
payload = event.payload
|
|
9
|
+
|
|
10
|
+
Thread.current[:binocs_logs] << {
|
|
11
|
+
type: "controller",
|
|
12
|
+
controller: payload[:controller],
|
|
13
|
+
action: payload[:action],
|
|
14
|
+
format: payload[:format],
|
|
15
|
+
method: payload[:method],
|
|
16
|
+
path: payload[:path],
|
|
17
|
+
status: payload[:status],
|
|
18
|
+
view_runtime: payload[:view_runtime]&.round(2),
|
|
19
|
+
db_runtime: payload[:db_runtime]&.round(2),
|
|
20
|
+
duration: event.duration.round(2),
|
|
21
|
+
timestamp: Time.current.iso8601
|
|
22
|
+
}
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def halted_callback(event)
|
|
26
|
+
return unless Thread.current[:binocs_logs]
|
|
27
|
+
|
|
28
|
+
Thread.current[:binocs_logs] << {
|
|
29
|
+
type: "halted",
|
|
30
|
+
filter: event.payload[:filter],
|
|
31
|
+
timestamp: Time.current.iso8601
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def send_data(event)
|
|
36
|
+
return unless Thread.current[:binocs_logs]
|
|
37
|
+
|
|
38
|
+
Thread.current[:binocs_logs] << {
|
|
39
|
+
type: "send_data",
|
|
40
|
+
filename: event.payload[:filename],
|
|
41
|
+
timestamp: Time.current.iso8601
|
|
42
|
+
}
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def redirect_to(event)
|
|
46
|
+
return unless Thread.current[:binocs_logs]
|
|
47
|
+
|
|
48
|
+
Thread.current[:binocs_logs] << {
|
|
49
|
+
type: "redirect",
|
|
50
|
+
location: event.payload[:location],
|
|
51
|
+
status: event.payload[:status],
|
|
52
|
+
timestamp: Time.current.iso8601
|
|
53
|
+
}
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Binocs
|
|
6
|
+
module Middleware
|
|
7
|
+
class RequestRecorder
|
|
8
|
+
def initialize(app)
|
|
9
|
+
@app = app
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call(env)
|
|
13
|
+
return @app.call(env) unless Binocs.enabled?
|
|
14
|
+
return @app.call(env) if ignored_path?(env["PATH_INFO"])
|
|
15
|
+
|
|
16
|
+
request_id = SecureRandom.uuid
|
|
17
|
+
Thread.current[:binocs_request_id] = request_id
|
|
18
|
+
Thread.current[:binocs_logs] = []
|
|
19
|
+
Thread.current[:binocs_start_time] = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
20
|
+
Thread.current[:binocs_memory_before] = get_memory_usage
|
|
21
|
+
|
|
22
|
+
request = ActionDispatch::Request.new(env)
|
|
23
|
+
|
|
24
|
+
recorded_request = build_request_record(request, request_id)
|
|
25
|
+
|
|
26
|
+
begin
|
|
27
|
+
status, headers, response = @app.call(env)
|
|
28
|
+
|
|
29
|
+
complete_request_record(recorded_request, status, headers, response, env)
|
|
30
|
+
|
|
31
|
+
[status, headers, response]
|
|
32
|
+
rescue Exception => e
|
|
33
|
+
record_exception(recorded_request, e)
|
|
34
|
+
raise
|
|
35
|
+
ensure
|
|
36
|
+
save_request_record(recorded_request)
|
|
37
|
+
cleanup_thread_locals
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def ignored_path?(path)
|
|
44
|
+
Binocs.configuration.ignored_paths.any? { |ignored| path.start_with?(ignored) }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def build_request_record(request, request_id)
|
|
48
|
+
{
|
|
49
|
+
uuid: request_id,
|
|
50
|
+
method: request.request_method,
|
|
51
|
+
path: request.path,
|
|
52
|
+
full_url: request.original_url,
|
|
53
|
+
params: sanitize_params(request),
|
|
54
|
+
request_headers: extract_headers(request.headers),
|
|
55
|
+
ip_address: request.remote_ip,
|
|
56
|
+
session_id: request.session.id&.to_s,
|
|
57
|
+
content_type: request.content_type,
|
|
58
|
+
request_body: extract_request_body(request)
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def complete_request_record(record, status, headers, response, env)
|
|
63
|
+
record[:status_code] = status
|
|
64
|
+
record[:response_headers] = headers.to_h
|
|
65
|
+
record[:response_body] = extract_response_body(response, headers)
|
|
66
|
+
record[:duration_ms] = calculate_duration
|
|
67
|
+
record[:memory_delta] = calculate_memory_delta
|
|
68
|
+
record[:logs] = Thread.current[:binocs_logs] || []
|
|
69
|
+
record[:controller_name] = env["action_controller.instance"]&.class&.name
|
|
70
|
+
record[:action_name] = env["action_controller.instance"]&.action_name
|
|
71
|
+
record[:route_name] = extract_route_name(env)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def record_exception(record, exception)
|
|
75
|
+
record[:exception] = {
|
|
76
|
+
class: exception.class.name,
|
|
77
|
+
message: exception.message,
|
|
78
|
+
backtrace: exception.backtrace&.first(20)
|
|
79
|
+
}
|
|
80
|
+
record[:status_code] ||= 500
|
|
81
|
+
record[:duration_ms] = calculate_duration
|
|
82
|
+
record[:memory_delta] = calculate_memory_delta
|
|
83
|
+
record[:logs] = Thread.current[:binocs_logs] || []
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def save_request_record(record)
|
|
87
|
+
return unless record[:status_code]
|
|
88
|
+
|
|
89
|
+
Binocs::Request.create!(
|
|
90
|
+
uuid: record[:uuid],
|
|
91
|
+
method: record[:method],
|
|
92
|
+
path: record[:path],
|
|
93
|
+
full_url: record[:full_url],
|
|
94
|
+
controller_name: record[:controller_name],
|
|
95
|
+
action_name: record[:action_name],
|
|
96
|
+
route_name: record[:route_name],
|
|
97
|
+
params: record[:params],
|
|
98
|
+
request_headers: record[:request_headers],
|
|
99
|
+
response_headers: record[:response_headers],
|
|
100
|
+
request_body: record[:request_body],
|
|
101
|
+
response_body: record[:response_body],
|
|
102
|
+
status_code: record[:status_code],
|
|
103
|
+
duration_ms: record[:duration_ms],
|
|
104
|
+
ip_address: record[:ip_address],
|
|
105
|
+
session_id: record[:session_id],
|
|
106
|
+
logs: record[:logs],
|
|
107
|
+
exception: record[:exception],
|
|
108
|
+
memory_delta: record[:memory_delta]
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
broadcast_new_request(record)
|
|
112
|
+
cleanup_old_requests
|
|
113
|
+
rescue => e
|
|
114
|
+
Rails.logger.error "[Binocs] Failed to save request: #{e.message}"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def broadcast_new_request(record)
|
|
118
|
+
unless defined?(Turbo::StreamsChannel)
|
|
119
|
+
Rails.logger.debug "[Binocs] Turbo::StreamsChannel not defined, skipping broadcast"
|
|
120
|
+
return
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
request = Binocs::Request.find_by(uuid: record[:uuid])
|
|
124
|
+
unless request
|
|
125
|
+
Rails.logger.debug "[Binocs] Request not found for broadcast: #{record[:uuid]}"
|
|
126
|
+
return
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
Rails.logger.debug "[Binocs] Broadcasting new request: #{request.method} #{request.path}"
|
|
130
|
+
|
|
131
|
+
Turbo::StreamsChannel.broadcast_prepend_to(
|
|
132
|
+
"binocs_requests",
|
|
133
|
+
target: "requests-list",
|
|
134
|
+
partial: "binocs/requests/request",
|
|
135
|
+
locals: { request: request }
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
Rails.logger.debug "[Binocs] Broadcast complete"
|
|
139
|
+
rescue => e
|
|
140
|
+
Rails.logger.error "[Binocs] Failed to broadcast request: #{e.message}"
|
|
141
|
+
Rails.logger.error e.backtrace.first(5).join("\n")
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def cleanup_old_requests
|
|
145
|
+
return unless rand < 0.01 # Only run 1% of the time
|
|
146
|
+
|
|
147
|
+
max_requests = Binocs.configuration.max_requests
|
|
148
|
+
retention_period = Binocs.configuration.retention_period
|
|
149
|
+
|
|
150
|
+
Binocs::Request.where("created_at < ?", retention_period.ago).delete_all
|
|
151
|
+
|
|
152
|
+
count = Binocs::Request.count
|
|
153
|
+
if count > max_requests
|
|
154
|
+
Binocs::Request.order(created_at: :asc).limit(count - max_requests).delete_all
|
|
155
|
+
end
|
|
156
|
+
rescue => e
|
|
157
|
+
Rails.logger.error "[Binocs] Failed to cleanup old requests: #{e.message}"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def sanitize_params(request)
|
|
161
|
+
params = request.filtered_parameters.except("controller", "action")
|
|
162
|
+
params.deep_transform_values { |v| truncate_value(v) }
|
|
163
|
+
rescue
|
|
164
|
+
{}
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def extract_headers(headers)
|
|
168
|
+
result = {}
|
|
169
|
+
headers.each do |key, value|
|
|
170
|
+
next unless key.start_with?("HTTP_") || %w[CONTENT_TYPE CONTENT_LENGTH].include?(key)
|
|
171
|
+
|
|
172
|
+
header_name = key.sub(/^HTTP_/, "").split("_").map(&:capitalize).join("-")
|
|
173
|
+
result[header_name] = value.to_s
|
|
174
|
+
end
|
|
175
|
+
result.except("Cookie") # Don't store cookies for security
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def extract_request_body(request)
|
|
179
|
+
return nil unless Binocs.configuration.record_request_body
|
|
180
|
+
return nil if ignored_content_type?(request.content_type)
|
|
181
|
+
|
|
182
|
+
body = request.body.read
|
|
183
|
+
request.body.rewind
|
|
184
|
+
truncate_body(body)
|
|
185
|
+
rescue
|
|
186
|
+
nil
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def extract_response_body(response, headers)
|
|
190
|
+
return nil unless Binocs.configuration.record_response_body
|
|
191
|
+
|
|
192
|
+
content_type = headers["Content-Type"]
|
|
193
|
+
return nil if ignored_content_type?(content_type)
|
|
194
|
+
|
|
195
|
+
body = ""
|
|
196
|
+
response.each { |part| body << part.to_s }
|
|
197
|
+
truncate_body(body)
|
|
198
|
+
rescue
|
|
199
|
+
nil
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def ignored_content_type?(content_type)
|
|
203
|
+
return true if content_type.nil?
|
|
204
|
+
|
|
205
|
+
Binocs.configuration.ignored_content_types.any? do |ignored|
|
|
206
|
+
content_type.to_s.include?(ignored)
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def extract_route_name(env)
|
|
211
|
+
route = Rails.application.routes.recognize_path(
|
|
212
|
+
env["PATH_INFO"],
|
|
213
|
+
method: env["REQUEST_METHOD"]
|
|
214
|
+
)
|
|
215
|
+
"#{route[:controller]}##{route[:action]}"
|
|
216
|
+
rescue
|
|
217
|
+
nil
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def truncate_value(value)
|
|
221
|
+
return value unless value.is_a?(String)
|
|
222
|
+
return value if value.length <= 1000
|
|
223
|
+
|
|
224
|
+
"#{value[0, 1000]}... (truncated)"
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def truncate_body(body)
|
|
228
|
+
return nil if body.nil? || body.empty?
|
|
229
|
+
|
|
230
|
+
max_size = Binocs.configuration.max_body_size
|
|
231
|
+
if body.bytesize > max_size
|
|
232
|
+
"#{body.byteslice(0, max_size)}... (truncated, #{body.bytesize} bytes total)"
|
|
233
|
+
else
|
|
234
|
+
body
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def calculate_duration
|
|
239
|
+
start_time = Thread.current[:binocs_start_time]
|
|
240
|
+
return nil unless start_time
|
|
241
|
+
|
|
242
|
+
((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(2)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def get_memory_usage
|
|
246
|
+
`ps -o rss= -p #{Process.pid}`.to_i * 1024 rescue 0
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def calculate_memory_delta
|
|
250
|
+
before = Thread.current[:binocs_memory_before]
|
|
251
|
+
return nil unless before
|
|
252
|
+
|
|
253
|
+
get_memory_usage - before
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def cleanup_thread_locals
|
|
257
|
+
Thread.current[:binocs_request_id] = nil
|
|
258
|
+
Thread.current[:binocs_logs] = nil
|
|
259
|
+
Thread.current[:binocs_start_time] = nil
|
|
260
|
+
Thread.current[:binocs_memory_before] = nil
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'yaml'
|
|
6
|
+
|
|
7
|
+
module Binocs
|
|
8
|
+
module Swagger
|
|
9
|
+
class Client
|
|
10
|
+
CACHE_TTL = 300 # 5 minutes
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
def fetch_spec
|
|
14
|
+
return nil unless Binocs.configuration.swagger_enabled?
|
|
15
|
+
|
|
16
|
+
if cached_spec_valid?
|
|
17
|
+
@cached_spec
|
|
18
|
+
else
|
|
19
|
+
refresh_spec
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def clear_cache
|
|
24
|
+
@cached_spec = nil
|
|
25
|
+
@cache_time = nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def cached_spec_valid?
|
|
31
|
+
@cached_spec && @cache_time && (Time.now - @cache_time) < CACHE_TTL
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def refresh_spec
|
|
35
|
+
spec_url = build_spec_url
|
|
36
|
+
return nil unless spec_url
|
|
37
|
+
|
|
38
|
+
begin
|
|
39
|
+
response = fetch_with_redirects(spec_url)
|
|
40
|
+
return nil unless response
|
|
41
|
+
|
|
42
|
+
@cached_spec = parse_spec(response.body, response['content-type'])
|
|
43
|
+
@cache_time = Time.now
|
|
44
|
+
@cached_spec
|
|
45
|
+
rescue StandardError => e
|
|
46
|
+
Rails.logger.error("[Binocs] Failed to fetch Swagger spec: #{e.message}") if defined?(Rails)
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def fetch_with_redirects(url, limit = 5)
|
|
52
|
+
return nil if limit == 0
|
|
53
|
+
|
|
54
|
+
uri = URI(url)
|
|
55
|
+
response = Net::HTTP.get_response(uri)
|
|
56
|
+
|
|
57
|
+
case response
|
|
58
|
+
when Net::HTTPSuccess
|
|
59
|
+
response
|
|
60
|
+
when Net::HTTPRedirection
|
|
61
|
+
redirect_url = response['location']
|
|
62
|
+
# Handle relative redirects
|
|
63
|
+
redirect_url = URI.join(url, redirect_url).to_s unless redirect_url.start_with?('http')
|
|
64
|
+
fetch_with_redirects(redirect_url, limit - 1)
|
|
65
|
+
else
|
|
66
|
+
nil
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def build_spec_url
|
|
71
|
+
spec_path = Binocs.configuration.swagger_spec_url
|
|
72
|
+
return nil if spec_path.blank?
|
|
73
|
+
|
|
74
|
+
# If it's already a full URL, use it directly
|
|
75
|
+
return spec_path if spec_path.start_with?('http://', 'https://')
|
|
76
|
+
|
|
77
|
+
# Otherwise, build from Rails default_url_options or localhost
|
|
78
|
+
if defined?(Rails)
|
|
79
|
+
host = Rails.application.routes.default_url_options[:host] || 'localhost'
|
|
80
|
+
port = Rails.application.routes.default_url_options[:port] || 3000
|
|
81
|
+
protocol = Rails.application.routes.default_url_options[:protocol] || 'http'
|
|
82
|
+
"#{protocol}://#{host}:#{port}#{spec_path}"
|
|
83
|
+
else
|
|
84
|
+
"http://localhost:3000#{spec_path}"
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def parse_spec(body, content_type)
|
|
89
|
+
if content_type&.include?('yaml') || body.strip.start_with?('openapi:', 'swagger:')
|
|
90
|
+
YAML.safe_load(body, permitted_classes: [Date, Time])
|
|
91
|
+
else
|
|
92
|
+
JSON.parse(body)
|
|
93
|
+
end
|
|
94
|
+
rescue StandardError
|
|
95
|
+
nil
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Binocs
|
|
4
|
+
module Swagger
|
|
5
|
+
class PathMatcher
|
|
6
|
+
class << self
|
|
7
|
+
def find_operation(request)
|
|
8
|
+
spec = Client.fetch_spec
|
|
9
|
+
return nil unless spec && spec['paths']
|
|
10
|
+
|
|
11
|
+
method = request.method.downcase
|
|
12
|
+
path = normalize_path(request.path)
|
|
13
|
+
|
|
14
|
+
spec['paths'].each do |spec_path, path_item|
|
|
15
|
+
next unless path_item.is_a?(Hash)
|
|
16
|
+
|
|
17
|
+
operation = path_item[method]
|
|
18
|
+
next unless operation
|
|
19
|
+
|
|
20
|
+
if path_matches?(path, spec_path)
|
|
21
|
+
return build_operation_result(spec_path, method, operation, path_item, spec)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def build_swagger_ui_url(operation)
|
|
29
|
+
return nil unless operation
|
|
30
|
+
|
|
31
|
+
base_url = Binocs.configuration.swagger_ui_url
|
|
32
|
+
return nil if base_url.blank?
|
|
33
|
+
|
|
34
|
+
# Build full URL if needed
|
|
35
|
+
unless base_url.start_with?('http://', 'https://')
|
|
36
|
+
if defined?(Rails)
|
|
37
|
+
host = Rails.application.routes.default_url_options[:host] || 'localhost'
|
|
38
|
+
port = Rails.application.routes.default_url_options[:port] || 3000
|
|
39
|
+
protocol = Rails.application.routes.default_url_options[:protocol] || 'http'
|
|
40
|
+
base_url = "#{protocol}://#{host}:#{port}#{base_url}"
|
|
41
|
+
else
|
|
42
|
+
base_url = "http://localhost:3000#{base_url}"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Build anchor for Swagger UI
|
|
47
|
+
# Format: #/{tag}/{operationId}
|
|
48
|
+
# Example: #/Company%20Invitations/get_v1_companies__company_uuid__invitations
|
|
49
|
+
tag = operation[:tags]&.first || 'default'
|
|
50
|
+
encoded_tag = URI.encode_www_form_component(tag).gsub('+', '%20')
|
|
51
|
+
|
|
52
|
+
if operation[:operation_id]
|
|
53
|
+
"#{base_url}#/#{encoded_tag}/#{operation[:operation_id]}"
|
|
54
|
+
else
|
|
55
|
+
# Fallback: build operation ID from method and path
|
|
56
|
+
# get /v1/companies/{company_uuid}/invitations -> get_v1_companies__company_uuid__invitations
|
|
57
|
+
fallback_op_id = "#{operation[:method]}#{operation[:spec_path]}"
|
|
58
|
+
.gsub('/', '_')
|
|
59
|
+
.gsub(/[{}]/, '_')
|
|
60
|
+
.gsub(/__+/, '__')
|
|
61
|
+
"#{base_url}#/#{encoded_tag}/#{fallback_op_id}"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def normalize_path(path)
|
|
68
|
+
# Remove query string and normalize
|
|
69
|
+
path = path.split('?').first
|
|
70
|
+
path = path.chomp('/')
|
|
71
|
+
path = '/' if path.empty?
|
|
72
|
+
path
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def path_matches?(request_path, spec_path)
|
|
76
|
+
# Convert spec path template to regex
|
|
77
|
+
# /users/{id}/posts/{post_id} -> /users/[^/]+/posts/[^/]+
|
|
78
|
+
pattern = spec_path.gsub(/\{[^}]+\}/, '[^/]+')
|
|
79
|
+
pattern = "^#{pattern}$"
|
|
80
|
+
|
|
81
|
+
Regexp.new(pattern).match?(request_path)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def build_operation_result(spec_path, method, operation, path_item, spec)
|
|
85
|
+
{
|
|
86
|
+
spec_path: spec_path,
|
|
87
|
+
method: method,
|
|
88
|
+
operation_id: operation['operationId'],
|
|
89
|
+
summary: operation['summary'],
|
|
90
|
+
description: operation['description'],
|
|
91
|
+
tags: operation['tags'] || [],
|
|
92
|
+
parameters: collect_parameters(operation, path_item),
|
|
93
|
+
request_body: operation['requestBody'],
|
|
94
|
+
responses: operation['responses'] || {},
|
|
95
|
+
deprecated: operation['deprecated'] || false,
|
|
96
|
+
security: operation['security'] || spec['security']
|
|
97
|
+
}
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def collect_parameters(operation, path_item)
|
|
101
|
+
params = []
|
|
102
|
+
|
|
103
|
+
# Path-level parameters
|
|
104
|
+
if path_item['parameters'].is_a?(Array)
|
|
105
|
+
params.concat(path_item['parameters'])
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Operation-level parameters
|
|
109
|
+
if operation['parameters'].is_a?(Array)
|
|
110
|
+
params.concat(operation['parameters'])
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
params.uniq { |p| "#{p['in']}-#{p['name']}" }
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|