defmodule Supabase.Fetcher do
@moduledoc """
A fundamental HTTP client for interfacing directly with Supabase services.
`Supabase.Fetcher` provides the groundwork for sending HTTP requests to the Supabase infrastructure. This includes utilities for various HTTP methods such as GET, POST, PUT, DELETE, and functions to simplify the process of streaming data or uploading files.
## Key Features
- **Low-level HTTP Interactions**: This module allows for raw HTTP requests to any URL, simplifying interactions with web resources.
- **Data Streaming**: Efficiently stream large data payloads, such as downloading files.
- **Request Customization**: Extensive header customization and utility functions for constructing requests tailored to your requirements.
- **Response Parsing**: Automatically converts JSON responses into Elixir maps and handles various response scenarios.
## Recommended Usage
While `Supabase.Fetcher` is versatile and comprehensive, it operates at a very granular level. For most applications and needs, leveraging higher-level APIs that correspond to specific Supabase services is advisable:
- `Supabase.Storage` - API to interact directly with buckets and objects in Supabase Storage.
## Disclaimer
If your aim is to directly harness this module as a low-level HTTP client, due to missing features in other packages or a desire to craft a unique Supabase integration, you can certainly do so. However, always keep in mind that `Supabase.Storage` and other Supabase-oriented packages might offer better abstractions and ease-of-use.
Use `Supabase.Fetcher` with a clear understanding of its features and operations.
"""
@behaviour Supabase.FetcherBehaviour
@spec version :: String.t()
def version do
{:ok, vsn} = :application.get_key(:supabase_potion, :vsn)
List.to_string(vsn)
end
@spec new_connection(atom, url, body, headers) :: Finch.Request.t()
when url: String.t() | URI.t(),
body: binary | nil | {:stream, Enumerable.t()},
headers: list(tuple)
defp new_connection(method, url, body, headers) do
headers = merge_headers(default_headers(), headers)
Finch.build(method, url, headers, body)
end
@spec default_headers :: list(tuple)
defp default_headers do
[
{"accept", "application/json"},
{"x-client-info", "supabase-fetch-elixir/#{version()}"}
]
end
@doc """
Makes a HTTP request to the desired URL, with default headers and
stream back the response. Good to stream large files downlaods.
You can also pass custom `Finch` options directly to the underlying `Finch.stream/4` function.
Those options can be seen on the [Finch documentation](https://hexdocs.pm/finch/Finch.html#stream/5-options).
## Examples
iex> {status, stream} = Supabase.Fetcher.stream("https://example.com")
iex> file = File.stream!("path/to/file", [], 4096)
Stream.run Stream.into(stream, file)
"""
@impl true
def stream(url, headers \\ [], opts \\ []) do
ref = make_ref()
task = spawn_stream_task(new_connection(:get, url, nil, headers), ref, opts)
status = receive(do: ({:chunk, {:status, status}, ^ref} -> status))
stream =
Stream.resource(fn -> {ref, task} end, &receive_stream(&1), fn {_ref, task} ->
Task.shutdown(task)
end)
case {status, stream} do
{200, stream} -> {:ok, stream}
{s, _} when s >= 400 -> {:error, :not_found}
{s, _} when s >= 500 -> {:error, :server_error}
end
end
defp spawn_stream_task(%Finch.Request{} = req, ref, opts) do
me = self()
Task.async(fn ->
on_chunk = fn chunk, _acc -> send(me, {:chunk, chunk, ref}) end
Finch.stream(req, Supabase.Finch, nil, on_chunk, opts)
send(me, {:done, ref})
end)
end
defp receive_stream({ref, _task} = payload) do
receive do
{:chunk, {:data, data}, ^ref} -> {[data], payload}
{:done, ^ref} -> {:halt, payload}
end
end
@doc """
Simple GET request that format the response to a map or retrieve
the error reason as `String.t()`.
## Examples
iex> Supabase.Fetcher.get("https://example.com")
{:ok, %{"key" => "value"}}
"""
def get(url) do
get(url, nil, [], [])
end
def get(url, body) do
get(url, body, [], [])
end
def get(url, body, headers) do
get(url, body, headers, [])
end
@impl true
def get(url, body, headers, opts) do
resp =
:get
|> new_connection(url, Jason.encode_to_iodata!(body), headers)
|> Finch.request(Supabase.Finch)
if opts[:resolve_json] do
format_response(resp)
else
resp
end
end
@doc """
Simple POST request that format the response to a map or retrieve
the error reason as `String.t()`.
## Examples
iex> Supabase.Fetcher.post("https://example.com", %{key: "value"})
{:ok, %{"key" => "value"}}
"""
@impl true
def post(url, body \\ nil, headers \\ []) do
headers = merge_headers(headers, [{"content-type", "application/json"}])
:post
|> new_connection(url, Jason.encode_to_iodata!(body), headers)
|> Finch.request(Supabase.Finch)
|> format_response()
end
@doc """
Simple PUT request that format the response to a map or retrieve
the error reason as `String.t()`.
## Examples
iex> Supabase.Fetcher.put("https://example.com", %{key: "value"})
{:ok, %{"key" => "value"}}
"""
@impl true
def put(url, body, headers \\ []) do
headers = merge_headers(headers, [{"content-type", "application/json"}])
:put
|> new_connection(url, Jason.encode_to_iodata!(body), headers)
|> Finch.request(Supabase.Finch)
|> format_response()
end
@doc """
Simple DELETE request that format the response to a map or retrieve
the error reason as `String.t()`.
## Examples
iex> Supabase.Fetcher.delete("https://example.com", %{key: "value"})
{:ok, %{"key" => "value"}}
iex> Supabase.Fetcher.delete("https://example.com", %{key: "value"})
{:error, :not_found}
"""
@impl true
def delete(url, body \\ nil, headers \\ []) do
headers = merge_headers(headers, [{"content-type", "application/json"}])
:delete
|> new_connection(url, Jason.encode_to_iodata!(body), headers)
|> Finch.request(Supabase.Finch)
|> format_response()
end
@doc """
Upload a binary to the desired URL.
params:
- `method`: `:put` or `:post`
- `url`: the URL to upload the file
- `file`: the path to the file to upload
- `headers`: list of additional headers to append to the request
## Examples
iex> Supabase.Fetcher.upload(:post, "https://example.com", "path/to/file")
{:ok, %{"key" => "value"}}
"""
@impl true
def upload(method, url, file, headers \\ []) do
body_stream = File.stream!(file, [{:read_ahead, 4096}], 1024)
%File.Stat{size: content_length} = File.stat!(file)
content_headers = [{"content-length", to_string(content_length)}]
headers = merge_headers(headers, content_headers)
conn = new_connection(method, url, {:stream, body_stream}, headers)
conn
|> Finch.request(Supabase.Finch)
|> format_response()
end
def get_full_url(base_url, path) do
URI.merge(base_url, path)
end
@doc """
Convenience function that given a `apikey` and a optional ` token`, it will return the headers
to be used in a request to your Supabase API.
## Examples
iex> Supabase.Fetcher.apply_conn_headers("apikey-value")
[{"apikey", "apikey-value"}, {"authorization", "Bearer apikey-value"}]
iex> Supabase.Fetcher.apply_conn_headers("apikey-value", "token-value")
[{"apikey", "apikey-value"}, {"authorization", "Bearer token-value"}]
"""
def apply_headers(api_key, token \\ nil, headers \\ []) do
conn_headers = [
{"apikey", api_key},
{"authorization", "Bearer #{token || api_key}"}
]
merge_headers(conn_headers, headers)
end
defp merge_headers(some, other) do
some = if is_list(some), do: some, else: Map.to_list(some)
other = if is_list(other), do: other, else: Map.to_list(other)
some
|> Kernel.++(other)
|> Enum.dedup_by(fn {name, _} -> name end)
|> Enum.reject(fn {_, v} -> is_nil(v) end)
end
def apply_client_headers(%Supabase.Client{} = client, token \\ nil, headers \\ []) do
client.conn.api_key
|> apply_headers(token || client.conn.access_token, client.global.headers)
|> merge_headers(headers)
end
def get_header(%Finch.Response{headers: headers}, header) do
if h = Enum.find(headers, &(elem(&1, 0) == header)) do
elem(h, 1)
else
nil
end
end
def get_header(%Finch.Response{} = resp, header, default) do
get_header(resp, header) || default
end
def format_response({:error, %{reason: reason}}) do
{:error, reason}
end
def format_response({:ok, %{status: 404}}) do
{:error, :not_found}
end
def format_response({:ok, %{status: 401}}) do
{:error, :unauthorized}
end
def format_response({:ok, %{status: 204}}) do
{:ok, :no_body}
end
def format_response({:ok, %{status: s, body: body}}) when s in 200..300 do
result =
case Jason.decode(body) do
{:ok, body} -> body
{:error, _} when is_binary(body) -> body
end
{:ok, result}
end
def format_response({:ok, %{status: s, body: body}}) when s in 400..499 do
{:error, format_bad_request_error(Jason.decode!(body))}
end
def format_response({:ok, %{status: s}}) when s >= 500 do
{:error, :server_error}
end
defp format_bad_request_error(%{"message" => msg}) do
case msg do
"The resource was not found" -> :not_found
_ -> msg
end
end
defp format_bad_request_error(%{"code" => 429, "msg" => msg}) do
if String.starts_with?(msg, "For security purposes,") do
[seconds] = Regex.run(~r/\d+/, msg, return: :binary) || ["undefined"]
{:error, {:rate_limit_until_seconds, seconds}}
else
case msg do
"Email rate limit exceeded" -> :email_rate_limit
end
end
end
defp format_bad_request_error(%{"error" => err, "error_description" => desc}) do
case {err, desc} do
{"invalid_grant", nil} -> :invalid_grant
{"invalid_grant", "Invalid login credentials"} -> {:invalid_grant, :invalid_credentials}
{"invalid_grant", "Email not confirmed"} -> {:invalid_grant, :email_not_confirmed}
{"invalid_grant", err} -> {:invalid_grant, err}
end
end
defp format_bad_request_error(err) do
err
end
end