lib/fbe/middleware/formatter.rb



# frozen_string_literal: true

# SPDX-FileCopyrightText: Copyright (c) 2024-2025 Zerocracy
# SPDX-License-Identifier: MIT

require 'ellipsized'
require 'faraday'
require 'faraday/logging/formatter'
require_relative '../../fbe'
require_relative '../../fbe/middleware'

# Custom Faraday formatter that logs only error responses (4xx/5xx).
#
# This formatter reduces log noise by only outputting details when HTTP
# requests fail. For 403 errors with JSON responses, it shows a compact
# warning with the error message. For other errors, it logs the full
# request/response details including headers and bodies.
#
# @example Usage in Faraday middleware
#   connection = Faraday.new do |f|
#     f.response :logger, nil, formatter: Fbe::Middleware::Formatter
#   end
#
# @example Log output for 403 error
#   # GET https://api.github.com/repos/private/repo -> 403 / Repository access denied
#
# @example Log output for other errors (500, 404, etc)
#   # GET https://api.example.com/endpoint HTTP/1.1
#   #   Content-Type: "application/json"
#   #   Authorization: "Bearer [FILTERED]"
#   #
#   #   {"query": "data"}
#   # HTTP/1.1 500
#   #   Content-Type: "text/html"
#   #
#   #   Internal Server Error
#
# Author:: Yegor Bugayenko (yegor256@gmail.com)
# Copyright:: Copyright (c) 2024-2025 Zerocracy
# License:: MIT
class Fbe::Middleware::Formatter < Faraday::Logging::Formatter
  # Captures HTTP request details for later use in error logging.
  #
  # @param [Hash] http Request data including method, url, headers, and body
  # @return [void]
  def request(http)
    @req = http
  end

  # Logs HTTP response details only for error responses (4xx/5xx).
  #
  # @param [Hash] http Response data including status, headers, and body
  # @return [void]
  # @note Only logs when status >= 400
  # @note Special handling for 403 JSON responses to show compact error message
  def response(http)
    return if http.status < 400
    if http.status == 403 && http.response_headers['content-type'].start_with?('application/json')
      warn(
        [
          "#{@req.method.upcase} #{apply_filters(@req.url.to_s)}",
          '->',
          http.status,
          '/',
          JSON.parse(http.response_body)['message']
        ].join(' ')
      )
      return
    end
    if http.status >= 500 && http.response_headers['content-type']&.start_with?('text')
      error(
        [
          "#{@req.method.upcase} #{apply_filters(@req.url.to_s)} HTTP/1.1",
          shifted(apply_filters(dump_headers(@req.request_headers))),
          '',
          shifted(apply_filters(@req.request_body)),
          "HTTP/1.1 #{http.status}",
          shifted(apply_filters(dump_headers(http.response_headers))),
          '',
          shifted(apply_filters(http.response_body&.ellipsized(100, :right)))
        ].join("\n")
      )
      return
    end
    error(
      [
        "#{@req.method.upcase} #{apply_filters(@req.url.to_s)} HTTP/1.1",
        shifted(apply_filters(dump_headers(@req.request_headers))),
        '',
        shifted(apply_filters(@req.request_body)),
        "HTTP/1.1 #{http.status}",
        shifted(apply_filters(dump_headers(http.response_headers))),
        '',
        shifted(apply_filters(http.response_body))
      ].join("\n")
    )
  end

  private

  # Indents text with two spaces, including all lines.
  #
  # @param [String, nil] txt The text to indent
  # @return [String] The indented text, or an empty string if input was nil
  # @example
  #   shifted("line1\nline2")
  #   #=> "  line1\n  line2"
  def shifted(txt)
    return '' if txt.nil?
    "  #{txt.gsub("\n", "\n  ")}"
  end

  # Formats HTTP headers as a multi-line string.
  #
  # @param [Hash, nil] headers The headers to format
  # @return [String] The formatted headers, or an empty string if input was nil
  # @example
  #   dump_headers({"Content-Type" => "application/json", "Authorization" => "Bearer token"})
  #   #=> "Content-Type: \"application/json\"\nAuthorization: \"Bearer token\""
  def dump_headers(headers)
    return '' if headers.nil?
    headers.map { |k, v| "#{k}: #{v.inspect}" }.join("\n")
  end
end