From 4245ec977fddc9e6b3e26d98c5bb14822d1ebfcf Mon Sep 17 00:00:00 2001 From: Garry Hill Date: Tue, 19 Nov 2024 13:25:28 +0000 Subject: [PATCH] elixir-client: Configuration of non-standard API endpoints (#1994) 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 --- .changeset/bright-melons-pay.md | 5 ++ packages/elixir-client/lib/electric/client.ex | 58 ++++++++++++++++--- .../lib/electric/client/fetch/request.ex | 22 ++++--- .../test/electric/client_test.exs | 42 ++++++++++++-- 4 files changed, 104 insertions(+), 23 deletions(-) create mode 100644 .changeset/bright-melons-pay.md diff --git a/.changeset/bright-melons-pay.md b/.changeset/bright-melons-pay.md new file mode 100644 index 0000000000..fcdeaa24ae --- /dev/null +++ b/.changeset/bright-melons-pay.md @@ -0,0 +1,5 @@ +--- +"@core/elixir-client": patch +--- + +Add :endpoint configuration for Elixir client for non-standard API URLs diff --git a/packages/elixir-client/lib/electric/client.ex b/packages/elixir-client/lib/electric/client.ex index ca3fd4927f..c1b31377a1 100644 --- a/packages/elixir-client/lib/electric/client.ex +++ b/packages/elixir-client/lib/electric/client.ex @@ -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]}, @@ -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()} } @@ -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 @@ -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`). """ @@ -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 """ diff --git a/packages/elixir-client/lib/electric/client/fetch/request.ex b/packages/elixir-client/lib/electric/client/fetch/request.ex index 6cf38809ef..bae0b1b1f7 100644 --- a/packages/elixir-client/lib/electric/client/fetch/request.ex +++ b/packages/elixir-client/lib/electric/client/fetch/request.ex @@ -10,7 +10,7 @@ defmodule Electric.Client.Fetch.Request do require Logger defstruct [ - :base_url, + :endpoint, :database_id, :shape_handle, :live, @@ -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()), @@ -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 @@ -63,13 +63,13 @@ 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 """ @@ -77,16 +77,14 @@ defmodule Electric.Client.Fetch.Request do """ @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 diff --git a/packages/elixir-client/test/electric/client_test.exs b/packages/elixir-client/test/electric/client_test.exs index 00ee7b4f57..652772787c 100644 --- a/packages/elixir-client/test/electric/client_test.exs +++ b/packages/elixir-client/test/electric/client_test.exs @@ -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") @@ -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