Current section
Files
Jump to
Current section
Files
lib/supabase/fetcher/request.ex
defmodule Supabase.Fetcher.Request do
@moduledoc """
`Supabase.Fetcher.Request` is a structure to handle HTTP request builder designed to interface seamlessly with Supabase services.
## Key Features
- **Request Composition**: Build requests with method, headers, body, and query parameters using a composable builder pattern.
- **Service-Specific Integrations**: Automatically derive URLs for Supabase services like `auth`, `functions`, `storage`, `realtime`, and `database`.
- **Customizable Response Handling**: Attach decoders and error parsers tailored to specific service requirements.
- **Error Management**: Centralized error handling through `Supabase.ErrorParser`, supporting structured and semantic error reporting.
## Key Components
### Request Builder API
The `Supabase.Fetcher.Request` provides a composable API for constructing HTTP requests. Each step updates the request builder state:
- `with_<service>_url/2`: Appends the path to a service-specific base URL, available services can be consulted on `Supabase.services()` typespec.
- `with_method/2`: Sets the HTTP method (`:get`, `:post`, etc.).
- `with_headers/2`: Appends or overrides headers.
- `with_body/2`: Sets the request body, supporting JSON, iodata, or streams.
- `with_query/2`: Adds query parameters.
- `with_body_decoder/3`: Registers a custom body decoder to be hooked into the response, defaults to `Supabase.Fetcher.JSONDecoder`.
- `with_error_parser/2`: Registers a custom error parser to be hooked into the response, defaults to `Supabase.ErrorParser`.
### Custom HTTP Clients
`Supabase.Fetcher.Request` depends on a `Supabase.Fetcher.Adapter` implementation to dispatch HTTP requests, check `Supabase.Fetcher` module documentation for more info.
### Decoders and Parsers
- **Body Decoder**: Custom modules implementing the `Supabase.Fetcher.BodyDecoder` behaviour can decode response bodies into application-specific formats.
- **Error Parser**: Handle service-specific errors using `Supabase.Error` implementations, ensuring consistent error reporting across services.
## Example Usage
### Basic Request
```elixir
{: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()
```
## Custom Decoders and Error Parsers
```elixir
{:ok, response} =
Supabase.Fetcher.new(client)
|> Supabase.Fetcher.with_functions_url("/execute")
|> Supabase.Fetcher.with_body_decoder(MyCustomDecoder)
|> Supabase.Fetcher.with_error_parser(MyErrorParser)
|> Supabase.Fetcher.request()
```
## Notes
This module is designed to be extensible and reusable for all Supabase-related services. It abstracts away the low-level HTTP intricacies while providing the flexibility developers need to interact with Supabase services in Elixir applications.
"""
alias Supabase.Client
alias Supabase.Fetcher
@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,
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,
options: []
]
@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}
|> Map.update!(:options, &Keyword.merge(&1, opts))
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) when is_atom(adapter) do
%{builder | http_client: adapter}
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.
"""
@impl true
def with_body(builder, body \\ nil)
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