lib/assent/http_adapter/httpc.ex

defmodule Assent.HTTPAdapter.Httpc do
  @moduledoc """
  HTTP adapter module for making http requests with httpc.

  SSL support will automatically be enabled if the `:certifi` and
  `:ssl_verify_fun` libraries exists in your project. You can also override
  the httpc options by updating the configuration to
  `http_adapter: {Assent.HTTPAdapter.Httpc, [...]}`.

  See `Assent.HTTPAdapter` for more.
  """
  alias Assent.{HTTPAdapter, HTTPAdapter.HTTPResponse}

  @behaviour HTTPAdapter

  @impl HTTPAdapter
  def request(method, url, body, headers, httpc_opts \\ nil) do
    headers = headers ++ [HTTPAdapter.user_agent_header()]
    request = httpc_request(url, body, headers)
    opts    = parse_httpc_opts(httpc_opts, url)

    warn_missing_ssl(opts)

    method
    |> :httpc.request(request, opts, [])
    |> format_response()
  end

  defp httpc_request(url, body, headers) do
    url          = to_charlist(url)
    headers      = Enum.map(headers, fn {k, v} -> {to_charlist(k), to_charlist(v)} end)

    do_httpc_request(url, body, headers)
  end

  defp do_httpc_request(url, nil, headers) do
    {url, headers}
  end
  defp do_httpc_request(url, body, headers) do
    {content_type, headers} = split_content_type_headers(headers)
    body                    = to_charlist(body)

    {url, headers, content_type, body}
  end

  defp split_content_type_headers(headers) do
    case List.keytake(headers, 'content-type', 0) do
      nil -> {'text/plain', headers}
      {{_, ct}, headers} -> {ct, headers}
    end
  end

  defp format_response({:ok, {{_, status, _}, headers, body}}) do
    headers = Enum.map(headers, fn {key, value} -> {String.downcase(to_string(key)), to_string(value)} end)
    body    = IO.iodata_to_binary(body)

    {:ok, %HTTPResponse{status: status, headers: headers, body: body}}
  end
  defp format_response({:error, error}), do: {:error, error}

  defp parse_httpc_opts(nil, url), do: default_httpc_opts(url)
  defp parse_httpc_opts(opts, _url), do: opts

  defp default_httpc_opts(url) do
    case certifi_and_ssl_verify_fun_available?() do
      true  -> [ssl: ssl_opts(url)]
      false -> []
    end
  end

  defp certifi_and_ssl_verify_fun_available? do
    Application.ensure_all_started(:certifi)
    Application.ensure_all_started(:ssl_verify_fun)

    app_available?(:certifi) && app_available?(:ssl_verify_fun)
  end

  defp app_available?(app) do
    case :application.get_key(app, :vsn) do
      {:ok, _vsn} -> true
      _           -> false
    end
  end

  defp ssl_opts(url) do
    %{host: host} = URI.parse(url)

    # This handles certificates for wildcard domain with SAN extension for
    # OTP >= 22
    hostname_match_check =
      try do
        [customize_hostname_check: [match_fun: :public_key.pkix_verify_hostname_match_fun(:https)]]
      rescue
        _e in UndefinedFunctionError -> []
      end

    [
      verify: :verify_peer,
      depth: 99,
      cacerts: :certifi.cacerts(),
      verify_fun: {&:ssl_verify_hostname.verify_fun/3, check_hostname: to_charlist(host)}
    ] ++ hostname_match_check
  end

  defp warn_missing_ssl(opts) do
    opts
    |> Keyword.get(:ssl, [])
    |> Keyword.get(:verify_fun)
    |> case do
      nil -> IO.warn("This request will NOT be verified for valid SSL certificate")
      _   -> :ok
    end
  end
end