lib/waffle/storage/google.ex

defmodule Waffle.Storage.Google do
  @moduledoc """
  The main storage integration for Waffle, this acts primarily as a wrapper
  around `Google.Api.Storage.V1`. To use this module with Waffle, simply set
  your `:storage` config appropriately:

  ```elixir
  config :waffle, storage: Waffle.Storage.Google
  ```

  Ensure you have a valid bucket set, either through the configs or as an
  environment variable, otherwise all calls will fail. The credentials available
  through `Goth` must have the appropriate level of access to the bucket,
  otherwise some (or all) calls may fail.
  """

  alias GoogleApi.Storage.V1.Connection
  alias GoogleApi.Storage.V1.Api.Objects
  alias GoogleApi.Storage.V1.Model.Object
  alias Waffle.Storage.Google.Util
  alias Waffle.Types

  @type object_or_error :: {:ok, GoogleApi.Storage.V1.Model.Object.t()} | {:error, Tesla.Env.t()}

  @doc """
  Put a Waffle file in a Google Cloud Storage bucket.
  """
  @spec put(Types.definition(), Types.version(), Types.meta()) :: object_or_error
  def put(definition, version, meta) do
    path = path_for(definition, version, meta)
    acl = definition.acl(version, meta)

    gcs_options =
      definition
      |> get_gcs_options(version, meta)
      |> ensure_keyword_list()
      |> Keyword.put(:acl, acl)
      |> Enum.into(%{})

    insert(conn(), bucket(definition), path, data(meta), gcs_options)
  end

  @doc """
  Delete a file from a Google Cloud Storage bucket.
  """
  @spec put(Types.definition(), Types.version(), Types.meta()) :: object_or_error
  def delete(definition, version, meta) do
    Objects.storage_objects_delete(
      conn(),
      bucket(definition),
      path_for(definition, version, meta)
    )
  end

  @doc """
  Retrieve the public URL for a file in a Google Cloud Storage bucket. Uses
  `Waffle.Storage.Google.UrlV2` by default, which uses v2 signing if a signed
  URL is requested, but this can be overriden in the options list or in the
  application configs by setting `:url_builder` to any module that imlements the
  behavior of `Waffle.Storage.Google.Url`.
  """
  @spec url(Types.definition(), Types.version(), Types.meta(), Keyword.t()) :: String.t()
  def url(definition, version, meta, opts \\ []) do
    signer = Util.option(opts, :url_builder, Waffle.Storage.Google.UrlV2)
    signer.build(definition, version, meta, opts)
  end

  @doc """
  Constructs a new connection.
  """
  @spec conn() :: Tesla.Env.client()
  def conn() do
    {:ok, token} = Goth.fetch(Waffle.Storage.Google.Goth)
    Connection.new(token.token)
  end

  @doc """
  Returns the bucket for file uploads.
  """
  @spec bucket(Types.definition()) :: String.t()
  def bucket(definition), do: Util.var(definition.bucket())

  @doc """
  Returns the storage directory **within a bucket** to store the file under.
  """
  @spec storage_dir(Types.definition(), Types.version(), Types.meta()) :: String.t()
  def storage_dir(definition, version, meta) do
    version
    |> definition.storage_dir(meta)
    |> Util.var()
  end

  @doc """
  Returns the full file path for the upload destination.
  """
  @spec path_for(Types.definition(), Types.version(), Types.meta()) :: String.t()
  def path_for(definition, version, meta) do
    definition
    |> storage_dir(version, meta)
    |> Path.join(fullname(definition, version, meta))
  end

  @doc """
  A wrapper for `Waffle.Definition.Versioning.resolve_file_name/3`.
  """
  @spec fullname(Types.definition(), Types.version(), Types.meta()) :: String.t()
  def fullname(definition, version, meta) do
    Waffle.Definition.Versioning.resolve_file_name(definition, version, meta)
  end

  @spec data(Types.file()) :: {:file | :binary, String.t()}
  defp data({%{binary: nil, path: path}, _}), do: {:file, path}
  defp data({%{binary: data}, _}), do: {:binary, data}

  @spec insert(
          Tesla.Env.client(),
          String.t(),
          String.t(),
          {:file | :binary, String.t()},
          String.t()
        ) :: object_or_error
  defp insert(conn, bucket, name, {:file, path}, gcs_options) do
    object =
      %Object{name: name}
      |> Map.merge(gcs_options)

    Objects.storage_objects_insert_simple(
      conn,
      bucket,
      "multipart",
      object,
      path
    )
  end

  defp insert(conn, bucket, name, {:binary, data}, acl) do
    Objects.storage_objects_insert_iodata(
      conn,
      bucket,
      "multipart",
      %Object{name: name, acl: acl},
      data
    )
  end

  defp get_gcs_options(definition, version, {file, scope}) do
    try do
      apply(definition, :gcs_object_headers, [version, {file, scope}])
    rescue
      UndefinedFunctionError ->
        []
    end
  end

  defp ensure_keyword_list(list) when is_list(list), do: list
  defp ensure_keyword_list(map) when is_map(map), do: Map.to_list(map)
end