3.8 KiB · text History b868907
defmodule GitGudWeb.Api.Mobile.Helpers do
  @moduledoc """
  Shared plumbing for the mobile API controllers: repo resolution
  behind the viewer's visibility rules, uniform error bodies, and the
  small amount of param coercion every listing endpoint repeats.

  `fetch_repo/4` folds "does it exist" and "may this token's owner see
  it" into one answer on purpose — a private repo the caller can't read
  must 404, not 403, or the API leaks which private repos exist.
  """
  import Plug.Conn
  import Phoenix.Controller, only: [json: 2]

  alias GitGud.Organizations
  alias GitGud.Repositories

  @doc """
  Resolve `owner/name` and check `action` against the viewer's effective
  permission. `{:ok, repo}` or `{:error, halted_conn}`.
  """
  def fetch_repo(conn, user, owner, name, action \\ :read) do
    case Repositories.find_by_path(owner, name) do
      nil ->
        {:error, send_error(conn, 404, "repository not found")}

      # A job's token reaches its own repository and no other — its
      # permissions, not the CI account's, say what it may do there.
      repo when is_map_key(conn.assigns, :api_job) ->
        if repo.id == conn.assigns.api_job["pid"],
          do: {:ok, GitGud.Repo.preload(repo, [:owner, :organization])},
          else: {:error, send_error(conn, 404, "repository not found")}

      repo ->
        cond do
          Organizations.can?(user, repo, action) ->
            {:ok, GitGud.Repo.preload(repo, [:owner, :organization])}

          # Readable but not writable: say so, since the caller already
          # knows the repo exists.
          Organizations.can?(user, repo, :read) ->
            {:error, send_error(conn, 403, "requires #{action} access to #{owner}/#{name}")}

          true ->
            {:error, send_error(conn, 404, "repository not found")}
        end
    end
  end

  @doc "A `:state` filter param, restricted to what the contexts accept."
  def state_param(params, default \\ "open") do
    case params["state"] do
      s when s in ["open", "closed", "merged", "all"] -> s
      _ -> default
    end
  end

  @doc "A positive integer param, clamped to `max`."
  def int_param(params, key, default, max) do
    case Integer.parse(to_string(params[key] || "")) do
      {n, _} when n > 0 -> min(n, max)
      _ -> default
    end
  end

  @doc "A `:number` path segment, or a halted 404 if it isn't one."
  def number_param(conn, raw) do
    case Integer.parse(to_string(raw)) do
      {n, ""} when n > 0 -> {:ok, n}
      _ -> {:error, send_error(conn, 404, "not found")}
    end
  end

  @doc """
  `:ok`, or a halted 429 when a job's run has already opened as many
  issues or pull requests (`policy`, a `GitGud.RateLimit` policy keyed
  by run) as one run may. People aren't counted here.
  """
  def job_quota(conn, policy) do
    case conn.assigns[:api_job] do
      %{"rid" => rid} ->
        case GitGud.RateLimit.hit(policy, rid) do
          :ok ->
            :ok

          {:error, _retry_after} ->
            {limit, _} = GitGud.RateLimit.limits(policy)

            {:error,
             send_error(conn, 429, "this run has already opened #{limit}; that's the limit")}
        end

      _ ->
        :ok
    end
  end

  @doc "The run a job token belongs to, as `workflow_run_id:` for what it creates."
  def job_opts(conn) do
    case conn.assigns[:api_job] do
      %{"rid" => rid} -> [workflow_run_id: rid]
      _ -> []
    end
  end

  def send_error(conn, status, message) do
    conn |> put_status(status) |> json(%{"message" => message}) |> halt()
  end

  @doc """
  Turn a changeset into the flat `field message; field message` string
  the API returns — the phone shows it in a snackbar, so structure
  buys nothing.
  """
  def changeset_message(%Ecto.Changeset{} = cs) do
    cs
    |> Ecto.Changeset.traverse_errors(fn {m, _} -> m end)
    |> Enum.map_join("; ", fn {k, v} -> "#{k} #{Enum.join(v, ", ")}" end)
  end
end