lib/maxwell/middleware/logger.ex

defmodule Maxwell.Middleware.Logger do
  @moduledoc """
  Log the request and response by Logger, default log_level is :info.
  Setting log_level in 3 ways:

  ### Log everything by log_level

      middleware Maxwell.Middleware.Logger, log_level: :debug

  ### Log request by specific status code.

      middleware Maxwell.Middleware.Logger, log_level: [debug: 200, error: 404, info: default]

  ### Log request by status code's Ranges

      middleware Maxwell.Middleware.Logger, log_level: [error: [500..599, 300..399, 400], warn: 404, debug: default]

  ### Examples

        # Client.ex
        use Maxwell.Builder ~(get)a

        middleware Maxwell.Middleware.Logger, log_level: [
          info: [1..100, 200..299, 404],
          warn: 300..399,
          error: :default
        ]

        def your_own_request(url) do
          url |> new() |> get!()
        end

  """
  use Maxwell.Middleware
  require Logger

  @levels [:info, :debug, :warn, :error]

  def init(opts) do
    case Keyword.pop(opts, :log_level) do
      {_, [_ | _]} ->
        raise ArgumentError, "Logger Middleware Options doesn't accept wrong_option (:log_level)"

      {nil, _} ->
        [default: :info]

      {options, _} when is_list(options) ->
        parse_opts(options)

      {level, _} ->
        parse_opts([{level, :default}])
    end
  end

  def call(request_env, next_fn, options) do
    start = System.system_time(:millisecond)
    new_result = next_fn.(request_env)

    case new_result do
      {:error, reason, _conn} ->
        method = request_env.method |> to_string |> String.upcase()
        Logger.error("#{method} #{request_env.url}>> #{IO.ANSI.red()}ERROR: #{inspect(reason)}")

      %Maxwell.Conn{} = response_conn ->
        stop = System.system_time(:millisecond)
        diff = stop - start
        log_response_message(options, response_conn, diff)
    end

    new_result
  end

  defp log_response_message(options, conn, diff) do
    %Maxwell.Conn{status: status, url: url, method: method} = conn
    level = get_level(options, status)

    color =
      case level do
        nil -> nil
        :debug -> IO.ANSI.cyan()
        :info -> IO.ANSI.normal()
        :warn -> IO.ANSI.yellow()
        :error -> IO.ANSI.red()
      end

    unless is_nil(level) do
      message =
        "#{method} #{url} <<<#{color}#{status}(#{diff}ms)#{IO.ANSI.reset()}\n#{inspect(conn)}"

      Logger.log(level, message)
    end
  end

  defp get_level([], _code), do: nil
  defp get_level([{code, level} | _], code), do: level

  defp get_level([{from..to, level} | _], code)
       when code in from..to,
       do: level

  defp get_level([{:default, level} | _], _code), do: level
  defp get_level([_ | t], code), do: get_level(t, code)

  defp parse_opts(options), do: parse_opts(options, [], nil)
  defp parse_opts([], result, nil), do: Enum.reverse(result)
  defp parse_opts([], result, default), do: Enum.reverse([{:default, default} | result])

  defp parse_opts([{level, :default} | rest], result, nil) do
    check_level(level)
    parse_opts(rest, result, level)
  end

  defp parse_opts([{level, :default} | rest], result, level) do
    Logger.warn("Logger Middleware: default level defined multiple times.")
    parse_opts(rest, result, level)
  end

  defp parse_opts([{_level, :default} | _rest], _result, _default) do
    raise ArgumentError, "Logger Middleware: default level conflict."
  end

  defp parse_opts([{level, codes} | rest], result, default) when is_list(codes) do
    check_level(level)

    result =
      Enum.reduce(codes, result, fn code, acc ->
        check_code(code)
        [{code, level} | acc]
      end)

    parse_opts(rest, result, default)
  end

  defp parse_opts([{level, code} | rest], result, default) do
    check_level(level)
    check_code(code)
    parse_opts(rest, [{code, level} | result], default)
  end

  defp check_level(level) when level in @levels, do: :ok

  defp check_level(_level) do
    raise ArgumentError, "Logger Middleware: level only accepts #{inspect(@levels)}."
  end

  defp check_code(code) when is_integer(code), do: :ok
  defp check_code(_from.._to), do: :ok

  defp check_code(_any) do
    raise ArgumentError, "Logger Middleware: status code only accepts Integer and Range."
  end
end