lib/supabase/storage.ex

defmodule Supabase.Storage do
  @moduledoc """
  Supabase.Storage Elixir Package

  This module provides integration with the Supabase Storage API, enabling developers
  to perform a multitude of operations related to buckets and objects with ease.

  ## Features

  1. **Bucket Operations**: Methods that allow the creation, listing, and removal of buckets.
  2. **Object Operations**: Functions designed to upload, download, retrieve object information,
     and perform move, copy, and remove actions on objects.

  ## Usage

  You can start by creating or managing buckets:

      Supabase.Storage.create_bucket(client, "my_new_bucket")

  Once a bucket is set up, objects within the bucket can be managed:

      Supabase.Storage.upload_object(client, "my_bucket", "path/on/server.png", "path/on/local.png")

  ## Examples

  Here are some basic examples:

      # Removing an object
      Supabase.Storage.remove_object(client, "my_bucket", "path/on/server.png")

      # Moving an object
      Supabase.Storage.move_object(client, "my_bucket", "path/on/server1.png", "path/on/server2.png")

  Ensure to refer to method-specific documentation for detailed examples and explanations.

  ## Permissions

  Do remember to check and set the appropriate permissions in Supabase to make sure that the
  operations can be performed without any hitches.
  """

  import Supabase.Client, only: [is_client: 1]

  alias Supabase.Client
  alias Supabase.Client.Conn
  alias Supabase.Storage.Bucket
  alias Supabase.Storage.BucketHandler
  alias Supabase.Storage.Object
  alias Supabase.Storage.ObjectHandler
  alias Supabase.Storage.ObjectOptions
  alias Supabase.Storage.SearchOptions

  @behaviour Supabase.StorageBehaviour

  @doc """
  Retrieves information about all buckets in the current project.

  ## Notes

  * Policy permissions required
    * `buckets` permissions: `select`
    * `objects` permissions: none

  ## Examples

      iex> Supabase.Storage.list_buckets(client)
      {:ok, [%Supabase.Storage.Bucket{...}, ...]}

      iex> Supabase.Storage.list_buckets(invalid_conn)
      {:error, reason}

  """
  @impl true
  def list_buckets(client) when is_client(client) do
    case Client.retrieve_connection(client) do
      nil ->
        {:error, :invalid_client}

      %Conn{access_token: token, api_key: api_key, base_url: base_url} ->
        {:ok, BucketHandler.list(base_url, api_key, token)}
    end
  end

  @doc """
  Retrieves information about a bucket in the current project.

  ## Notes

  * Policy permissions required
    * `buckets` permissions: `select`
    * `objects` permissions: none

  ## Examples

      iex> Supabase.Storage.retrieve_bucket_info(client, "avatars")
      {:ok, %Supabase.Storage.Bucket{...}}

      iex> Supabase.Storage.retrieve_bucket_info(invalid_conn, "avatars")
      {:error, reason}

  """
  @impl true
  def retrieve_bucket_info(client, id) when is_client(client) do
    case Client.retrieve_connection(client) do
      nil ->
        {:error, :invalid_client}

      %Conn{access_token: token, api_key: api_key, base_url: base_url} ->
        BucketHandler.retrieve_info(base_url, api_key, token, id)
    end
  end

  @doc """
  Creates a new bucket in the current project given a map of attributes.

  ## Attributes

  * `id`: the id of the bucket to be created, required
  * `name`: the name of the bucket to be created, defaults to the `id` provided
  * `file_size_limit`: the maximum size of a file in bytes
  * `allowed_mime_types`: a list of allowed mime types, defaults to allow all MIME types
  * `public`: whether the bucket is public or not, defaults to `false`

  ## Notes

  * Policy permissions required
    * `buckets` permissions: `insert`
    * `objects` permissions: none

  ## Examples

      iex> Supabase.Storage.create_bucket(client, %{id: "avatars"})
      {:ok, %Supabase.Storage.Bucket{...}}

      iex> Supabase.Storage.create_bucket(invalid_conn, %{id: "avatars"})
      {:error, reason}

  """
  @impl true
  def create_bucket(client, attrs) when is_client(client) do
    with {:ok, bucket_params} <- Bucket.create_changeset(attrs),
         %Conn{access_token: token, api_key: api_key, base_url: base_url} <-
           Client.retrieve_connection(client),
         {:ok, _} <- BucketHandler.create(base_url, api_key, token, bucket_params) do
      retrieve_bucket_info(client, bucket_params.id)
    else
      nil ->
        {:error, :invalid_client}

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

  @doc """
  Updates a bucket in the current project given a map of attributes.

  ## Attributes

  * `file_size_limit`: the maximum size of a file in bytes
  * `allowed_mime_types`: a list of allowed mime types, defaults to allow all MIME types
  * `public`: whether the bucket is public or not, defaults to `false`

  Isn't possible to update a bucket's `id` or `name`. If you want or need this, you should
  firstly delete the bucket and then create a new one.

  ## Notes

  * Policy permissions required
    * `buckets` permissions: `update`
    * `objects` permissions: none

  ## Examples

      iex> Supabase.Storage.update_bucket(client, bucket, %{public: true})
      {:ok, %Supabase.Storage.Bucket{...}}

      iex> Supabase.Storage.update_bucket(invalid_conn, bucket, %{public: true})
      {:error, reason}

  """
  @impl true
  def update_bucket(client, bucket, attrs) when is_client(client) do
    with {:ok, bucket_params} <- Bucket.update_changeset(bucket, attrs),
         %Conn{access_token: token, api_key: api_key, base_url: base_url} <-
           Client.retrieve_connection(client),
         {:ok, _} <- BucketHandler.update(base_url, api_key, token, bucket.id, bucket_params) do
      retrieve_bucket_info(client, bucket.id)
    else
      nil ->
        {:error, :invalid_client}

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

  @doc """
  Empties a bucket in the current project. This action deletes all objects in the bucket.

  ## Notes

  * Policy permissions required
    * `buckets` permissions: `update`
    * `objects` permissions: `delete`

  ## Examples

      iex> Supabase.Storage.empty_bucket(client, bucket)
      {:ok, :emptied}

      iex> Supabase.Storage.empty_bucket(invalid_conn, bucket)
      {:error, reason}

  """
  @impl true
  def empty_bucket(client, %Bucket{} = bucket) when is_client(client) do
    case Client.retrieve_connection(client) do
      nil ->
        {:error, :invalid_client}

      %Conn{access_token: token, api_key: api_key, base_url: base_url} ->
        BucketHandler.empty(base_url, api_key, token, bucket.id)
    end
  end

  @doc """
  Deletes a bucket in the current project. Notice that this also deletes all objects in the bucket.

  ## Notes

  * Policy permissions required
    * `buckets` permissions: `delete`
    * `objects` permissions: `delete`

  ## Examples

      iex> Supabase.Storage.delete_bucket(client, bucket)
      {:ok, :deleted}

      iex> Supabase.Storage.delete_bucket(invalid_conn, bucket)
      {:error, reason}

  """
  @impl true
  def delete_bucket(client, %Bucket{} = bucket) when is_client(client) do
    with %Conn{access_token: token, api_key: api_key, base_url: base_url} <-
           Client.retrieve_connection(client),
         {:ok, _} <- BucketHandler.delete(base_url, api_key, token, bucket.id) do
      {:ok, :deleted}
    else
      nil ->
        {:error, :invalid_client}

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

  @doc """
  Removes an object from a bucket in the current project.

  ## Notes

  * Policy permissions required
    * `buckets` permissions: none
    * `objects` permissions: `delete`

  ## Examples

      iex> Supabase.Storage.remove_object(client, bucket, object)
      {:ok, :deleted}

      iex> Supabase.Storage.remove_object(invalid_conn, bucket, object)
      {:error, reason}

  """
  @impl true
  def remove_object(client, %Bucket{} = bucket, %Object{} = object) when is_client(client) do
    case Client.retrieve_connection(client) do
      nil ->
        {:error, :invalid_client}

      %Conn{access_token: token, api_key: api_key, base_url: base_url} ->
        ObjectHandler.remove(base_url, api_key, token, bucket.name, object.path)
    end
  end

  @doc """
  Moves a object from a bucket and send it to another bucket, in the current project.
  Notice that isn't necessary to pass the current bucket, because the object already
  contains this information.

  ## Notes

  * Policy permissions required
    * `buckets` permissions: none
    * `objects` permissions: `delete` and `create`

  ## Examples

      iex> Supabase.Storage.move_object(client, bucket, object)
      {:ok, :moved}

      iex> Supabase.Storage.move_object(invalid_conn, bucket, object)
      {:error, reason}

  """
  @impl true
  def move_object(client, %Bucket{} = bucket, %Object{} = object, to) when is_client(client) do
    case Client.retrieve_connection(client) do
      nil ->
        {:error, :invalid_client}

      %Conn{access_token: token, api_key: api_key, base_url: base_url} ->
        ObjectHandler.move(base_url, api_key, token, bucket.name, object.path, to)
    end
  end

  @doc """
  Copies a object from a bucket and send it to another bucket, in the current project.
  Notice that isn't necessary to pass the current bucket, because the object already
  contains this information.

  ## Notes

  * Policy permissions required
    * `buckets` permissions: none
    * `objects` permissions: `create`

  ## Examples

      iex> Supabase.Storage.copy_object(client, bucket, object)
      {:ok, :copied}

      iex> Supabase.Storage.copy_object(invalid_conn, bucket, object)
      {:error, reason}

  """
  @impl true
  def copy_object(client, %Bucket{} = bucket, %Object{} = object, to) when is_client(client) do
    case Client.retrieve_connection(client) do
      nil ->
        {:error, :invalid_client}

      %Conn{access_token: token, api_key: api_key, base_url: base_url} ->
        ObjectHandler.copy(base_url, api_key, token, bucket.name, object.path, to)
    end
  end

  @doc """
  Retrieves information about an object in a bucket in the current project.

  ## Notes

  * Policy permissions required
    * `buckets` permissions: none
    * `objects` permissions: `select`

  ## Examples

      iex> Supabase.Storage.retrieve_object_info(client, bucket, "some.png")
      {:ok, %Supabase.Storage.Object{...}}

      iex> Supabase.Storage.retrieve_object_info(invalid_conn, bucket, "some.png")
      {:error, reason}

  """
  @impl true
  def retrieve_object_info(client, %Bucket{} = bucket, wildcard) when is_client(client) do
    case Client.retrieve_connection(client) do
      nil ->
        {:error, :invalid_client}

      %Conn{access_token: token, api_key: api_key, base_url: base_url} ->
        ObjectHandler.get_info(base_url, api_key, token, bucket.name, wildcard)
    end
  end

  @doc """
  Lists a set of objects in a bucket in the current project.

  ## Searching

  You can pass a prefix to filter the objects returned. For example, if you have the following
  objects in your bucket:

      .
      └── bucket/
          ├── avatars/
          │   └── some.png
          ├── other.png
          └── some.pdf

  And you want to list only the objects inside the `avatars` folder, you can do:

      iex> Supabase.Storage.list_objects(client, bucket, "avatars/")
      {:ok, [%Supabase.Storage.Object{...}]}

  Also you can pass some search options as a `Supabase.Storage.SearchOptions` struct. Available
  options are:

  * `limit`: the maximum number of objects to return
  * `offset`: the number of objects to skip
  * `sort_by`:
    * `column`: the column to sort by, defaults to `created_at`
    * `order`: the order to sort by, defaults to `desc`

  ## Notes

  * Policy permissions required
    * `buckets` permissions: none
    * `objects` permissions: `select`

  ## Examples

      iex> Supabase.Storage.list_objects(client, bucket)
      {:ok, [%Supabase.Storage.Object{...}, ...]}

      iex> Supabase.Storage.list_objects(invalid_conn, bucket)
      {:error, reason}

  """
  @impl true
  def list_objects(client, %Bucket{} = bucket, prefix \\ "", opts \\ %SearchOptions{})
      when is_client(client) do
    case Client.retrieve_connection(client) do
      nil ->
        {:error, :invalid_client}

      %Conn{access_token: token, api_key: api_key, base_url: base_url} ->
        ObjectHandler.list(base_url, api_key, token, bucket.name, prefix, opts)
    end
  end

  @doc """
  Uploads a file to a bucket in the current project. Notice that you only need to
  pass the path to the file you want to upload, as the file will be read in a stream way
  to be sent to the server.

  ## Options

  You can pass some options as a `Supabase.Storage.ObjectOptions` struct. Available
  options are:

  * `cache_control`: the cache control header value, defaults to `3600`
  * `content_type`: the content type header value, defaults to `text/plain;charset=UTF-8`
  * `upsert`: whether to overwrite the object if it already exists, defaults to `false`

  ## Notes

  * Policy permissions required
    * `buckets` permissions: none
    * `objects` permissions: `insert`

  ## Examples

      iex> Supabase.Storage.upload_object(client, bucket, "avatars/some.png", "path/to/file.png")
      {:ok, %Supabase.Storage.Object{...}}

      iex> Supabase.Storage.upload_object(invalid_conn, bucket, "avatars/some.png", "path/to/file.png")
      {:error, reason}

  """
  @impl true
  def upload_object(client, %Bucket{} = bucket, path, file, opts \\ %ObjectOptions{})
      when is_client(client) do
    case Client.retrieve_connection(client) do
      nil ->
        {:error, :invalid_client}

      %Conn{access_token: token, api_key: api_key, base_url: base_url} ->
        file = Path.expand(file)
        ObjectHandler.create_file(base_url, api_key, token, bucket.name, path, file, opts)
    end
  end

  @doc """
  Downloads an object from a bucket in the current project. That return a binary that
  represents the object content.

  ## Notes

  * Policy permissions required
    * `buckets` permissions: none
    * `objects` permissions: `select`

  ## Examples

       iex> Supabase.Storage.download_object(client, %Bucket{}, "avatars/some.png")
       {:ok, <<>>}

       iex> Supabase.Storage.download_object(invalid_conn, %Bucket{}, "avatars/some.png")
       {:error, reason}

  """
  @impl true
  def download_object(client, %Bucket{} = bucket, wildcard) when is_client(client) do
    case Client.retrieve_connection(client) do
      nil ->
        {:error, :invalid_client}

      %Conn{access_token: token, api_key: api_key, base_url: base_url} ->
        ObjectHandler.get(base_url, api_key, token, bucket.name, wildcard)
    end
  end

  @doc """
  Downloads an object from a bucket in the current project. That return a stream that
  represents the object content. Notice that the request to the server is only made
  when you start to consume the stream.

  ## Notes

  * Policy permissions required
    * `buckets` permissions: none
    * `objects` permissions: `select`

  ## Examples

       iex> Supabase.Storage.download_object_lazy(client, %Bucket{}, "avatars/some.png")
       {:ok, #Function<59.128620087/2 in Stream.resource/3>}

       iex> Supabase.Storage.download_object_lazy(invalid_conn, %Bucket{}, "avatars/some.png")
       {:error, reason}

  """
  @impl true
  def download_object_lazy(client, %Bucket{} = bucket, wildcard) when is_client(client) do
    case Client.retrieve_connection(client) do
      nil ->
        {:error, :invalid_client}

      %Conn{access_token: token, api_key: api_key, base_url: base_url} ->
        ObjectHandler.get_lazy(base_url, api_key, token, bucket.name, wildcard)
    end
  end

  @doc """
  Saves an object from a bucket in the current project to a file in the local filesystem.

  ## Notes

  * Policy permissions required
    * `buckets` permissions: none
    * `objects` permissions: `select`

  ## Examples

       iex> Supabase.Storage.save_object(client, "./some.png", %Bucket{}, "avatars/some.png")
       :ok

       iex> Supabase.Storage.save_object(client, "./some.png", %Bucket{}, "do_not_exist.png")
       {:error, reason}

  """
  @impl true
  def save_object(client, path, %Bucket{} = bucket, wildcard) when is_client(client) do
    with {:ok, bin} <- download_object(client, bucket, wildcard) do
      File.write(Path.expand(path), bin)
    end
  end

  @doc """
  Saves an object from a bucket in the current project to a file in the local filesystem.
  Notice that the request to the server is only made when you start to consume the stream.

  ## Notes

  * Policy permissions required
    * `buckets` permissions: none
    * `objects` permissions: `select`

  ## Examples

       iex> Supabase.Storage.save_object_stream(client, "./some.png", %Bucket{}, "avatars/some.png")
       :ok

       iex> Supabase.Storage.save_object_stream(client, "./some.png", %Bucket{}, "do_not_exist.png")
       {:error, reason}

  """
  @impl true
  def save_object_stream(client, path, %Bucket{} = bucket, wildcard) when is_client(client) do
    with {:ok, stream} <- download_object_lazy(client, bucket, wildcard) do
      fs = File.stream!(Path.expand(path))

      stream
      |> Stream.into(fs)
      |> Stream.run()
    end
  end

  @doc """
  Creates a signed URL for an object in a bucket in the current project. This URL can
  be used to perform an HTTP request to the object, without the need of authentication.
  Usually this is used to allow users to download objects from a bucket.

  ## Notes

  * Policy permissions required
    * `buckets` permissions: none
    * `objects` permissions: `select`

  ## Examples

       iex> Supabase.Storage.create_signed_url(client, bucket, "avatars/some.png", 3600)
       {:ok, "https://<project>.supabase.co"/object/sign/<bucket>/<file>?token=<token>}

       iex> Supabase.Storage.create_signed_url(invalid_client, bucket, "avatars/some.png", 3600)
       {:error, :invalid_client}

  """
  @impl true
  def create_signed_url(client, %Bucket{} = bucket, path, expires_in) when is_client(client) do
    with %Conn{access_token: token, api_key: api_key, base_url: base_url} <-
           Client.retrieve_connection(client),
         {:ok, sign_url} <-
           ObjectHandler.create_signed_url(
             base_url,
             api_key,
             token,
             bucket.name,
             path,
             expires_in
           ) do
      {:ok, URI.to_string(URI.merge(base_url, sign_url))}
    else
      nil ->
        {:error, :invalid_client}

      err ->
        err
    end
  end
end