Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,32 @@ A `Supabase.Client` holds general information about Supabase that can be used to

### Usage

To create a `Supabase.Client`:
#### Module-based Client (Recommended)

You can define a client module using the macro (similar to Ecto Repo). This approach reads configuration from your application config and builds a fresh client struct on each call:

```elixir
# lib/my_app/supabase.ex
defmodule MyApp.Supabase do
use Supabase.Client, otp_app: :my_app
end

# config/config.exs
config :my_app, MyApp.Supabase,
base_url: "https://<supabase-url>",
api_key: "<supabase-api-key>",
db: [schema: "public"],
auth: [flow_type: :pkce],
global: [headers: %{"custom-header" => "custom-value"}]

# Usage
iex> client = MyApp.Supabase.get_client!()
iex> %Supabase.Client{}
```

#### Direct Initialization

Alternatively, you can create a client directly using `Supabase.init_client/3`:

```elixir
iex> Supabase.init_client("https://<supabase-url>", "<supabase-api-key>")
Expand Down
20 changes: 1 addition & 19 deletions lib/supabase/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,27 +7,9 @@ defmodule Supabase.Application do

@impl true
def start(_start_type, _args) do
children =
[{Finch, @finch_opts}]
|> maybe_append_child(fn e -> e == :dev end, SupabasePotion.Client)

children = [{Finch, @finch_opts}]
opts = [strategy: :one_for_one, name: Supabase.Supervisor]

Supervisor.start_link(children, opts)
end

@spec maybe_append_child([child], (env -> boolean()), child) :: [child]
when env: :dev | :prod | :test | nil, child: Supervisor.module_spec()
defp maybe_append_child(children, pred, child) do
env = get_env()

cond do
is_nil(env) -> children
pred.(env) -> children ++ [child]
not pred.(env) -> children
end
end

@spec get_env :: :dev | :prod | :test | nil
defp get_env, do: Application.get_env(:supabase_potion, :env)
end
166 changes: 40 additions & 126 deletions lib/supabase/client.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,43 @@ defmodule Supabase.Client do

## Usage

Generally, you can start a client by calling `Supabase.init_client/3`:
There are two ways to create a Supabase client:

### 1. Module-based Client (Recommended)

Define a client module using the macro (similar to Ecto Repo). This approach
reads configuration from your application config and builds a fresh client
struct on each call:

# lib/my_app/supabase.ex
defmodule MyApp.Supabase do
use Supabase.Client, otp_app: :my_app
end

# config/config.exs
config :my_app, MyApp.Supabase,
base_url: "https://<app-name>.supabase.io",
api_key: "<supabase-api-key>",
db: [schema: "public"],
auth: [flow_type: :pkce]

# Usage
iex> client = MyApp.Supabase.get_client!()
iex> %Supabase.Client{}

### 2. Direct Initialization

Alternatively, create a client directly using `Supabase.init_client/3`:

iex> base_url = "https://<app-name>.supabase.io"
iex> api_key = "<supabase-api-key>"
iex> Supabase.init_client(base_url, api_key, %{})
{:ok, %Supabase.Client{}}

For more information on how to configure your Supabase Client with additional options, please refer to the `Supabase.Client.t()` typespec.
For more information on how to configure your Supabase Client with additional
options, please refer to the `Supabase.Client.t()` typespec.

## Examples
## Client Structure

%Supabase.Client{
base_url: "https://<app-name>.supabase.io",
Expand Down Expand Up @@ -72,44 +99,16 @@ defmodule Supabase.Client do
@typedoc """
The type for the available additional options that can be passed
to `Supabase.init_client/3` to configure the Supabase client.

Note that these options can be passed to `Supabase.init_client/3` as `Enumerable`, which means it can be either a `Keyword.t()` or a `Map.t()`, but internally it will be passed as a map.
"""
@type options :: %{
optional(:db) => Db.params(),
optional(:global) => Global.params(),
optional(:auth) => Auth.params()
}

@deprecated """
The self-managed client pattern using Agents is deprecated and will be removed in v1.0.

This pattern causes race conditions and security vulnerabilities in multi-user server
environments where multiple requests share the same Agent state. User tokens can become mixed, allowing User A to access User B's data.

Instead, one can still use the macro utility to better organize the client
initialization:

defmodule MyApp.Supabase do
# this will define both `get_client/0` and `set_auth/1`
use Supabase.Client, otp_app: :my_app
end

And pass client struct explicitly to Supabase functions, even for Plug/Live View helpers generated by `supabase.gen.auth`, like:

# Before
MyApp.Auth.log_in_with_password(conn, %{email: "", password: ""})

# After
MyApp.Auth.log_in_with_password(client, conn, %{email: "", password: ""})

"""
@spec __using__(otp_app: atom) :: Macro.t()
defmacro __using__(otp_app: otp_app) do
module = __CALLER__.module

quote do
use Agent

import Supabase.Client, only: [update_access_token: 2]

alias Supabase.MissingSupabaseConfig
Expand All @@ -119,114 +118,29 @@ defmodule Supabase.Client do
@otp_app unquote(otp_app)

@doc """
Start an Agent process to manage the Supabase client instance.

## Usage

First, define your client module and use the `Supabase.Client` module:

defmodule MyApp.Supabase.Client do
use Supabase.Client, otp_app: :my_app
end
Builds a `Supabase.Client` struct based on application config, so you can use it to interact with the Supabase API.

Note that you need to configure it with your Supabase project details. You can do this by setting the `base_url` and `api_key` in your `config.exs` file:

config :#{@otp_app}, #{inspect(unquote(module))},
base_url: "https://<app-name>.supabase.co",
api_key: "<supabase-api-key>",
# additional options
access_token: "<supabase-access-token>",
db: [schema: "another"],
auth: [debug: true]

Then, on your `application.ex` file, you can start the agent process by adding your defined client into the Supervision tree of your project:

def start(_type, _args) do
children = [
#{inspect(unquote(module))}
]

Supervisor.init(children, strategy: :one_for_one)
end

For alternatives on how to start and define your Supabase client instance, please refer to the [Supabase.Client module documentation](https://hexdocs.pm/supabase_potion/Supabase.Client.html).

For more information on how to start an Agent process, please refer to the [Agent module documentation](https://hexdocs.pm/elixir/Agent.html).
Read more on `Supabase.Client.Behaviour`
"""
def start_link(opts \\ [])

def start_link(opts) when is_list(opts) and opts == [] do
@impl Supabase.Client.Behaviour
def get_client! do
config = Application.get_env(@otp_app, __MODULE__)

if is_nil(config) do
raise MissingSupabaseConfig, key: :config, client: __MODULE__, otp_app: @otp_app
end

base_url = Keyword.get(config, :base_url)
api_key = Keyword.get(config, :api_key)
name = Keyword.get(config, :name, __MODULE__)
params = Map.new(config)

if is_nil(base_url) do
raise MissingSupabaseConfig, key: :url, client: __MODULE__, otp_app: @otp_app
end

if is_nil(api_key) do
raise MissingSupabaseConfig, key: :key, client: __MODULE__, otp_app: @otp_app
end

Agent.start_link(fn -> Supabase.init_client!(base_url, api_key, params) end, name: name)
end

def start_link(opts) when is_list(opts) do
base_url = Keyword.get(opts, :base_url)
api_key = Keyword.get(opts, :api_key)

if is_nil(base_url) do
raise MissingSupabaseConfig, key: :url, client: __MODULE__, otp_app: @otp_app
end

if is_nil(api_key) do
raise MissingSupabaseConfig, key: :key, client: __MODULE__, otp_app: @otp_app
end

name = Keyword.get(opts, :name, __MODULE__)
params = Map.new(opts)

Agent.start_link(
fn ->
Supabase.init_client!(base_url, api_key, params)
end,
name: name
)
end

@doc """
This function is an alias for `start_link/1` with no arguments.
"""
@impl Supabase.Client.Behaviour
def init, do: start_link([])

@doc """
Retrieve the client instance from the Agent process, so you can use it to interact with the Supabase API.
"""
@impl Supabase.Client.Behaviour
def get_client(pid \\ __MODULE__) do
case Agent.get(pid, & &1) do
nil -> {:error, :not_found}
client -> {:ok, client}
end
Supabase.init_client!(base_url, api_key, Map.new(config))
end

@doc """
This function updates the `access_token` field of client
that will then be used by the integrations as the `Authorization`
header in requests, by default the `access_token` have the same
value as the `api_key`.

Read more on `Supabase.Client.update_access_token/2`
"""
@impl Supabase.Client.Behaviour
def set_auth(pid \\ __MODULE__, token) when is_binary(token) do
Agent.update(pid, &update_access_token(&1, token))
def set_auth!(token) when is_binary(token) do
update_access_token(get_client!(), token)
end
end
end
Expand Down
30 changes: 21 additions & 9 deletions lib/supabase/client/behaviour.ex
Original file line number Diff line number Diff line change
@@ -1,18 +1,30 @@
defmodule Supabase.Client.Behaviour do
@moduledoc """
The behaviour for the Supabase Client. This behaviour is used to define the API for a Supabase Client.
The behaviour for the Supabase Client. This behaviour defines a consistent
API for modules that provide Supabase client functionality.

If you're implementing a [Self Managed Client](https://github.com/zoedsoupe/supabase-ex?tab=readme-ov-file#self-managed-clients) as the [Supabase.Client](https://hexdocs.pm/supabase_potion/Supabase.Client.html), this behaviour is already implemented for you.
## Usage

If you're implementing a [One Off Client](https://github.com/zoedsoupe/supabase-ex?tab=readme-ov-file#one-off-clients) as the [Supabase.Client](https://hexdocs.pm/supabase_potion/Supabase.Client.html), you need to implement this behaviour in case you want to use the integration with [Supabase.GoTrue](https://hexdocs.pm/supabase_gotrue/readme.html) for [Plug](https://hexdocs.pm/plug) based application or [Phoenix.LiveView](https://hexdocs.pm/phoenix_live_view) applications.
When you use the `Supabase.Client` macro in your module (similar to Ecto Repo),
this behaviour is automatically implemented for you:

defmodule MyApp.Supabase do
use Supabase.Client, otp_app: :my_app
end

This provides two callbacks:

- `get_client!/0` - Builds a fresh client struct from application config
- `set_auth!/1` - Updates the access token on a client instance

## Custom Implementations

You can also implement this behaviour manually if you need custom client
initialization logic.
"""

alias Supabase.Client

@callback init :: {:ok, Client.t()} | {:error, Ecto.Changeset.t()}
@callback get_client :: {:ok, Client.t()} | {:error, :not_found}
@callback get_client(pid | atom) :: {:ok, Client.t()} | {:error, :not_found}
@callback set_auth(pid | atom, access_token :: String.t()) :: :ok

@optional_callbacks get_client: 0, get_client: 1, set_auth: 2
@callback get_client! :: Client.t()
@callback set_auth!(access_token :: String.t()) :: Client.t()
end
6 changes: 0 additions & 6 deletions priv/local/client.ex

This file was deleted.

16 changes: 7 additions & 9 deletions test/supabase/client_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ defmodule Supabase.ClientTest do
use Supabase.Client, otp_app: :supabase_potion
end

describe "Agent behavior" do
describe "client definition" do
setup do
config = [
base_url: @valid_base_url,
Expand All @@ -35,25 +35,23 @@ defmodule Supabase.ClientTest do
]

Application.put_env(:supabase_potion, TestClient, config)
pid = start_supervised!(TestClient)
{:ok, pid: pid}
:ok
end

test "retrieves client from Agent", %{pid: pid} do
assert {:ok, %Client{} = client} = TestClient.get_client(pid)
test "retrieves client" do
assert %Client{} = client = TestClient.get_client!()
assert client.base_url == @valid_base_url
assert client.api_key == @valid_api_key
assert client.access_token == "123"
assert client.auth.debug
assert client.auth.storage_key == "test-key"
end

test "updates access token in client", %{pid: pid} do
test "updates access token in client" do
new_access_token = "new_access_token"
assert {:ok, %Client{} = client} = TestClient.get_client(pid)
assert %Client{} = client = TestClient.get_client!()
assert client.access_token == "123"
assert :ok = TestClient.set_auth(pid, new_access_token)
assert {:ok, %Client{} = client} = TestClient.get_client(pid)
assert %Client{} = client = TestClient.set_auth!(new_access_token)
assert client.access_token == new_access_token
end
end
Expand Down