Skip to content

Commit

Permalink
elixir-client: Configuration of non-standard API endpoints (#1994)
Browse files Browse the repository at this point in the history
use `base_url` for the old behaviour of automatically appending
`/v1/shape` and `endpoint` to explictly set the full URL of the shape
api

Fixes #1992
  • Loading branch information
magnetised authored Nov 19, 2024
1 parent 2933f27 commit 4245ec9
Show file tree
Hide file tree
Showing 4 changed files with 104 additions and 23 deletions.
5 changes: 5 additions & 0 deletions .changeset/bright-melons-pay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@core/elixir-client": patch
---

Add :endpoint configuration for Elixir client for non-standard API URLs
58 changes: 51 additions & 7 deletions packages/elixir-client/lib/electric/client.ex
Original file line number Diff line number Diff line change
Expand Up @@ -150,18 +150,23 @@ defmodule Electric.Client do
end

defstruct [
:base_url,
:endpoint,
:database_id,
:fetch,
:authenticator
]

@api_endpoint_path "/v1/shape"
@client_schema NimbleOptions.new!(
base_url: [
type: :string,
required: true,
doc:
"The URL of the electric server, e.g. for local development this would be `http://localhost:3000`."
"The URL of the electric server, not including the path. E.g. for local development this would be `http://localhost:3000`."
],
endpoint: [
type: :string,
doc:
"The full URL of the shape API endpoint. E.g. for local development this would be `http://localhost:3000/v1/shape`. Use this if you need a non-standard API path."
],
database_id: [
type: {:or, [nil, :string]},
Expand Down Expand Up @@ -197,7 +202,7 @@ defmodule Electric.Client do
@type stream_options :: [stream_option()]

@type t :: %__MODULE__{
base_url: URI.t(),
endpoint: URI.t(),
fetch: {module(), term()}
}

Expand All @@ -207,12 +212,46 @@ defmodule Electric.Client do
## Options
#{NimbleOptions.docs(@client_schema)}
### `:base_url` vs. `:endpoint`
If you configure your client using e.g. `base_url: "http://localhost:3000"`
Electric will append the default shape API path
`#{inspect(@api_endpoint_path)}` to create the final endpoint configuration,
in this case `"http://localhost:3000#{@api_endpoint_path}"`. Note that any
trailing path in the `base_url` is ignored.
If you wish to use a non-standard endpoint path because, for example, you wrap your Shape
API calls in an [authentication
proxy](https://electric-sql.com/docs/guides/auth), then configure
the endpoint directly:
Client.new(endpoint: "https://my-server.my-domain.com/electric/shape/proxy")
"""
@spec new(client_options()) :: {:ok, t()} | {:error, term()}
def new(opts) do
with {:ok, attrs} <- NimbleOptions.validate(Map.new(opts), @client_schema),
{:ok, uri} <- URI.new(attrs[:base_url]) do
{:ok, struct(__MODULE__, Map.put(attrs, :base_url, uri))}
{:ok, endpoint} <- client_endpoint(attrs) do
{:ok, struct(__MODULE__, Map.put(attrs, :endpoint, endpoint))}
end
end

defp client_endpoint(attrs) when is_map(attrs) do
case Map.fetch(attrs, :endpoint) do
{:ok, endpoint} ->
URI.new(endpoint)

:error ->
case Map.fetch(attrs, :base_url) do
{:ok, url} ->
with {:ok, uri} <- URI.new(url) do
{:ok, %{uri | path: @api_endpoint_path}}
end

:error ->
{:error, "Client requires either a :base_url or :endpoint configuration"}
end
end
end

Expand Down Expand Up @@ -268,6 +307,11 @@ defmodule Electric.Client do
end
end

# pass through a pre-configured ShapeDefinition as-is so that this is idempotent
def shape!(%ShapeDefinition{} = shape) do
shape
end

@doc """
A shortcut to [`ShapeDefinition.new!/2`](`Electric.Client.ShapeDefinition.new!/2`).
"""
Expand Down Expand Up @@ -327,7 +371,7 @@ defmodule Electric.Client do
@doc false
@spec request(t(), Fetch.Request.attrs()) :: Fetch.Request.t()
def request(%Client{} = client, opts) do
struct(%Fetch.Request{base_url: client.base_url, database_id: client.database_id}, opts)
struct(%Fetch.Request{endpoint: client.endpoint, database_id: client.database_id}, opts)
end

@doc """
Expand Down
22 changes: 10 additions & 12 deletions packages/elixir-client/lib/electric/client/fetch/request.ex
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ defmodule Electric.Client.Fetch.Request do
require Logger

defstruct [
:base_url,
:endpoint,
:database_id,
:shape_handle,
:live,
Expand All @@ -29,7 +29,7 @@ defmodule Electric.Client.Fetch.Request do

fields = [
method: quote(do: :get | :head | :delete),
base_url: quote(do: URI.t()),
endpoint: quote(do: URI.t()),
offset: quote(do: Electric.Client.Offset.t()),
shape_handle: quote(do: Electric.Client.shape_handle() | nil),
replica: quote(do: Electric.Client.replica()),
Expand All @@ -45,7 +45,7 @@ defmodule Electric.Client.Fetch.Request do
@type t :: unauthenticated() | authenticated()

# the base url should come from the client
attrs = Keyword.delete(fields, :base_url)
attrs = Keyword.delete(fields, :endpoint)

attr_types =
attrs
Expand All @@ -63,30 +63,28 @@ defmodule Electric.Client.Fetch.Request do
end

defp request_id(%Client{fetch: {fetch_impl, _}}, %__MODULE__{shape_handle: nil} = request) do
%{base_url: base_url, shape: shape_definition} = request
{fetch_impl, base_url, shape_definition}
%{endpoint: endpoint, shape: shape_definition} = request
{fetch_impl, URI.to_string(endpoint), shape_definition}
end

defp request_id(%Client{fetch: {fetch_impl, _}}, %__MODULE__{} = request) do
%{base_url: base_url, offset: offset, live: live, shape_handle: shape_handle} = request
{fetch_impl, base_url, shape_handle, Offset.to_tuple(offset), live}
%{endpoint: endpoint, offset: offset, live: live, shape_handle: shape_handle} = request
{fetch_impl, URI.to_string(endpoint), shape_handle, Offset.to_tuple(offset), live}
end

@doc """
Returns the URL for the Request.
"""
@spec url(t()) :: binary()
def url(%__MODULE__{} = request, opts \\ []) do
%{base_url: base_url} = request
path = "/v1/shape"
uri = URI.append_path(base_url, path)
%{endpoint: endpoint} = request

if Keyword.get(opts, :query, true) do
query = request |> params() |> URI.encode_query(:rfc3986)

URI.to_string(%{uri | query: query})
URI.to_string(%{endpoint | query: query})
else
URI.to_string(uri)
URI.to_string(endpoint)
end
end

Expand Down
42 changes: 38 additions & 4 deletions packages/elixir-client/test/electric/client_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,40 @@ defmodule Electric.ClientTest do
end

describe "new" do
test ":url is used as the base of the endpoint" do
endpoint = URI.new!("http://localhost:3000/v1/shape")

{:ok, %Client{endpoint: ^endpoint}} =
Client.new(base_url: "http://localhost:3000")

{:ok, %Client{endpoint: ^endpoint}} =
Client.new(base_url: "http://localhost:3000/v1/shape")

{:ok, %Client{endpoint: ^endpoint}} =
Client.new(base_url: "http://localhost:3000/some/random/path")
end

test ":endpoint is used as-is" do
endpoint = URI.new!("http://localhost:3000")

{:ok, %Client{endpoint: ^endpoint}} =
Client.new(endpoint: "http://localhost:3000")

endpoint = URI.new!("http://localhost:3000/v1/shape")

{:ok, %Client{endpoint: ^endpoint}} =
Client.new(endpoint: "http://localhost:3000/v1/shape")

endpoint = URI.new!("http://localhost:3000/some/random/path")

{:ok, %Client{endpoint: ^endpoint}} =
Client.new(endpoint: "http://localhost:3000/some/random/path")
end

test "returns an error if neither :base_url or :endpoint is given" do
assert {:error, _} = Client.new([])
end

test "database_id is correctly assigned" do
{:ok, %Client{database_id: "1234"}} =
Client.new(base_url: "http://localhost:3000", database_id: "1234")
Expand Down Expand Up @@ -173,12 +207,12 @@ defmodule Electric.ClientTest do
{:ok, id2} = insert_item(ctx)
{:ok, id3} = insert_item(ctx)

assert_receive {:stream, 1, %ChangeMessage{value: %{"id" => ^id2}}}, 500
assert_receive {:stream, 1, %ChangeMessage{value: %{"id" => ^id3}}}, 500
assert_receive {:stream, 1, %ChangeMessage{value: %{"id" => ^id2}}}, 5000
assert_receive {:stream, 1, %ChangeMessage{value: %{"id" => ^id3}}}, 5000
assert_receive {:stream, 1, up_to_date()}

assert_receive {:stream, 2, %ChangeMessage{value: %{"id" => ^id2}}}, 500
assert_receive {:stream, 2, %ChangeMessage{value: %{"id" => ^id3}}}, 500
assert_receive {:stream, 2, %ChangeMessage{value: %{"id" => ^id2}}}, 5000
assert_receive {:stream, 2, %ChangeMessage{value: %{"id" => ^id3}}}, 5000
assert_receive {:stream, 2, up_to_date()}
end

Expand Down

0 comments on commit 4245ec9

Please sign in to comment.