diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..b5bcb01 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,15 @@ +require: + - rubocop-rake + - rubocop-rspec + +AllCops: + NewCops: enable + +Layout/HeredocIndentation: + Exclude: + - 'lib/hawksi.rb' + +Metrics/BlockLength: + Exclude: + - 'spec/**/*' + - '*.gemspec' \ No newline at end of file diff --git a/Gemfile b/Gemfile index 4b5a326..c89eded 100644 --- a/Gemfile +++ b/Gemfile @@ -2,14 +2,20 @@ source 'https://rubygems.org' -gem 'json' gem 'httpx', '~> 1.3' -gem "puma", ">= 6.4.3" +gem 'json' +gem 'puma', '>= 6.4.3' gem 'rack' gem 'thor' group :development, :test do + gem 'bundler' + gem 'rake' gem 'rspec', '~> 3.13' + gem 'rubocop' + gem 'rubocop-rake' + gem 'rubocop-rspec' + gem 'ruby-lsp' end group :test do diff --git a/Gemfile.lock b/Gemfile.lock index df96097..f143962 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,17 +1,31 @@ GEM remote: https://rubygems.org/ specs: + ast (2.4.2) diff-lcs (1.5.1) http-2 (1.0.1) httpx (1.3.0) http-2 (>= 1.0.0) json (2.7.1) + language_server-protocol (3.17.0.3) + logger (1.6.1) nio4r (2.7.3) + parallel (1.26.3) + parser (3.3.5.0) + ast (~> 2.4.1) + racc + prism (1.0.0) puma (6.4.3) nio4r (~> 2.0) + racc (1.8.1) rack (2.2.9) rack-test (1.1.0) rack (>= 1.0, < 3) + rainbow (3.1.1) + rake (13.2.1) + rbs (3.5.3) + logger + regexp_parser (2.9.2) rspec (3.13.0) rspec-core (~> 3.13.0) rspec-expectations (~> 3.13.0) @@ -25,19 +39,49 @@ GEM diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) rspec-support (3.13.1) + rubocop (1.66.1) + json (~> 2.3) + language_server-protocol (>= 3.17.0) + parallel (~> 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 2.4, < 3.0) + rubocop-ast (>= 1.32.2, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 3.0) + rubocop-ast (1.32.3) + parser (>= 3.3.1.0) + rubocop-rake (0.6.0) + rubocop (~> 1.0) + rubocop-rspec (3.0.5) + rubocop (~> 1.61) + ruby-lsp (0.18.2) + language_server-protocol (~> 3.17.0) + prism (~> 1.0) + rbs (>= 3, < 4) + sorbet-runtime (>= 0.5.10782) + ruby-progressbar (1.13.0) + sorbet-runtime (0.5.11577) thor (1.3.1) + unicode-display_width (2.5.0) PLATFORMS arm64-darwin-23 ruby DEPENDENCIES + bundler httpx (~> 1.3) json puma (>= 6.4.3) rack rack-test (~> 1.1) + rake rspec (~> 3.13) + rubocop + rubocop-rake + rubocop-rspec + ruby-lsp thor BUNDLED WITH diff --git a/Rakefile b/Rakefile index 99cd8e6..752beb3 100644 --- a/Rakefile +++ b/Rakefile @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'bundler/gem_tasks' require 'rspec/core/rake_task' @@ -11,11 +13,11 @@ task :build do end desc 'Install the Hawksi gem locally' -task :install => :build do +task install: :build do system "gem install ./hawksi-#{Hawksi::VERSION}.gem" end desc 'Release the Hawksi gem to RubyGems' -task :release => :build do +task release: :build do system "gem push ./hawksi-#{Hawksi::VERSION}.gem" end diff --git a/bin/hawksi b/bin/hawksi index 3118cea..7796b14 100755 --- a/bin/hawksi +++ b/bin/hawksi @@ -1,3 +1,5 @@ #!/usr/bin/env ruby +# frozen_string_literal: true + require_relative '../lib/hawksi' CLI.start(ARGV) diff --git a/hawksi.gemspec b/hawksi.gemspec index 8d9d205..6a6dad1 100644 --- a/hawksi.gemspec +++ b/hawksi.gemspec @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # hawksi.gemspec require_relative 'lib/version' @@ -8,7 +10,8 @@ Gem::Specification.new do |spec| spec.email = ['engineering@mocksi.ai'] spec.summary = 'Hawksi: Rack middleware to the Mocksi API.' - spec.description = 'Hawksi sits between your application and the Mocksi API, allowing our agents to learn from your app to simulate whatever you can imagine.' + spec.description = 'Hawksi sits between your application and the Mocksi API,\n' + spec.description += 'allowing our agents to learn from your app to simulate whatever you can imagine.' spec.homepage = 'https://github.com/Mocksi/hawksi' spec.license = 'MIT' spec.required_ruby_version = Gem::Requirement.new('>= 3.2.0') @@ -20,22 +23,19 @@ Gem::Specification.new do |spec| spec.files = Dir.chdir(File.expand_path(__dir__)) do `git ls-files -z`.split("\x0").reject do |f| %w[test spec features .gitignore hawksi.gemspec].include?(f) || - f.match?(/(^\.|\/\.\.|\.\.\/|\.git|\.hg|CVS|\.svn|\.lock|~$)/) || - f.end_with?('.gem') # Exclude gem files + f.match?(%r{(^\.|/\.\.|\.\./|\.git|\.hg|CVS|\.svn|\.lock|~$)}) || + f.end_with?('.gem') # Exclude gem files end end spec.bindir = 'bin' spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } spec.require_paths = ['lib'] - spec.add_dependency 'rack', '~> 2.2' + spec.add_dependency 'httpx', '~> 1.3' + spec.add_dependency 'json', '~> 2.5' spec.add_dependency 'puma', '~> 5.0' + spec.add_dependency 'rack', '~> 2.2' spec.add_dependency 'thor', '~> 1.1' - spec.add_dependency 'json', '~> 2.5' - spec.add_dependency 'httpx', '~> 1.3' - - spec.add_development_dependency 'bundler', '~> 2.2' - spec.add_development_dependency 'rake', '~> 13.0' - spec.add_development_dependency 'rspec', '~> 3.10' + spec.metadata['rubygems_mfa_required'] = 'true' end diff --git a/lib/captures_cli.rb b/lib/captures_cli.rb index 68055df..def05eb 100644 --- a/lib/captures_cli.rb +++ b/lib/captures_cli.rb @@ -1,10 +1,14 @@ +# frozen_string_literal: true + require 'thor' require_relative 'file_storage' +# CLI for listing captured requests and responses class CapturesCLI < Thor - desc "list", "Lists recent captured requests and responses" - option :base_dir, type: :string, desc: 'Base directory for storing intercepted data. Defaults to ./tmp/intercepted_data' - def list(*args) + desc 'list', 'Lists recent captured requests and responses' + option :base_dir, type: :string, + desc: 'Base directory for storing intercepted data. Defaults to ./tmp/intercepted_data' + def list(*_args) # rubocop:disable Metrics/MethodLength base_dir = FileStorage.base_dir FileStorage.base_dir = options[:base_dir] if options[:base_dir] @@ -14,7 +18,7 @@ def list(*args) files = Dir.glob(glob_pattern) if files.empty? - puts "No captured requests or responses found." + puts 'No captured requests or responses found.' return end diff --git a/lib/cli.rb b/lib/cli.rb index 89595e0..b1461e5 100644 --- a/lib/cli.rb +++ b/lib/cli.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'thor' require 'puma' require 'puma/cli' @@ -6,33 +8,35 @@ require_relative 'captures_cli' require_relative 'uploads_cli' +# CLI for starting and stopping the Hawksi Interceptor server class CLI < Thor - desc "start", "Starts the Hawksi Interceptor server" - option :base_dir, type: :string, desc: 'Base directory for storing intercepted data. Defaults to ./tmp/intercepted_data' + desc 'start', 'Starts the Hawksi Interceptor server' + option :base_dir, type: :string, + desc: 'Base directory for storing intercepted data. Defaults to ./tmp/intercepted_data' def start(*args) FileStorage.base_dir = options[:base_dir] if options[:base_dir] - puts "Starting HawksiInterceptor server..." + puts 'Starting HawksiInterceptor server...' Puma::CLI.new(args).run end map 'serve' => 'start' - desc "stop", "Stops the HawksiInterceptor server" + desc 'stop', 'Stops the HawksiInterceptor server' def stop - puts "Stopping Hawksi Interceptor server..." - system("pkill -f puma") + puts 'Stopping Hawksi Interceptor server...' + system('pkill -f puma') end - desc "captures", "Manage captures" - subcommand "captures", CapturesCLI + desc 'captures', 'Manage captures' + subcommand 'captures', CapturesCLI - desc "uploads", "Uploads captured requests and responses" - subcommand "uploads", UploadsCLI + desc 'uploads', 'Uploads captured requests and responses' + subcommand 'uploads', UploadsCLI - desc "clear", "Clears stored request/response data" + desc 'clear', 'Clears stored request/response data' def clear FileUtils.rm_rf('./intercepted_data/requests') FileUtils.rm_rf('./intercepted_data/responses') - puts "Cleared stored data." + puts 'Cleared stored data.' end end diff --git a/lib/command_executor.rb b/lib/command_executor.rb index 314a64c..217d5c7 100644 --- a/lib/command_executor.rb +++ b/lib/command_executor.rb @@ -1,7 +1,10 @@ +# frozen_string_literal: true + require 'httpx' require 'json' require 'logger' +# Generates and sends commands to the Reactor endpoint. class CommandExecutor attr_reader :logger, :client_uuid, :endpoint_url @@ -11,12 +14,12 @@ def initialize(logger, client_uuid) @endpoint_url = Hawksi.configuration.reactor_url end - def execute_command(command, params) + def execute_command(command, params) # rubocop:disable Metrics/MethodLength request_body = build_request_body(command, params) response = send_request(request_body) if response.nil? - logger.error "Failed to execute command due to a request error." + logger.error 'Failed to execute command due to a request error.' elsif response.is_a?(HTTPX::ErrorResponse) logger.error "HTTPX Error: #{response.error.message}" elsif response.status == 200 @@ -31,7 +34,7 @@ def execute_command(command, params) def build_request_body(command, params) { client_id: client_uuid, - command: command, + command:, instructions: params.join(' ') }.to_json end @@ -39,8 +42,9 @@ def build_request_body(command, params) def send_request(request_body) logger.info "sending request to #{endpoint_url}" logger.info "request body: #{request_body}" - HTTPX.post(endpoint_url, headers: { "Content-Type" => "application/json", "x-client-id" => client_uuid }, body: request_body) - rescue => e + HTTPX.post(endpoint_url, headers: { 'Content-Type' => 'application/json', 'x-client-id' => client_uuid }, + body: request_body) + rescue StandardError => e logger.error "Failed to send request: #{e.message}" nil end diff --git a/lib/file_handler.rb b/lib/file_handler.rb index c8876c0..704fb1c 100644 --- a/lib/file_handler.rb +++ b/lib/file_handler.rb @@ -1,6 +1,9 @@ +# frozen_string_literal: true + require 'fileutils' require 'securerandom' +# Handles file operations. class FileHandler def initialize(base_dir, logger) @base_dir = base_dir @@ -25,7 +28,7 @@ def generate_client_uuid client_uuid end - def create_tar_gz_files(files) + def create_tar_gz_files(files) # rubocop:disable Metrics/MethodLength tar_gz_files = [] files.each do |file| tar_file = "#{file}.tar" @@ -39,7 +42,6 @@ def create_tar_gz_files(files) system("gzip #{tar_file}") end - tar_gz_files << tar_gz_file end tar_gz_files diff --git a/lib/file_storage.rb b/lib/file_storage.rb index 8d05182..6a61150 100644 --- a/lib/file_storage.rb +++ b/lib/file_storage.rb @@ -1,7 +1,10 @@ +# frozen_string_literal: true + require 'json' require 'fileutils' require 'securerandom' +# Handles file storage.. class FileStorage def self.base_dir @base_dir ||= ENV['HAWKSI_BASE_DIR'] || './tmp/intercepted_data' @@ -24,9 +27,7 @@ def self.store(type, data) file_path = File.join(dir, filename) puts("Storing data in: #{file_path}") - File.open(file_path, 'w') do |file| - file.write(data.to_json) - end + File.write(file_path, data.to_json) puts("Data stored in: #{file_path}") end end diff --git a/lib/file_uploader.rb b/lib/file_uploader.rb index 8bc4453..c46f296 100644 --- a/lib/file_uploader.rb +++ b/lib/file_uploader.rb @@ -1,6 +1,9 @@ +# frozen_string_literal: true + require 'httpx' require 'logger' +# Initializes a new instance of FileUploader. class FileUploader # FIXME: use a base URL for the upload and process URLs def initialize(logger, client_uuid) @@ -14,11 +17,11 @@ def upload_files(tar_gz_files) wait_for_threads(threads) end - def process_files + def process_files # rubocop:disable Metrics/MethodLength HTTPX.wrap do |client| response = begin - client.post(Hawksi.configuration.process_url, headers: { "x-client-id" => @client_uuid }) - rescue => e + client.post(Hawksi.configuration.process_url, headers: { 'x-client-id' => @client_uuid }) + rescue StandardError => e @logger.error "Failed to process files. Error: #{e.message}" end @@ -61,18 +64,18 @@ def upload_file(tar_gz_file) def post_file(client, tar_gz_file) filename = File.basename(tar_gz_file) client.post("#{Hawksi.configuration.upload_url}?filename=#{filename}", - headers: { "x-client-id" => @client_uuid }, + headers: { 'x-client-id' => @client_uuid }, body: File.read(tar_gz_file)) - rescue => e + rescue StandardError => e @logger.error "Failed to upload #{tar_gz_file}: #{e.message}" nil end def log_upload_result(tar_gz_file, response) - if response && response.is_a?(HTTPX::Response) && response.status == 200 + if response.is_a?(HTTPX::Response) && response.status == 200 @logger.info "Uploaded #{tar_gz_file}: #{response.status}" else @logger.error "Failed to upload #{tar_gz_file}. Status: #{response&.status}, Body: #{response&.body}" end end -end \ No newline at end of file +end diff --git a/lib/hawksi/configuration.rb b/lib/hawksi/configuration.rb index f1b7eec..ca42023 100644 --- a/lib/hawksi/configuration.rb +++ b/lib/hawksi/configuration.rb @@ -1,14 +1,18 @@ +# frozen_string_literal: true + +# Top level module module Hawksi + # Global mocksi configurations class Configuration attr_accessor :mocksi_server, :reactor_url, :upload_url, :process_url def initialize - @mocksi_server = get_env('MOCKSI_SERVER') || "https://app.mocksi.ai" - @reactor_url = get_env('MOCKSI_REACTOR_URL') || "https://api.mocksi.ai/api/v1/reactor" - @upload_url = get_env('MOCKSI_UPLOAD_URL') || "https://api.mocksi.ai/api/v1/upload" - @process_url = get_env('MOCKSI_PROCESS_URL') || "https://api.mocksi.ai/api/v1/process" + @mocksi_server = get_env('MOCKSI_SERVER') || 'https://app.mocksi.ai' + @reactor_url = get_env('MOCKSI_REACTOR_URL') || 'https://api.mocksi.ai/api/v1/reactor' + @upload_url = get_env('MOCKSI_UPLOAD_URL') || 'https://api.mocksi.ai/api/v1/upload' + @process_url = get_env('MOCKSI_PROCESS_URL') || 'https://api.mocksi.ai/api/v1/process' end - + private def get_env(key) @@ -26,4 +30,4 @@ def configure yield(configuration) end end -end \ No newline at end of file +end diff --git a/lib/mocksi_handler.rb b/lib/mocksi_handler.rb index e3aef21..8b4f001 100644 --- a/lib/mocksi_handler.rb +++ b/lib/mocksi_handler.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'httpx' HAWK_SVG = <<~SVG @@ -6,44 +8,56 @@ SVG +# Handles calls to /mocksi module MocksiHandler class << self - def handle(request) - if request.path == '/favicon.ico' - return [200, { 'Content-Type' => 'image/svg+xml' }, [HAWK_SVG]] + def fetch_mocksi_server_url + mocksi_server_url = Hawksi.configuration.mocksi_server + raise 'Mocksi server URL not configured' if mocksi_server_url.nil? || mocksi_server_url.empty? + end + + def prep_headers((request)) + headers = {} + request.env.each do |key, value| + if key.start_with?('HTTP_') + header_key = key.sub(/^HTTP_/, '').split('_').map(&:capitalize).join('-') + headers[header_key] = value + end + ## Yay for Rack's weirdness. See https://github.com/rack/rack/issues/1311 + headers['Content-Type'] = value if key == 'CONTENT_TYPE' + headers['Content-Length'] = value if key == 'CONTENT_LENGTH' end + headers + end - begin - # Get the mocksi server URL from configuration - mocksi_server_url = Hawksi.configuration.mocksi_server - raise "Mocksi server URL not configured" if mocksi_server_url.nil? || mocksi_server_url.empty? + def build_response_body(response) # rubocop:disable Metrics/MethodLength + response_headers = response.headers.dup + # Check for chunked transfer encoding and remove it + response_headers.delete('transfer-encoding') if response_headers['transfer-encoding']&.include?('chunked') + + response_body = response.body.to_s + if response_headers['content-encoding']&.include?('gzip') + response_body = safe_decompress_gzip(response_body) + response_headers.delete('content-encoding') # Remove content-encoding since the content is decompressed + elsif response_headers['content-encoding']&.include?('deflate') + response_body = decompress_deflate(response_body) + response_headers.delete('content-encoding') # Remove content-encoding since the content is decompressed + end + response_body + end - # Prepare the full URL (mocksi_server + request path + query string) - target_uri = URI.join(mocksi_server_url, request.fullpath) + def handle(request) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize + return [200, { 'Content-Type' => 'image/svg+xml' }, [HAWK_SVG]] if request.path == '/favicon.ico' - # Prepare headers from the request - headers = {} - request.env.each do |key, value| - if key.start_with?('HTTP_') - header_key = key.sub(/^HTTP_/, '').split('_').map(&:capitalize).join('-') - headers[header_key] = value - end - ## Yay for Rack's weirdness. See https://github.com/rack/rack/issues/1311 - if key == 'CONTENT_TYPE' - headers['Content-Type'] = value - end - if key == 'CONTENT_LENGTH' - headers['Content-Length'] = value - end - end + begin + mocksi_server_url = fetch_mocksi_server_url + target_uri = URI.join(mocksi_server_url, request.fullpath) - # Forward the cookies - if request.cookies.any? - headers['Cookie'] = request.cookies.map { |k, v| "#{k}=#{v}" }.join('; ') - end + headers = prep_headers(request) + headers['Cookie'] = request.cookies.map { |k, v| "#{k}=#{v}" }.join('; ') if request.cookies.any? # Initialize httpx with headers - http_client = HTTPX.with(headers: headers) + http_client = HTTPX.with(headers:) # Forward the body content if it's a POST or PUT request body = nil @@ -52,30 +66,11 @@ def handle(request) body = request.body.read end - # Make the HTTP request using the appropriate method - response = http_client.request(request.request_method.downcase.to_sym, target_uri, body: body) - - # Clone headers to allow modification - response_headers = response.headers.dup - - # Check for chunked transfer encoding and remove it - if response_headers["transfer-encoding"]&.include?("chunked") - response_headers.delete("transfer-encoding") - end - - # Handle gzip or deflate content-encoding if present - response_body = response.body.to_s - if response_headers["content-encoding"]&.include?("gzip") - response_body = safe_decompress_gzip(response_body) - response_headers.delete("content-encoding") # Remove content-encoding since the content is decompressed - elsif response_headers["content-encoding"]&.include?("deflate") - response_body = decompress_deflate(response_body) - response_headers.delete("content-encoding") # Remove content-encoding since the content is decompressed - end + response = http_client.request(request.request_method.downcase.to_sym, target_uri, body:) # Return the response in a format compatible with Rack [response.status, response_headers, [response_body]] - rescue => e + rescue StandardError => e # Handle any errors that occur during the reverse proxy operation [500, { 'Content-Type' => 'text/plain' }, ["Error: #{e.message}"]] end @@ -102,4 +97,4 @@ def decompress_deflate(body) Zlib::Inflate.inflate(body) end end -end \ No newline at end of file +end diff --git a/lib/request_interceptor.rb b/lib/request_interceptor.rb index 85fd47c..a44af02 100644 --- a/lib/request_interceptor.rb +++ b/lib/request_interceptor.rb @@ -1,10 +1,17 @@ +# frozen_string_literal: true + require 'json' require 'logger' require 'digest' -require_relative './file_storage' -require_relative './mocksi_handler' +require_relative 'file_storage' +require_relative 'mocksi_handler' module Hawksi + # Initializes a new instance of RequestInterceptor. + # + # @param app [Rack::Builder] The Rack application to wrap. + # @param logger [Logger] The logger instance to use for logging. + # @param storage [FileStorage] The file storage instance to use for storing requests and responses. class RequestInterceptor def initialize(app, logger: Logger.new('hawksi.log'), storage: FileStorage) @app = app @@ -15,14 +22,13 @@ def initialize(app, logger: Logger.new('hawksi.log'), storage: FileStorage) def call(env) request = Rack::Request.new(env) - if request.path.end_with?('/favicon.ico') - return MocksiHandler.handle(request) - end + return MocksiHandler.handle(request) if request.path.end_with?('/favicon.ico') if request.path.start_with?('/mocksi') || request.path.start_with?('/_') || request.path.start_with?('/api') return MocksiHandler.handle(request) end - request_hash = generate_request_hash(request) # Generate a hash of the request + + request_hash = generate_request_hash(request) # Generate a hash of the request log_request(request, request_hash) status, headers, response = @app.call(env) @@ -39,7 +45,7 @@ def generate_request_hash(request) request.request_method, request.path, request.query_string, - request.body&.read, # Read the body content to include in the hash + request.body&.read # Read the body content to include in the hash ].join # Reset the body input stream for future use @@ -49,71 +55,67 @@ def generate_request_hash(request) Digest::SHA256.hexdigest(hash_input) end - def log_request(request, request_hash) - begin - data = { - request_hash: request_hash, # Include the request hash in the logged data - method: request.request_method, - path: request.path, - query_string: request.query_string, - url: request.url, - scheme: request.scheme, - host: request.host, - port: request.port, - # Log only specific parts of the env hash to avoid circular references - env: { - 'REQUEST_METHOD' => request.env['REQUEST_METHOD'], - 'SCRIPT_NAME' => request.env['SCRIPT_NAME'], - 'PATH_INFO' => request.env['PATH_INFO'], - 'QUERY_STRING' => request.env['QUERY_STRING'], - 'SERVER_NAME' => request.env['SERVER_NAME'], - 'SERVER_PORT' => request.env['SERVER_PORT'], - 'REMOTE_ADDR' => request.env['REMOTE_ADDR'], - 'HTTP_HOST' => request.env['HTTP_HOST'], - 'HTTP_USER_AGENT' => request.env['HTTP_USER_AGENT'], - 'HTTP_COOKIE' => request.env['HTTP_COOKIE'], - 'HTTP_ACCEPT' => request.env['HTTP_ACCEPT'], - 'CONTENT_TYPE' => request.env['CONTENT_TYPE'], - 'CONTENT_LENGTH' => request.env['CONTENT_LENGTH'], - 'rack.session' => request.env['rack.session'] - }, - cookies: request.cookies, - params: request.params, - body: request.body&.read, - ip: request.ip, - xhr: request.xhr?, - content_type: request.content_type, - content_length: request.content_length, - capture_type: "request", - } - @logger.info("Request: #{data.to_json}") - @storage.store('requests', data) - rescue => e - @logger.error("Error logging request: #{e.message}") - end + def log_request(request, request_hash) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength + data = { + request_hash:, # Include the request hash in the logged data + method: request.request_method, + path: request.path, + query_string: request.query_string, + url: request.url, + scheme: request.scheme, + host: request.host, + port: request.port, + # Log only specific parts of the env hash to avoid circular references + env: { + 'REQUEST_METHOD' => request.env['REQUEST_METHOD'], + 'SCRIPT_NAME' => request.env['SCRIPT_NAME'], + 'PATH_INFO' => request.env['PATH_INFO'], + 'QUERY_STRING' => request.env['QUERY_STRING'], + 'SERVER_NAME' => request.env['SERVER_NAME'], + 'SERVER_PORT' => request.env['SERVER_PORT'], + 'REMOTE_ADDR' => request.env['REMOTE_ADDR'], + 'HTTP_HOST' => request.env['HTTP_HOST'], + 'HTTP_USER_AGENT' => request.env['HTTP_USER_AGENT'], + 'HTTP_COOKIE' => request.env['HTTP_COOKIE'], + 'HTTP_ACCEPT' => request.env['HTTP_ACCEPT'], + 'CONTENT_TYPE' => request.env['CONTENT_TYPE'], + 'CONTENT_LENGTH' => request.env['CONTENT_LENGTH'], + 'rack.session' => request.env['rack.session'] + }, + cookies: request.cookies, + params: request.params, + body: request.body&.read, + ip: request.ip, + xhr: request.xhr?, + content_type: request.content_type, + content_length: request.content_length, + capture_type: 'request' + } + @logger.info("Request: #{data.to_json}") + @storage.store('requests', data) + rescue StandardError => e + @logger.error("Error logging request: #{e.message}") end - def log_response(status, headers, response, request_hash) - begin - body = if response.respond_to?(:body) - response.body.join.to_s - else - response.join.to_s - end - data = { - request_hash: request_hash, # Include the request hash in the response log - status: status, - headers: headers, - body: body, - content_type: headers['Content-Type'], - content_length: headers['Content-Length'], - capture_type: "response" - } - @logger.info("Response: #{data.to_json}") - @storage.store('responses', data) - rescue => e - @logger.error("Error logging response: #{e.message}") - end + def log_response(status, headers, response, request_hash) # rubocop:disable Metrics/MethodLength + body = if response.respond_to?(:body) + response.body.join.to_s + else + response.join.to_s + end + data = { + request_hash:, # Include the request hash in the response log + status:, + headers:, + body:, + content_type: headers['Content-Type'], + content_length: headers['Content-Length'], + capture_type: 'response' + } + @logger.info("Response: #{data.to_json}") + @storage.store('responses', data) + rescue StandardError => e + @logger.error("Error logging response: #{e.message}") end end -end \ No newline at end of file +end diff --git a/lib/uploads_cli.rb b/lib/uploads_cli.rb index c619662..90ae362 100644 --- a/lib/uploads_cli.rb +++ b/lib/uploads_cli.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'thor' require 'securerandom' require 'httpx' @@ -9,26 +11,32 @@ require_relative 'command_executor' require 'logger' +# Initializes a new instance of UploadsCLI with the given options. +# +# Sets up a logger for writing to stdout, sets the base directory for file +# operations, creates a new FileHandler instance, a new FileUploader instance, +# and a new CommandExecutor instance. class UploadsCLI < Thor def initialize(*args) super - @logger = Logger.new(STDOUT) + @logger = Logger.new($stdout) @logger.level = Logger::INFO @base_dir = options[:base_dir] || FileStorage.base_dir @file_handler = FileHandler.new(@base_dir, @logger) - @client_uuid = get_client_uuid + @client_uuid = current_client_uuid @file_uploader = FileUploader.new(@logger, @client_uuid) @command_executor = CommandExecutor.new(@logger, @client_uuid) end - desc "update", "Update uploaded requests and responses" - option :base_dir, type: :string, desc: 'Base directory for storing intercepted data. Defaults to ./tmp/intercepted_data' - def update(*args) + desc 'update', 'Update uploaded requests and responses' + option :base_dir, type: :string, + desc: 'Base directory for storing intercepted data. Defaults to ./tmp/intercepted_data' + def update(*_args) set_base_dir files = find_files if files.empty? - @logger.info "No captured requests or responses found." + @logger.info 'No captured requests or responses found.' return end @@ -38,15 +46,17 @@ def update(*args) upload_files(tar_gz_files) end - desc "process", "Process uploaded requests and responses" - option :base_dir, type: :string, desc: 'Base directory for storing intercepted data. Defaults to ./tmp/intercepted_data' - def process(*args) + desc 'process', 'Process uploaded requests and responses' + option :base_dir, type: :string, + desc: 'Base directory for storing intercepted data. Defaults to ./tmp/intercepted_data' + def process(*_args) set_base_dir process_files end - desc "execute COMMAND PARAMS", "Execute a command with the given parameters" - option :base_dir, type: :string, desc: 'Base directory for storing intercepted data. Defaults to ./tmp/intercepted_data' + desc 'execute COMMAND PARAMS', 'Execute a command with the given parameters' + option :base_dir, type: :string, + desc: 'Base directory for storing intercepted data. Defaults to ./tmp/intercepted_data' def execute(command, *params) set_base_dir @command_executor.execute_command(command, params) @@ -58,7 +68,7 @@ def set_base_dir FileStorage.base_dir = @base_dir end - def get_client_uuid + def current_client_uuid @file_handler.generate_client_uuid end @@ -84,4 +94,4 @@ def upload_files(tar_gz_files) def process_files @file_uploader.process_files end -end \ No newline at end of file +end diff --git a/lib/version.rb b/lib/version.rb index 5da2e5e..fec3d4b 100644 --- a/lib/version.rb +++ b/lib/version.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Hawksi VERSION = '0.1.0' end diff --git a/spec/file_storage_spec.rb b/spec/file_storage_spec.rb index f2e9e0b..ca840a4 100644 --- a/spec/file_storage_spec.rb +++ b/spec/file_storage_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require 'file_storage' @@ -5,44 +7,44 @@ let(:storage_dir) { './spec/tmp/intercepted_data' } before do - FileStorage.base_dir = storage_dir + described_class.base_dir = storage_dir FileUtils.rm_rf(storage_dir) end describe '.store' do - it 'stores data in a JSON file' do + it 'stores data in a JSON file' do # rubocop:disable RSpec/ExampleLength,RSpec/MultipleExpectations data = { 'key' => 'value' } # Use string keys type = 'request' - FileStorage.store(type, data) + described_class.store(type, data) sleep(0.5) # Increase wait time file_path = Dir.glob("#{storage_dir}/#{type}/*.json").first - expect(file_path).to_not be_nil + expect(file_path).not_to be_nil expect(JSON.parse(File.read(file_path))).to eq(data) end it 'creates the directory if it does not exist' do type = 'request' - FileStorage.store(type, {}) + described_class.store(type, {}) - expect(Dir.exist?("#{storage_dir}/#{type}")).to be_truthy + expect(Dir).to exist("#{storage_dir}/#{type}") end - it 'stores data asynchronously' do + it 'stores data asynchronously' do # rubocop:disable RSpec/ExampleLength,RSpec/MultipleExpectations data = { key: 'value' } type = 'request' - expect { - FileStorage.store(type, data) - }.to_not raise_error + expect do + described_class.store(type, data) + end.not_to raise_error sleep(0.1) # Wait for the thread to finish file_path = Dir.glob("#{storage_dir}/#{type}/*.json").first - expect(file_path).to_not be_nil + expect(file_path).not_to be_nil end end end diff --git a/spec/lib/hawksi/configuration_spec.rb b/spec/lib/hawksi/configuration_spec.rb index 7ffb290..21d0254 100644 --- a/spec/lib/hawksi/configuration_spec.rb +++ b/spec/lib/hawksi/configuration_spec.rb @@ -1,3 +1,4 @@ +# frozen_string_literal: true require 'spec_helper' require 'hawksi/configuration' @@ -5,7 +6,7 @@ RSpec.describe Hawksi::Configuration do describe '.configuration' do it 'returns a Configuration instance' do - expect(Hawksi.configuration).to be_an_instance_of(Hawksi::Configuration) + expect(Hawksi.configuration).to be_an_instance_of(described_class) end it 'memoizes the configuration instance' do @@ -22,9 +23,9 @@ end describe '#initialize' do - let(:config) { Hawksi::Configuration.new } + let(:config) { described_class.new } - it 'sets default values for URLs' do + it 'sets default values for URLs' do # rubocop:disable RSpec/MultipleExpectations expect(config.instance_variable_get(:@mocksi_server)).to eq('https://app.mocksi.ai') expect(config.instance_variable_get(:@reactor_url)).to eq('https://api.mocksi.ai/api/v1/reactor') expect(config.instance_variable_get(:@upload_url)).to eq('https://api.mocksi.ai/api/v1/upload') @@ -46,14 +47,15 @@ ENV.delete('MOCKSI_PROCESS_URL') end - it 'uses environment variables for URLs' do - config = Hawksi::Configuration.new + it 'uses environment variables for URLs' do # rubocop:disable RSpec/MultipleExpectations + config = described_class.new expect(config.instance_variable_get(:@mocksi_server)).to eq('https://custom.mocksi.ai') expect(config.instance_variable_get(:@reactor_url)).to eq('https://custom.mocksi.ai/reactor') expect(config.instance_variable_get(:@upload_url)).to eq('https://custom.mocksi.ai/upload') expect(config.instance_variable_get(:@process_url)).to eq('https://custom.mocksi.ai/process') end end + context 'when a value is set to an empty string' do before do ENV['MOCKSI_SERVER'] = '' @@ -69,8 +71,8 @@ ENV.delete('MOCKSI_PROCESS_URL') end - it 'uses default values for empty string environment variables' do - config = Hawksi::Configuration.new + it 'uses default values for empty string environment variables' do # rubocop:disable RSpec/MultipleExpectations + config = described_class.new expect(config.instance_variable_get(:@mocksi_server)).to eq('https://app.mocksi.ai') expect(config.instance_variable_get(:@reactor_url)).to eq('https://api.mocksi.ai/api/v1/reactor') expect(config.instance_variable_get(:@upload_url)).to eq('https://api.mocksi.ai/api/v1/upload') diff --git a/spec/lib/request_interceptor_spec.rb b/spec/lib/request_interceptor_spec.rb index 6a12aac..9a0f9bc 100644 --- a/spec/lib/request_interceptor_spec.rb +++ b/spec/lib/request_interceptor_spec.rb @@ -1,15 +1,17 @@ +# frozen_string_literal: true + require 'spec_helper' require 'hawksi' require 'rack' require 'rack/test' -describe Hawksi::RequestInterceptor do +describe Hawksi::RequestInterceptor do # rubocop:disable RSpec/SpecFilePathFormat include Rack::Test::Methods - let(:app) { lambda { |env| [200, { 'Content-Type' => 'text/plain' }, ['Hello']] } } - let(:logger) { double('Logger') } - let(:storage) { double('FileStorage') } - let(:request_interceptor) { Hawksi::RequestInterceptor.new(app, logger: logger, storage: storage) } + let(:app) { ->(_env) { [200, { 'Content-Type' => 'text/plain' }, ['Hello']] } } + let(:logger) { double('Logger') } # rubocop:disable RSpec/VerifiedDoubles + let(:storage) { double('FileStorage') } # rubocop:disable RSpec/VerifiedDoubles + let(:request_interceptor) { described_class.new(app, logger:, storage:) } before do allow(logger).to receive(:info) @@ -22,23 +24,23 @@ allow(request_interceptor).to receive(:log_response) get '/' end - + it 'logs the request' do - expect(request_interceptor).to receive(:log_request).with(kind_of(Rack::Request), kind_of(String)) + expect(request_interceptor).to receive(:log_request).with(kind_of(Rack::Request), kind_of(String)) # rubocop:disable RSpec/MessageSpies request_interceptor.call(last_request.env) end it 'logs the response' do - expect(request_interceptor).to receive(:log_response).with(200, kind_of(Hash), kind_of(Array), kind_of(String)) + expect(request_interceptor).to receive(:log_response).with(200, kind_of(Hash), kind_of(Array), kind_of(String)) # rubocop:disable RSpec/MessageSpies request_interceptor.call(last_request.env) end it 'passes the request to the app' do - expect(app).to receive(:call).with(last_request.env).and_call_original + expect(app).to receive(:call).with(last_request.env).and_call_original # rubocop:disable RSpec/MessageSpies request_interceptor.call(last_request.env) end - it 'returns the app response' do + it 'returns the app response' do # rubocop:disable RSpec/MultipleExpectations status, headers, body = request_interceptor.call(last_request.env) expect(status).to eq(200) expect(headers).to include('Content-Type' => 'text/plain') @@ -56,7 +58,7 @@ end it 'logs the request data' do - expect(storage).to receive(:store).with('requests', kind_of(Hash)) + expect(storage).to receive(:store).with('requests', kind_of(Hash)) # rubocop:disable RSpec/MessageSpies request_interceptor.send(:log_request, request, kind_of(String)) end @@ -66,13 +68,13 @@ end it 'logs the error' do - expect(logger).to receive(:error).with('Error logging request: Boom!') + expect(logger).to receive(:error).with('Error logging request: Boom!') # rubocop:disable RSpec/MessageSpies request_interceptor.send(:log_request, request, kind_of(String)) end end end - describe '#log_response' do + describe '#log_response' do # rubocop:disable RSpec/MultipleMemoizedHelpers let(:status) { 200 } let(:headers) { { 'Content-Type' => 'text/plain', 'Content-Length' => '5' } } let(:response) { ['Hello'] } @@ -82,19 +84,19 @@ end it 'logs the response data' do - expect(storage).to receive(:store).with('responses', kind_of(Hash)) + expect(storage).to receive(:store).with('responses', kind_of(Hash)) # rubocop:disable RSpec/MessageSpies request_interceptor.send(:log_response, status, headers, response, kind_of(String)) end - context 'when logging fails' do + context 'when logging fails' do # rubocop:disable RSpec/MultipleMemoizedHelpers before do allow(storage).to receive(:store).and_raise(StandardError.new('Boom!')) end it 'logs the error' do - expect(logger).to receive(:error).with('Error logging response: Boom!') + expect(logger).to receive(:error).with('Error logging response: Boom!') # rubocop:disable RSpec/MessageSpies request_interceptor.send(:log_response, status, headers, response, kind_of(String)) end end end -end \ No newline at end of file +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index abbb990..a0ac47a 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,8 +1,9 @@ +# frozen_string_literal: true require 'rspec' # Requires supporting files with custom matchers and config. -Dir[File.join(File.dirname(__FILE__), "support", "**", "*.rb")].each { |f| require f } +Dir[File.join(File.dirname(__FILE__), 'support', '**', '*.rb')].each { |f| require f } RSpec.configure do |config| # Add settings here, for example: