Current section

Files

Jump to
supabase_potion lib supabase fetcher request.ex
Raw

lib/supabase/fetcher/request.ex

defmodule Supabase.Fetcher.Request do
@moduledoc """
Composable HTTP request builder for Supabase services.
This module is primarily used internally by downstream libraries (`supabase_auth`,
`supabase_storage`, `supabase_realtime`, etc.) to construct requests. You can also
use it directly for full granular control over requests.
Provides a builder pattern to construct requests with method, headers, body,
query params, and service-specific URLs (`:auth`, `:storage`, `:functions`, etc.).
## HTTP Client
The underlying HTTP adapter is resolved from application config:
config :supabase_potion, http_client: MyApp.CustomHTTPClient
Defaults to `Supabase.Fetcher.Adapter.Finch`. Any module implementing the
`Supabase.Fetcher.Adapter` behaviour can be used.
## Example
{:ok, response} =
Supabase.Fetcher.new(client)
|> Supabase.Fetcher.with_auth_url("/token")
|> Supabase.Fetcher.with_method(:post)
|> Supabase.Fetcher.with_body(%{username: "test", password: "test"})
|> Supabase.Fetcher.request()
"""
alias Supabase.Client
alias Supabase.Fetcher
alias Supabase.Fetcher.Multipart
@behaviour Supabase.Fetcher.Request.Behaviour
@type options :: list({:decode_body?, boolean} | {:parse_http_error?, boolean})
@type t :: %__MODULE__{
client: Client.t(),
method: Supabase.Fetcher.method(),
body: Supabase.Fetcher.body(),
headers: Supabase.Fetcher.headers(),
url: Supabase.Fetcher.url(),
service: Supabase.service(),
query: Supabase.Fetcher.query(),
body_decoder: module,
body_decoder_opts: keyword,
error_parser: module,
http_client: module,
http_client_opts: keyword,
options: options
}
defstruct [
:url,
:service,
:client,
:body,
method: :get,
query: [],
headers: [],
body_decoder_opts: [],
body_decoder: Supabase.Fetcher.JSONDecoder,
error_parser: Supabase.HTTPErrorParser,
http_client: Supabase.Fetcher.Adapter.Finch,
http_client_opts: [],
options: []
]
@default_http_adapter Supabase.Fetcher.Adapter.Finch
@doc """
Initialise the `Supabase.Fetcher` struct, accumulating the
client global headers and the client itself, so the request can be
easily composed using the `with_` functions of this module.
"""
@impl true
def new(%Client{global: global} = client, opts \\ []) when is_list(opts) do
headers =
global.headers
|> Map.put("authorization", "Bearer " <> client.access_token)
|> Map.put("apikey", client.api_key)
|> Map.to_list()
%__MODULE__{client: client, headers: headers, http_client: get_http_adapter()}
|> Map.update!(:options, &Keyword.merge(&1, opts))
end
defp get_http_adapter do
Application.get_env(:supabase_potion, :http_client, @default_http_adapter)
end
@services [:auth, :functions, :storage, :realtime, :database]
for service <- @services do
@doc """
Applies the #{service} base url from the client and appends the
informed path to the url. Note that this function will overwrite
the `url` field each time of call.
"""
@impl true
def unquote(:"with_#{service}_url")(%__MODULE__{} = builder, path)
when is_binary(path) do
base = Map.get(builder.client, :"#{unquote(service)}_url")
%{builder | url: Path.join(base, path) |> URI.parse(), service: unquote(service)}
end
end
@doc """
Attaches a custom body decoder to be called after a successfull response.
The body decoder should implement the `Supabase.Fetcher.BodyDecoder` behaviour, and it default
to the `Supabase.Fetcher.JSONDecoder`, or it can be a 2-arity function that will follow the `Supabase.Fetcher.BodyDecoder.decode` callback interface.
You can pass `nil` as the decoder to avoid body decoding, if you need the raw body.
"""
@impl true
def with_body_decoder(%__MODULE__{} = builder, decoder, decoder_opts \\ [])
when (is_atom(decoder) or is_function(decoder, 2)) and is_list(decoder_opts) do
%{
builder
| body_decoder: decoder,
body_decoder_opts: decoder_opts,
options: Keyword.put(builder.options, :decode_body?, true)
}
end
@doc """
Attaches a custom error parser to be called after a "successfull" response.
The error parser should implement the `Supabase.Error` behaviour, and it default
to the `Supabase.HTTPErrorParser`.
"successful" response means that the fetcher actually got a HTTP response from
the server, so if the HTTP status is >= 400, so this error parser will be invoked.
Check `Supabase.HTTPErrorParser` for an example of implementation.
"""
@impl true
def with_error_parser(%__MODULE__{} = builder, parser)
when is_atom(parser) and not is_nil(parser) do
%{builder | error_parser: parser}
end
@doc """
Define the method of the request, default to `:get`, the available options
are the same of `Finch.Request.method()` and note that this function
will overwrite the `method` attribute each time is called.
"""
@impl true
def with_method(%__MODULE__{} = builder, method \\ :get)
when method in ~w(get put post patch delete head)a do
%{builder | method: method}
end
@doc """
Registers a custom HTTP client backend to be used by `Supabase.Fetcher` while
dispatching the request. The default one is `Supabase.Fetcher.Adapter.Finch`
"""
@impl true
def with_http_client(%__MODULE__{} = builder, adapter, opts \\ [])
when is_atom(adapter) do
%{builder | http_client: adapter, http_client_opts: opts}
end
@doc """
Append query params to the current request builder, it receives an `Enumerable.t()`
and accumulates it into the current request. This function behaves the same as
`with_headers/2`, so it is **rigt-associative**, meaning that duplicate keys
informed will overwrite the last value.
Finally, before the request is sent, the query will be encoded with `URI.encode_query/1`
"""
@impl true
def with_query(%__MODULE__{} = builder, query)
when is_map(query) or is_list(query) do
%{builder | query: Fetcher.merge_headers(builder.query, query)}
end
@doc """
Defines the request body to be sent, it can be a map, that will be encoded
with `encode_to_iodata!/1`, any `iodata` or a stream body in the pattern of `{:stream, Enumerable.t}`, although you will problably prefer to use the `upload/2`
function of this module to hadle body stream since it will handle file management, content headers and so on.
Additionally, multipart/form-data bodies are supported via:
- `{:multipart, parts}` - In-memory multipart encoding
- `{:multipart_stream, parts}` - Streaming multipart encoding for large files
"""
@impl true
def with_body(builder, body \\ nil)
def with_body(%__MODULE__{} = builder, {:multipart, parts}) when is_list(parts) do
{content_type, body} = Multipart.encode(parts)
builder
|> Map.put(:body, body)
|> with_headers(%{"content-type" => content_type})
end
def with_body(%__MODULE__{} = builder, {:multipart_stream, parts}) when is_list(parts) do
{content_type, stream} = Multipart.encode_stream(parts)
builder
|> Map.put(:body, {:stream, stream})
|> with_headers(%{"content-type" => content_type})
end
def with_body(%__MODULE__{} = builder, %{} = body) do
json_library = Supabase.json_library()
%{builder | body: json_library.encode_to_iodata!(body)}
end
def with_body(%__MODULE__{} = builder, body) do
%{builder | body: body}
end
@doc """
Append headers to the current request builder, the headers needs to be an
`Enumerable.t()` and will be merged via `merge_headers/2`, which means that
this function can be called multiple times **without** overwriting the existing
headers definitions.
"""
@impl true
def with_headers(%__MODULE__{} = builder, headers) do
%{builder | headers: Fetcher.merge_headers(builder.headers, headers)}
end
@doc """
Tries to find and return the value for a query param, given it name, if it doesn't
existis, it returns the default value informed or `nil`.
"""
@spec get_query_param(t, param :: String.t(), default :: String.t() | nil) :: String.t() | nil
def get_query_param(%__MODULE__{} = builder, key, default \\ nil)
when is_binary(key) and (is_binary(default) or is_nil(default)) do
case List.keyfind(builder.query, key, 0) do
nil -> default
{^key, value} -> value
end
end
@doc """
Tries to find and return the value for a request headers, given it name, if it doesn't
existis, it returns the default value informed or `nil`.
Do not confuse with `Supabase.Response.get_header/2`.
"""
@spec get_header(t, name :: String.t(), default :: String.t() | nil) :: String.t() | nil
def get_header(%__MODULE__{} = builder, key, default \\ nil)
when is_binary(key) and (is_binary(default) or is_nil(default)) do
case List.keyfind(builder.headers, key, 0) do
nil -> default
{^key, value} -> value
end
end
@doc """
Merges an existing query param value with a new one, prepending the new value
with the existing one. If no current value exists for the param, this function
will behave the same as `with_query/2`.
"""
@spec merge_query_param(t, param :: String.t(), value :: String.t(),
with: joinner :: String.t()
) :: t
def merge_query_param(%__MODULE__{} = builder, key, value, [with: w] \\ [with: ","])
when is_binary(key) and is_binary(value) and is_binary(w) do
if curr = get_query_param(builder, key) do
with_query(builder, %{key => Enum.join([curr, value], w)})
else
with_query(builder, %{key => value})
end
end
@doc """
Merges an existing request header value with a new one, prepending the new value
with the existing one. If no current value exists for the header, this function
will behave the same as `with_headers/2`.
"""
@spec merge_req_header(t, header :: String.t(), value :: String.t(),
with: joinner :: String.t()
) :: t
def merge_req_header(%__MODULE__{} = builder, key, value, [with: w] \\ [with: ","])
when is_binary(key) and is_binary(value) and is_binary(w) do
if curr = get_header(builder, key) do
with_headers(builder, %{key => Enum.join([curr, value], w)})
else
with_headers(builder, %{key => value})
end
end
defimpl Inspect, for: __MODULE__ do
import Inspect.Algebra
def inspect(%Supabase.Fetcher.Request{} = fetcher, opts) do
base_url = Map.get(fetcher.client, :"#{fetcher.service}_url", "")
headers = Enum.reject(fetcher.headers, &(String.downcase(elem(&1, 0)) == "authorization"))
fields = [
method: String.upcase(to_string(fetcher.method)),
path: String.replace(to_string(fetcher.url), base_url, ""),
service: fetcher.service,
headers: format_headers(headers),
body_decoder: fetcher.body_decoder,
error_parser: fetcher.error_parser
]
concat([
"#Supabase.Fetcher.Request<",
to_doc(fields, opts),
">"
])
end
defp format_headers(headers) when is_list(headers) do
Enum.map_join(headers, ", ", fn {k, v} -> "#{k}=#{v}" end)
end
end
end