lib/cablegram/request_handler.ex

defmodule Cablegram.RequestHandler do
  @moduledoc """
  Handles the low-level request, including params transformation.
  """

  def default_opts do
    [base_url: "https://api.telegram.org/bot"]
  end

  def run(request, opts \\ default_opts()) do
    base_url = Keyword.fetch!(opts, :base_url)
    url = "#{base_url}#{request.token}/#{request.method_name}"

    body = httpoison_body(request)
    headers = []

    HTTPoison.request(:post, url, body, headers)
    |> case do
      {:ok, %HTTPoison.Response{body: body, status_code: status_code}} ->
        Jason.decode(body)
        |> case do
          {:ok, %{"ok" => true, "result" => result}} ->
            {:ok, result}

          {:ok, %{"ok" => false} = json} ->
            {:error, %{status_code: status_code, json: json}}

          {:error, _} ->
            {:error, %{body: body, status_code: status_code, reason: :bad_json_in_body}}
        end

      {:error, reason} ->
        {:error, reason}
    end
  end

  defp httpoison_body(request) do
    {upload_tuple, params} = pop_upload_param(request.params || [])
    params = serialize_form_params(params)

    case upload_tuple do
      nil ->
        {:form, params}

      {name, %{path: "" <> path} = input_file} ->
        disposition = disposition(name, input_file)
        extra_headers = []

        part = {:file, path, disposition, extra_headers}

        {:multipart, [part | params]}

      {name, %{binary: "" <> binary} = input_file} ->
        disposition = disposition(name, input_file)
        extra_headers = []

        part = {to_string(name), binary, disposition, extra_headers}

        {:multipart, [part | params]}
    end
  end

  defp pop_upload_param(params) do
    params
    |> Enum.split_with(fn
      {_, %Cablegram.InputFile{}} -> true
      _else -> false
    end)
    |> then(fn {uploads, params} ->
      case uploads do
        [] -> {nil, params}
        [upload_tuple] -> {upload_tuple, params}
        _else -> raise "only one upload is possible"
      end
    end)
  end

  defp serialize_form_params(params) do
    # As of the time of this writing, the API documentation at
    # https://core.telegram.org/bots/api mentions for each parameter
    # that it is JSON serialized if and only if the parameter at hand is
    # * an Array, or
    # * a compound type (object) known to the API, or
    # * the "provider_data" on the "sendInvoice" method
    # The latter is data of a type that is unknown to the bot API.
    #
    # This code interprets this as follows:
    # A parameter is sent JSON serialized if and only if it is an array
    # or a compound data structure (an Elixir struct or map).

    params
    |> Enum.filter(fn {_, v} -> v end)
    |> Enum.map(fn
      {k, list} when is_list(list) -> {to_string(k), Jason.encode!(list)}
      {k, %_{} = value} -> {to_string(k), Jason.encode!(value)}
      {k, v} -> {to_string(k), to_string(v)}
    end)
  end

  defp disposition(name, input_file) do
    {"form-data", [name: name, filename: input_file.filename]}
  end
end