diff --git a/CHANGELOG.md b/CHANGELOG.md index e69de29..35bdca8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +## [Unreleased] + +### Added +- Dynamic auth token updates feature for better token rotation support + - `Supabase.Functions.update_auth/2` function for functional client updates + - `:auth` option in `Supabase.Functions.invoke/3` for per-request auth token overrides + - Feature parity with JavaScript client's `setAuth(token)` method + - Comprehensive test coverage for both update methods +- Enhanced documentation with examples for both auth update approaches diff --git a/README.md b/README.md index c7ede86..fcb602f 100644 --- a/README.md +++ b/README.md @@ -88,3 +88,77 @@ end Supabase.Functions.invoke(client, "stream-data", on_response: on_response) # :ok ``` + +## Timeout Support + +You can control the timeout for function invocations using the `timeout` option. If no timeout is specified, requests will timeout after 15 seconds by default. + +```elixir +client = Supabase.init_client!("SUPABASE_URL", "SUPABASE_KEY") + +# Basic invocation with default timeout (15 seconds) +{:ok, response} = Supabase.Functions.invoke(client, "my-function") + +# Custom timeout (5 seconds) +{:ok, response} = Supabase.Functions.invoke(client, "my-function", timeout: 5_000) + +# Timeout with body and headers +{:ok, response} = Supabase.Functions.invoke(client, "my-function", + body: %{data: "value"}, + headers: %{"x-custom" => "header"}, + timeout: 30_000) + +# Streaming with timeout +on_response = fn {status, headers, body} -> + # Handle streaming response + {:ok, body} +end + +{:ok, response} = Supabase.Functions.invoke(client, "my-function", + on_response: on_response, + timeout: 10_000) +``` + +This feature provides: +- **Request cancellation**: Long-running requests will timeout and be cancelled +- **Better resource management**: Prevents hanging connections +- **Comprehensive timeout coverage**: Sets both receive timeout (per-chunk) and request timeout (complete response) +- **Feature parity with JS client**: Matches timeout functionality in the JavaScript SDK + +## Dynamic Auth Token Updates + +You can update authorization tokens dynamically using two approaches, providing feature parity with the JavaScript client's `setAuth(token)` method: + +### Option 1: Functional Update + +Create a new client with an updated auth token: + +```elixir +client = Supabase.init_client!("SUPABASE_URL", "SUPABASE_KEY") + +# Update auth token functionally +updated_client = Supabase.Functions.update_auth(client, "new_jwt_token") +{:ok, response} = Supabase.Functions.invoke(updated_client, "my-function") +``` + +### Option 2: Per-Request Override + +Override the authorization token for a specific request: + +```elixir +client = Supabase.init_client!("SUPABASE_URL", "SUPABASE_KEY") + +# Use a different token for this request only +{:ok, response} = Supabase.Functions.invoke(client, "my-function", auth: "user_jwt_token") + +# Original client remains unchanged for subsequent calls +{:ok, response2} = Supabase.Functions.invoke(client, "another-function") +``` + +### Benefits + +- **Token rotation support**: Easily update tokens without recreating clients +- **Better performance**: Avoid the overhead of creating new client instances +- **Flexibility**: Use different tokens per request or update client globally +- **Feature parity**: Matches the JavaScript client's `setAuth()` functionality +- **Immutability**: Original client instances remain unchanged with functional updates diff --git a/lib/supabase/functions.ex b/lib/supabase/functions.ex index e3865b4..854a81f 100644 --- a/lib/supabase/functions.ex +++ b/lib/supabase/functions.ex @@ -24,6 +24,8 @@ defmodule Supabase.Functions do - `method`: The HTTP method of the request. - `region`: The Region to invoke the function in. - `on_response`: The custom response handler for response streaming. + - `timeout`: The timeout in milliseconds for the request. Defaults to 15 seconds. + - `auth`: Override the authorization token for this request. """ @type opt :: {:body, Fetcher.body()} @@ -31,6 +33,8 @@ defmodule Supabase.Functions do | {:method, Fetcher.method()} | {:region, region} | {:on_response, on_response} + | {:timeout, pos_integer()} + | {:auth, String.t()} @type on_response :: ({Fetcher.status(), Fetcher.headers(), body :: Enumerable.t()} -> Supabase.result(Response.t())) @@ -52,6 +56,24 @@ defmodule Supabase.Functions do | :"ca-central-1" | :"eu-central-1" + @doc """ + Updates the access token for a client + + Creates a new client instance with the updated access token. This provides + feature parity with the JavaScript client's `setAuth(token)` method. + + ## Examples + + # Update auth token functionally + new_client = Supabase.Functions.update_auth(client, "new_token") + {:ok, response} = Supabase.Functions.invoke(new_client, "my-function") + + """ + @spec update_auth(Client.t(), String.t()) :: Client.t() + def update_auth(%Client{} = client, token) when is_binary(token) do + %{client | access_token: token} + end + @doc """ Invokes a function @@ -59,13 +81,60 @@ defmodule Supabase.Functions do - When you pass in a body to your function, we automatically attach the `Content-Type` header automatically. If it doesn't match any of these types we assume the payload is json, serialize it and attach the `Content-Type` header as `application/json`. You can override this behavior by passing in a `Content-Type` header of your own. - Responses are automatically parsed as json depending on the Content-Type header sent by your function. Responses are parsed as text by default. + + ## Authentication + + You can override the authorization token for a specific request using the `auth` option: + + # Use a different token for this request + {:ok, response} = Supabase.Functions.invoke(client, "my-function", auth: "new_token") + + Alternatively, you can update the client's auth token functionally: + + # Update the client's token + new_client = Supabase.Functions.update_auth(client, "new_token") + {:ok, response} = Supabase.Functions.invoke(new_client, "my-function") + + ## Timeout Support + + You can set a timeout for function invocations using the `timeout` option. This sets both the + receive timeout (for individual chunks) and request timeout (for the complete response): + + # Timeout after 5 seconds + Supabase.Functions.invoke(client, "my-function", timeout: 5_000) + + If no timeout is specified, requests will timeout after 15 seconds by default. + + ## Examples + + # Basic invocation + {:ok, response} = Supabase.Functions.invoke(client, "my-function") + + # With timeout + {:ok, response} = Supabase.Functions.invoke(client, "my-function", timeout: 10_000) + + # With body and timeout + {:ok, response} = Supabase.Functions.invoke(client, "my-function", + body: %{data: "value"}, + timeout: 30_000) + + # With custom auth token + {:ok, response} = Supabase.Functions.invoke(client, "my-function", auth: "custom_token") """ @spec invoke(Client.t(), function :: String.t(), opts) :: Supabase.result(Response.t()) def invoke(%Client{} = client, name, opts \\ []) when is_binary(name) do method = opts[:method] || :post custom_headers = opts[:headers] || %{} + timeout = opts[:timeout] || 15_000 - client + # Handle auth token override + effective_client = + case opts[:auth] do + nil -> client + auth_token when is_binary(auth_token) -> update_auth(client, auth_token) + end + + effective_client |> Request.new(decode_body?: false) |> Request.with_functions_url(name) |> Request.with_method(method) @@ -75,7 +144,7 @@ defmodule Supabase.Functions do |> Request.with_body_decoder(nil) |> maybe_define_content_type(opts[:body]) |> Request.with_headers(custom_headers) - |> execute_request(opts[:on_response]) + |> execute_request(opts[:on_response], timeout) |> maybe_decode_body() |> handle_response() end @@ -103,12 +172,13 @@ defmodule Supabase.Functions do defp raw_binary?(bin), do: not String.printable?(bin) - defp execute_request(req, on_response) do + defp execute_request(req, on_response, timeout) do + opts = [receive_timeout: timeout, request_timeout: timeout] + if on_response do - Fetcher.stream(req, on_response) + Fetcher.stream(req, on_response, opts) else - # consume all the response, answers eagerly - Fetcher.stream(req) + Fetcher.stream(req, nil, opts) end end diff --git a/test/supabase/functions_test.exs b/test/supabase/functions_test.exs index d32c011..1667b2a 100644 --- a/test/supabase/functions_test.exs +++ b/test/supabase/functions_test.exs @@ -34,7 +34,7 @@ defmodule Supabase.FunctionsTest do end test "handles text response content type", %{client: client} do - expect(@mock, :stream, fn _request, _ -> + expect(@mock, :stream, fn _request, _opts -> {:ok, %Finch.Response{ status: 200, @@ -50,7 +50,7 @@ defmodule Supabase.FunctionsTest do test "sets appropriate content-type for binary data", %{client: client} do binary_data = <<0, 1, 2, 3>> - expect(@mock, :stream, fn request, _ -> + expect(@mock, :stream, fn request, _opts -> assert Request.get_header(request, "content-type") == "application/octet-stream" assert request.body == binary_data @@ -71,7 +71,7 @@ defmodule Supabase.FunctionsTest do test "sets appropriate content-type for JSON data", %{client: client} do json_data = %{test: "data"} - expect(@mock, :stream, fn request, _ -> + expect(@mock, :stream, fn request, _opts -> assert Request.get_header(request, "content-type") == "application/json" # fetcher will io encode it assert {:ok, _} = Jason.decode(request.body) @@ -93,7 +93,7 @@ defmodule Supabase.FunctionsTest do test "handles custom headers", %{client: client} do custom_headers = %{"x-custom-header" => "test-value"} - expect(@mock, :stream, fn request, _ -> + expect(@mock, :stream, fn request, _opts -> assert Request.get_header(request, "x-custom-header") == "test-value" {:ok, @@ -116,7 +116,7 @@ defmodule Supabase.FunctionsTest do test "handles streaming responses with custom handler", %{client: client} do chunks = ["chunk1", "chunk2", "chunk3"] - expect(@mock, :stream, fn _request, on_response, _ -> + expect(@mock, :stream, fn _request, on_response, _opts -> Enum.each(chunks, fn chunk -> on_response.({200, %{"content-type" => "text/plain"}, [chunk]}) end) @@ -141,7 +141,7 @@ defmodule Supabase.FunctionsTest do end test "handles error responses", %{client: client} do - expect(@mock, :stream, fn _request, _ -> + expect(@mock, :stream, fn _request, _opts -> {:ok, %Finch.Response{ status: 404, @@ -156,7 +156,7 @@ defmodule Supabase.FunctionsTest do end test "uses custom HTTP method when specified", %{client: client} do - expect(@mock, :stream, fn request, _ -> + expect(@mock, :stream, fn request, _opts -> assert request.method == :get {:ok, @@ -174,7 +174,7 @@ defmodule Supabase.FunctionsTest do end test "handles relay errors", %{client: client} do - expect(@mock, :stream, fn _request, _ -> + expect(@mock, :stream, fn _request, _opts -> {:ok, %Finch.Response{ status: 200, @@ -188,5 +188,216 @@ defmodule Supabase.FunctionsTest do assert error.code == :relay_error end + + test "passes timeout option to underlying HTTP client", %{client: client} do + expect(@mock, :stream, fn _request, opts -> + assert Keyword.get(opts, :receive_timeout) == 5_000 + assert Keyword.get(opts, :request_timeout) == 5_000 + + {:ok, + %Finch.Response{ + status: 200, + headers: %{"content-type" => "application/json"}, + body: ~s({"success": true}) + }} + end) + + assert {:ok, response} = + Functions.invoke(client, "test-function", timeout: 5_000, http_client: @mock) + + assert response.body == %{"success" => true} + end + + test "uses default timeout when not specified", %{client: client} do + expect(@mock, :stream, fn _request, opts -> + assert Keyword.get(opts, :receive_timeout) == 15_000 + assert Keyword.get(opts, :request_timeout) == 15_000 + + {:ok, + %Finch.Response{ + status: 200, + headers: %{"content-type" => "application/json"}, + body: ~s({"success": true}) + }} + end) + + assert {:ok, response} = + Functions.invoke(client, "test-function", http_client: @mock) + + assert response.body == %{"success" => true} + end + + test "timeout works with streaming response", %{client: client} do + chunks = ["chunk1", "chunk2"] + + expect(@mock, :stream, fn _request, on_response, opts -> + assert Keyword.get(opts, :receive_timeout) == 2_000 + assert Keyword.get(opts, :request_timeout) == 2_000 + + Enum.each(chunks, fn chunk -> + on_response.({200, %{"content-type" => "text/plain"}, [chunk]}) + end) + + {:ok, Enum.join(chunks)} + end) + + on_response = fn {status, headers, body} -> + assert status == 200 + assert headers["content-type"] == "text/plain" + {:ok, body} + end + + assert {:ok, response} = + Functions.invoke(client, "test-function", + on_response: on_response, + timeout: 2_000, + http_client: @mock + ) + + assert response == "chunk1chunk2" + end + end + + describe "update_auth/2" do + test "returns a new client with updated access token", %{client: client} do + new_token = "new_test_token" + updated_client = Functions.update_auth(client, new_token) + + assert updated_client.access_token == new_token + # Original client should remain unchanged + assert client.access_token != new_token + # All other fields should remain the same + assert updated_client.base_url == client.base_url + assert updated_client.api_key == client.api_key + end + + test "updated client works with invoke/3", %{client: client} do + new_token = "updated_token" + + expect(@mock, :stream, fn request, _opts -> + assert Request.get_header(request, "authorization") == "Bearer #{new_token}" + + {:ok, + %Finch.Response{ + status: 200, + headers: %{"content-type" => "application/json"}, + body: ~s({"success": true}) + }} + end) + + updated_client = Functions.update_auth(client, new_token) + + assert {:ok, response} = + Functions.invoke(updated_client, "test-function", http_client: @mock) + + assert response.body == %{"success" => true} + end + end + + describe "auth option in invoke/3" do + test "overrides authorization header with custom token", %{client: client} do + custom_token = "custom_auth_token" + + expect(@mock, :stream, fn request, _opts -> + assert Request.get_header(request, "authorization") == "Bearer #{custom_token}" + + {:ok, + %Finch.Response{ + status: 200, + headers: %{"content-type" => "application/json"}, + body: ~s({"authorized": true}) + }} + end) + + assert {:ok, response} = + Functions.invoke(client, "test-function", auth: custom_token, http_client: @mock) + + assert response.body == %{"authorized" => true} + end + + test "works with other options combined", %{client: client} do + custom_token = "combined_auth_token" + custom_headers = %{"x-custom" => "value"} + body_data = %{test: "data"} + + expect(@mock, :stream, fn request, _opts -> + assert Request.get_header(request, "authorization") == "Bearer #{custom_token}" + assert Request.get_header(request, "x-custom") == "value" + assert Request.get_header(request, "content-type") == "application/json" + + {:ok, + %Finch.Response{ + status: 200, + headers: %{"content-type" => "application/json"}, + body: ~s({"success": true}) + }} + end) + + assert {:ok, response} = + Functions.invoke(client, "test-function", + auth: custom_token, + headers: custom_headers, + body: body_data, + http_client: @mock + ) + + assert response.body == %{"success" => true} + end + + test "original client remains unchanged after auth override", %{client: client} do + original_token = client.access_token + custom_token = "temporary_override_token" + + expect(@mock, :stream, fn request, _opts -> + assert Request.get_header(request, "authorization") == "Bearer #{custom_token}" + + {:ok, + %Finch.Response{ + status: 200, + headers: %{"content-type" => "application/json"}, + body: ~s({"success": true}) + }} + end) + + assert {:ok, _response} = + Functions.invoke(client, "test-function", auth: custom_token, http_client: @mock) + + # Original client should be unchanged + assert client.access_token == original_token + end + + test "nil auth option uses original client token", %{client: client} do + expect(@mock, :stream, fn request, _opts -> + assert Request.get_header(request, "authorization") == "Bearer #{client.access_token}" + + {:ok, + %Finch.Response{ + status: 200, + headers: %{"content-type" => "application/json"}, + body: ~s({"success": true}) + }} + end) + + assert {:ok, response} = + Functions.invoke(client, "test-function", auth: nil, http_client: @mock) + + assert response.body == %{"success" => true} + end + + test "empty auth option uses original client token", %{client: client} do + expect(@mock, :stream, fn request, _opts -> + assert Request.get_header(request, "authorization") == "Bearer #{client.access_token}" + + {:ok, + %Finch.Response{ + status: 200, + headers: %{"content-type" => "application/json"}, + body: ~s({"success": true}) + }} + end) + + assert {:ok, response} = Functions.invoke(client, "test-function", http_client: @mock) + assert response.body == %{"success" => true} + end end end