From 06f75837d54a7a9865969033f5bc5bcf886d600e Mon Sep 17 00:00:00 2001 From: Bruno Costanzo Date: Thu, 27 Nov 2025 19:46:48 +0100 Subject: [PATCH] faraday integration --- .gitignore | 1 + README.md | 41 +++++++ .../rails_api_logger/faraday_middleware.rb | 22 ++++ app/models/rails_api_logger/request_log.rb | 32 ++++++ gemfiles/rails_6.1.gemfile | 1 + gemfiles/rails_7.0.gemfile | 1 + gemfiles/rails_7.1.gemfile | 1 + gemfiles/rails_7.2.gemfile | 1 + gemfiles/rails_8.0.gemfile | 1 + gemfiles/rails_8.0.gemfile.lock | 11 +- gemfiles/rails_webrick_8.0.gemfile | 1 + lib/rails_api_logger.rb | 5 + spec/faraday_middleware_spec.rb | 94 +++++++++++++++ spec/request_log_spec.rb | 107 ++++++++++++++++++ 14 files changed, 318 insertions(+), 1 deletion(-) create mode 100644 app/middlewares/rails_api_logger/faraday_middleware.rb create mode 100644 spec/faraday_middleware_spec.rb diff --git a/.gitignore b/.gitignore index fe3f58f..37dfde5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /.bundle/ +/gemfiles/.bundle/ /.yardoc /_yardoc/ /coverage/ diff --git a/README.md b/README.md index 589cf2c..3ea7a16 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,47 @@ end This will guarantee that the log is always persisted, even in case of errors. +### Using Faraday + +If you use [Faraday](https://github.com/lostisland/faraday) for HTTP requests, `from_response` works seamlessly with `Faraday::Response` objects: + +```ruby +conn = Faraday.new(url: 'https://api.example.com') + +log = RailsApiLogger::OutboundRequestLog.create( + path: "https://api.example.com/users", + method: "POST", + request_body: { name: "John" }, + started_at: Time.current +) + +response = conn.post('/users', { name: 'John' }.to_json) + +log.from_response(response) +log.ended_at = Time.current +log.save! +``` + +For automatic logging of all requests, use the provided middleware: + +```ruby +conn = Faraday.new(url: 'https://api.example.com') do |f| + f.use RailsApiLogger::FaradayMiddleware + f.adapter Faraday.default_adapter +end + +response = conn.post('/users', { name: 'John' }.to_json) +``` + +The middleware accepts the following options: + +```ruby +f.use RailsApiLogger::FaradayMiddleware, + loggable: current_user, # associate log with a model + skip_request_body: true, # don't log request body + skip_response_body: true # don't log response body +``` + ### Database Transactions Caveats If you log your outbound requests inside of parent app transactions, your logs will not be persisted if diff --git a/app/middlewares/rails_api_logger/faraday_middleware.rb b/app/middlewares/rails_api_logger/faraday_middleware.rb new file mode 100644 index 0000000..13ffd33 --- /dev/null +++ b/app/middlewares/rails_api_logger/faraday_middleware.rb @@ -0,0 +1,22 @@ +module RailsApiLogger + class FaradayMiddleware < Faraday::Middleware + def initialize(app, options = {}) + super(app) + @options = options + end + + def call(env) + log = OutboundRequestLog.from_request(env, loggable: @options[:loggable], skip_request_body: @options[:skip_request_body]) + + @app.call(env).on_complete do |response_env| + log.from_response(response_env.response, skip_response_body: @options[:skip_response_body]) + end + rescue => e + log.response_body = {error: e.message} if log + raise + ensure + log.ended_at = Time.current + log.save! + end + end +end diff --git a/app/models/rails_api_logger/request_log.rb b/app/models/rails_api_logger/request_log.rb index 195f10e..9ee820c 100644 --- a/app/models/rails_api_logger/request_log.rb +++ b/app/models/rails_api_logger/request_log.rb @@ -20,6 +20,7 @@ class RequestLog < ActiveRecord::Base validates :path, presence: true def self.from_request(request, loggable: nil, skip_request_body: false) + request = normalize_request(request) if skip_request_body body = "[Skipped]" else @@ -35,11 +36,42 @@ def self.from_request(request, loggable: nil, skip_request_body: false) end def from_response(response, skip_response_body: false) + response = self.class.normalize_response(response) self.response_code = response.code self.response_body = skip_response_body ? "[Skipped]" : manipulate_body(response.body) self end + def self.normalize_request(request) + return request unless faraday_request?(request) + + NormalizedRequest.new( + path: request.url.to_s, + body: request.request_body, + method: request.method.to_s.upcase + ) + end + + def self.normalize_response(response) + return response unless faraday_response?(response) + + NormalizedResponse.new( + code: response.status, + body: response.body + ) + end + + def self.faraday_request?(request) + defined?(Faraday::Env) && request.is_a?(Faraday::Env) + end + + def self.faraday_response?(response) + defined?(Faraday::Response) && response.is_a?(Faraday::Response) + end + + NormalizedRequest = Struct.new(:path, :body, :method, keyword_init: true) + NormalizedResponse = Struct.new(:code, :body, keyword_init: true) + def formatted_request_body formatted_body(request_body) end diff --git a/gemfiles/rails_6.1.gemfile b/gemfiles/rails_6.1.gemfile index 6dc2283..3fba18e 100644 --- a/gemfiles/rails_6.1.gemfile +++ b/gemfiles/rails_6.1.gemfile @@ -8,5 +8,6 @@ gem "rspec-rails", "~> 6.1.0" gem "sqlite3", "~> 1.7.3" gem "ammeter", "~> 1.1" gem "webmock", "~> 3.25.0" +gem "faraday", "~> 2.0" gemspec path: "../" diff --git a/gemfiles/rails_7.0.gemfile b/gemfiles/rails_7.0.gemfile index 01d1648..ca33241 100644 --- a/gemfiles/rails_7.0.gemfile +++ b/gemfiles/rails_7.0.gemfile @@ -8,5 +8,6 @@ gem "rspec-rails", "~> 7.1.0" gem "sqlite3", "~> 1.7.3" gem "ammeter", "~> 1.1" gem "webmock", "~> 3.25.0" +gem "faraday", "~> 2.0" gemspec path: "../" diff --git a/gemfiles/rails_7.1.gemfile b/gemfiles/rails_7.1.gemfile index 8e8aaef..aec288e 100644 --- a/gemfiles/rails_7.1.gemfile +++ b/gemfiles/rails_7.1.gemfile @@ -7,5 +7,6 @@ gem "rspec-rails", "~> 7.1.0" gem "sqlite3", "~> 1.7.3" gem "ammeter", "~> 1.1" gem "webmock", "~> 3.25.0" +gem "faraday", "~> 2.0" gemspec path: "../" diff --git a/gemfiles/rails_7.2.gemfile b/gemfiles/rails_7.2.gemfile index 356bf8f..f7ca1ee 100644 --- a/gemfiles/rails_7.2.gemfile +++ b/gemfiles/rails_7.2.gemfile @@ -7,5 +7,6 @@ gem "rspec-rails", "~> 7.1.0" gem "sqlite3", "~> 1.7.3" gem "ammeter", "~> 1.1" gem "webmock", "~> 3.25.0" +gem "faraday", "~> 2.0" gemspec path: "../" diff --git a/gemfiles/rails_8.0.gemfile b/gemfiles/rails_8.0.gemfile index de49e75..cb3037f 100644 --- a/gemfiles/rails_8.0.gemfile +++ b/gemfiles/rails_8.0.gemfile @@ -8,5 +8,6 @@ gem "sqlite3", "~> 2.1.0" gem "ammeter", "~> 1.1" gem "puma" gem "webmock", "~> 3.25.0" +gem "faraday", "~> 2.0" gemspec path: "../" diff --git a/gemfiles/rails_8.0.gemfile.lock b/gemfiles/rails_8.0.gemfile.lock index a5c5d64..d15f545 100644 --- a/gemfiles/rails_8.0.gemfile.lock +++ b/gemfiles/rails_8.0.gemfile.lock @@ -1,7 +1,7 @@ PATH remote: .. specs: - rails_api_logger (0.10.1) + rails_api_logger (0.11.0) activejob (>= 6.0) activerecord (>= 6.0) nokogiri @@ -109,6 +109,12 @@ GEM drb (2.2.3) erb (5.0.1) erubi (1.13.1) + faraday (2.14.0) + faraday-net_http (>= 2.0, < 3.5) + json + logger + faraday-net_http (3.4.2) + net-http (~> 0.5) globalid (1.2.1) activesupport (>= 6.1) hashdiff (1.2.0) @@ -135,6 +141,8 @@ GEM mini_mime (1.1.5) minitest (5.25.5) mysql2 (0.5.6) + net-http (0.8.0) + uri (>= 0.11.1) net-imap (0.5.8) date net-protocol @@ -282,6 +290,7 @@ DEPENDENCIES ammeter (~> 1.1) appraisal (~> 2.5) byebug + faraday (~> 2.0) mysql2 (~> 0.5.6) pg (~> 1.5.4) puma diff --git a/gemfiles/rails_webrick_8.0.gemfile b/gemfiles/rails_webrick_8.0.gemfile index 11ce892..46a3eb7 100644 --- a/gemfiles/rails_webrick_8.0.gemfile +++ b/gemfiles/rails_webrick_8.0.gemfile @@ -8,5 +8,6 @@ gem "sqlite3", "~> 2.1.0" gem "ammeter", "~> 1.1" gem "webrick", "~> 1.9.1" gem "webmock", "~> 3.25.0" +gem "faraday", "~> 2.0" gemspec path: "../" diff --git a/lib/rails_api_logger.rb b/lib/rails_api_logger.rb index 0b820a8..143ced9 100644 --- a/lib/rails_api_logger.rb +++ b/lib/rails_api_logger.rb @@ -10,8 +10,13 @@ loader = Zeitwerk::Loader.for_gem loader.ignore("#{__dir__}/generators") +loader.ignore("#{__dir__}/../app/middlewares/rails_api_logger/faraday_middleware.rb") loader.setup module RailsApiLogger mattr_accessor :connects_to end + +if defined?(Faraday) + require_relative "../app/middlewares/rails_api_logger/faraday_middleware" +end diff --git a/spec/faraday_middleware_spec.rb b/spec/faraday_middleware_spec.rb new file mode 100644 index 0000000..3779f35 --- /dev/null +++ b/spec/faraday_middleware_spec.rb @@ -0,0 +1,94 @@ +require "spec_helper" +require "faraday" + +RSpec.describe RailsApiLogger::FaradayMiddleware do + before do + RailsApiLogger::OutboundRequestLog.delete_all + end + + let(:connection) do + Faraday.new(url: "https://example.com") do |f| + f.use described_class, options + f.adapter :test do |stub| + stub.post("/users") { [201, {}, '{"id": 1, "name": "John"}'] } + stub.get("/users/1") { [200, {}, '{"id": 1, "name": "John"}'] } + stub.get("/error") { raise Faraday::ConnectionFailed, "connection failed" } + end + end + end + + let(:options) { {} } + + describe "successful request" do + it "logs the request and response" do + response = connection.post("/users", {name: "John"}.to_json) + + expect(response.status).to eq(201) + expect(RailsApiLogger::OutboundRequestLog.count).to eq(1) + + log = RailsApiLogger::OutboundRequestLog.last + expect(log.path).to eq("https://example.com/users") + expect(log.method).to eq("POST") + expect(log.response_code).to eq(201) + expect(log.started_at).to be_present + expect(log.ended_at).to be_present + end + + it "logs GET requests" do + response = connection.get("/users/1") + + expect(response.status).to eq(200) + + log = RailsApiLogger::OutboundRequestLog.last + expect(log.path).to eq("https://example.com/users/1") + expect(log.method).to eq("GET") + expect(log.response_code).to eq(200) + end + end + + describe "with skip_request_body option" do + let(:options) { {skip_request_body: true} } + + it "does not log the request body" do + connection.post("/users", {name: "John"}.to_json) + + log = RailsApiLogger::OutboundRequestLog.last + expect(log.request_body).to eq("[Skipped]") + end + end + + describe "with skip_response_body option" do + let(:options) { {skip_response_body: true} } + + it "does not log the response body" do + connection.post("/users", {name: "John"}.to_json) + + log = RailsApiLogger::OutboundRequestLog.last + expect(log.response_body).to eq("[Skipped]") + end + end + + describe "with loggable option" do + let(:book) { Book.create!(title: "Test Book", author: "Author") } + let(:options) { {loggable: book} } + + it "associates the log with the loggable" do + connection.get("/users/1") + + log = RailsApiLogger::OutboundRequestLog.last + expect(log.loggable).to eq(book) + end + end + + describe "when request fails" do + it "logs the error and re-raises" do + expect { + connection.get("/error") + }.to raise_error(Faraday::ConnectionFailed) + + log = RailsApiLogger::OutboundRequestLog.last + expect(log.response_body).to eq({"error" => "connection failed"}) + expect(log.ended_at).to be_present + end + end +end diff --git a/spec/request_log_spec.rb b/spec/request_log_spec.rb index a5645e7..68d7e52 100644 --- a/spec/request_log_spec.rb +++ b/spec/request_log_spec.rb @@ -1,6 +1,113 @@ require "spec_helper" +require "faraday" RSpec.describe RailsApiLogger::RequestLog do + before do + RailsApiLogger::OutboundRequestLog.delete_all + RailsApiLogger::InboundRequestLog.delete_all + end + describe ".faraday_request?" do + it "returns true for Faraday::Env" do + env = Faraday::Env.new + expect(described_class.faraday_request?(env)).to be true + end + + it "returns false for Net::HTTP request" do + request = Net::HTTP::Get.new(URI("http://example.com")) + expect(described_class.faraday_request?(request)).to be false + end + end + + describe ".faraday_response?" do + it "returns true for Faraday::Response" do + response = Faraday::Response.new + expect(described_class.faraday_response?(response)).to be true + end + + it "returns false for Net::HTTP response" do + uri = URI("http://example.com") + response = Net::HTTP.new(uri.host, uri.port).start { |http| http.request(Net::HTTP::Get.new(uri)) } + expect(described_class.faraday_response?(response)).to be false + end + end + + describe ".normalize_request" do + context "with a Faraday::Env" do + it "returns a NormalizedRequest with correct attributes" do + env = Faraday::Env.new + env.url = URI("https://api.example.com/users") + env.method = :post + env.request_body = '{"name":"John"}' + + normalized = described_class.normalize_request(env) + + expect(normalized.path).to eq("https://api.example.com/users") + expect(normalized.method).to eq("POST") + expect(normalized.body).to eq('{"name":"John"}') + end + end + + context "with a Net::HTTP request" do + it "returns the request unchanged" do + request = Net::HTTP::Get.new(URI("http://example.com")) + expect(described_class.normalize_request(request)).to eq(request) + end + end + end + + describe ".normalize_response" do + context "with a Faraday::Response" do + it "returns a NormalizedResponse with correct attributes" do + response = Faraday::Response.new(status: 201, body: '{"id":1}') + + normalized = described_class.normalize_response(response) + + expect(normalized.code).to eq(201) + expect(normalized.body).to eq('{"id":1}') + end + end + + context "with a Net::HTTP response" do + it "returns the response unchanged" do + uri = URI("http://example.com") + response = Net::HTTP.new(uri.host, uri.port).start { |http| http.request(Net::HTTP::Get.new(uri)) } + expect(described_class.normalize_response(response)).to eq(response) + end + end + end + + describe ".from_request with Faraday" do + it "creates a log from a Faraday::Env" do + env = Faraday::Env.new + env.url = URI("https://api.example.com/users") + env.method = :post + env.request_body = '{"name":"John"}' + + log = RailsApiLogger::OutboundRequestLog.from_request(env) + + expect(log.path).to eq("https://api.example.com/users") + expect(log.method).to eq("POST") + expect(log.request_body).to eq({"name" => "John"}) + expect(log.started_at).to be_present + end + end + + describe "#from_response with Faraday" do + it "updates the log from a Faraday::Response" do + log = RailsApiLogger::OutboundRequestLog.create!( + path: "https://api.example.com/users", + method: "POST", + started_at: Time.current + ) + + response = Faraday::Response.new(status: 201, body: '{"id":1}') + log.from_response(response) + + expect(log.response_code).to eq(201) + expect(log.response_body).to eq({"id" => 1}) + end + end + describe ".failed" do [RailsApiLogger::OutboundRequestLog, RailsApiLogger::InboundRequestLog].each do |klass| it "returns only failed requests for #{klass}" do