Skip to content

Commit

Permalink
add shape_from_params utility function
Browse files Browse the repository at this point in the history
  • Loading branch information
magnetised committed Nov 20, 2024
1 parent c6f4499 commit 8bdb9d1
Show file tree
Hide file tree
Showing 3 changed files with 196 additions and 2 deletions.
96 changes: 96 additions & 0 deletions lib/electric/phoenix/plug.ex
Original file line number Diff line number Diff line change
Expand Up @@ -150,13 +150,21 @@ defmodule Electric.Phoenix.Plug do
require Ecto.Query

@valid_ops [:==, :!=, :>, :<, :>=, :<=]
@shape_keys [:namespace, :where, :columns]
@shape_params @shape_keys |> Enum.map(&to_string/1)

@type table_column :: atom()
@type param_name :: atom()
@type op :: :== | :!= | :> | :< | :>= | :<=
@type conn_param_spec :: param_name() | [{op(), param_name()}]
@type dynamic_shape_param :: {table_column(), conn_param_spec()} | table_column()
@type dynamic_shape_params :: [dynamic_shape_param()]
@type param_override ::
{:namespace, String.t()}
| {:table, String.t()}
| {:where, String.t()}
| {:columns, String.t()}
@type param_overrides :: [param_override()]

plug :fetch_query_params
plug :shape_definition
Expand Down Expand Up @@ -440,4 +448,92 @@ defmodule Electric.Phoenix.Plug do
defp authenticator_fun(_conn) do
fn _conn, _shape -> %{} end
end

@doc """
Use the request query parameters to create a
`Electric.Client.ShapeDefinition`.
Useful when creating authorization endpoints that validate a user's access to
a specific shape.
## Parameters
### Required
- `table` - the Postgres [table name](https://electric-sql.com/docs/guides/shapes#table)
### Optional
- `where` - the [Shape's where clause](https://electric-sql.com/docs/guides/shapes#where-clause)
- `columns` - The columns to include in the shape.
- `namespace` - The Postgres namespace (also called `SCHEMA`).
See
[`Electric.Client.ShapeDefinition.new/2`](`Electric.Client.ShapeDefinition.new/2`)
for more details on the parameters.
### Examples
# pass the Plug.Conn struct for a request
iex> Electric.Phoenix.Plug.shape_from_params(%Plug.Conn{params: %{"table" => "items", "where" => "visible = true" }})
{:ok, %Electric.Client.ShapeDefinition{table: "items", where: "visible = true"}}
# or a simple parameter map
iex> Electric.Phoenix.Plug.shape_from_params(%{"table" => "items", "columns" => "id,name,value" })
{:ok, %Electric.Client.ShapeDefinition{table: "items", columns: ["id", "name", "value"]}}
iex> Electric.Phoenix.Plug.shape_from_params(%{"columns" => "id,name,value" })
{:error, "Missing `table` parameter"}
## Overriding Parameter Values
If you want to hard-code some elements of the shape, ignoring the values from
the request, or to set defaults, then use the `overrides` to set specific
values for elements of the shape.
### Examples
iex> Electric.Phoenix.Plug.shape_from_params(%{"columns" => "id,name,value"}, table: "things")
{:ok, %Electric.Client.ShapeDefinition{table: "things", columns: ["id", "name", "value"]}}
iex> Electric.Phoenix.Plug.shape_from_params(%{"table" => "ignored"}, table: "things")
{:ok, %Electric.Client.ShapeDefinition{table: "things"}}
"""
@spec shape_from_params(Plug.Conn.t() | %{binary() => binary()}, overrides :: param_overrides()) ::
{:ok, Electric.Client.ShapeDefinition.t()} | {:error, String.t()}

def shape_from_params(conn_or_map, overrides \\ [])

def shape_from_params(%Plug.Conn{} = conn, overrides) do
%{params: params} = Plug.Conn.fetch_query_params(conn)
shape_from_params(params, overrides)
end

def shape_from_params(params, overrides) when is_map(params) do
shape_params =
params
|> Map.take(@shape_params)
|> Map.new(fn
{"columns", ""} ->
{:columns, nil}

{"columns", v} when is_binary(v) ->
{:columns, :binary.split(v, ",", [:global, :trim_all])}

{k, v} ->
{String.to_existing_atom(k), v}
end)

if table = Keyword.get(overrides, :table, Map.get(params, "table")) do
ShapeDefinition.new(
table,
Enum.map(@shape_keys, fn k ->
{k, Keyword.get(overrides, k, Map.get(shape_params, k))}
end)
)
else
{:error, "Missing `table` parameter"}
end
end
end
4 changes: 2 additions & 2 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ defmodule Electric.Phoenix.MixProject do
def project do
[
app: :electric_phoenix,
version: "0.2.0",
version: "0.2.0-rc-3",
elixir: "~> 1.17",
start_permanent: Mix.env() == :prod,
elixirc_paths: elixirc_paths(Mix.env()),
Expand All @@ -30,7 +30,7 @@ defmodule Electric.Phoenix.MixProject do
# Run "mix help deps" to learn about dependencies.
defp deps do
[
{:electric_client, "> 0.2.0"},
{:electric_client, ">= 0.2.1-rc-1"},
{:nimble_options, "~> 1.1"},
{:phoenix_live_view, "~> 0.20"},
{:plug, "~> 1.0"},
Expand Down
98 changes: 98 additions & 0 deletions test/electric/phoenix/plug_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ defmodule Electric.Phoenix.PlugTest do

Code.ensure_loaded(Support.User)

doctest Electric.Phoenix.Plug

defmodule MyEnv do
def client!(opts \\ []) do
Electric.Client.new!(
Expand Down Expand Up @@ -369,4 +371,100 @@ defmodule Electric.Phoenix.PlugTest do
}
end
end

describe "shape_from_params/[1,2]" do
alias Electric.Client.ShapeDefinition

test "returns a ShapeDefinition based on the request query params" do
conn =
conn(:get, "/my/path", %{
"table" => "items",
"namespace" => "my_app",
"where" => "something = 'open'",
"columns" => "id,name,value"
})

assert {:ok,
%ShapeDefinition{
table: "items",
namespace: "my_app",
where: "something = 'open'",
columns: ["id", "name", "value"]
}} = Electric.Phoenix.Plug.shape_from_params(conn)

conn = conn(:get, "/my/path", %{"table" => "items"})

assert {:ok,
%ShapeDefinition{
table: "items",
namespace: nil,
where: nil,
columns: nil
}} = Electric.Phoenix.Plug.shape_from_params(conn)

conn = conn(:get, "/my/path", %{"where" => "true"})

assert {:error, _} = Electric.Phoenix.Plug.shape_from_params(conn)

conn =
conn(:get, "/my/path", %{"table" => "items", "columns" => nil})

assert {:ok, %ShapeDefinition{table: "items", columns: nil}} =
Electric.Phoenix.Plug.shape_from_params(conn)
end

test "accepts a parameter map" do
assert {:ok, %ShapeDefinition{table: "items"}} =
Electric.Phoenix.Plug.shape_from_params(%{
"table" => "items",
"columns" => nil,
"where" => nil
})

assert {:error, _} = Electric.Phoenix.Plug.shape_from_params(%{})

assert {:ok, %ShapeDefinition{table: "items"}} =
Electric.Phoenix.Plug.shape_from_params(%{},
table: "items"
)
end

test "allows for overriding specific attributes" do
conn =
conn(:get, "/my/path", %{
"table" => "ignored",
"namespace" => "ignored_as_well",
"columns" => "ignored,also",
"where" => "something = 'open'"
})

assert {:ok,
%ShapeDefinition{
table: "items",
namespace: "my_app",
where: "something = 'open'",
columns: ["id", "name", "value"]
}} =
Electric.Phoenix.Plug.shape_from_params(conn,
table: "items",
namespace: "my_app",
columns: ["id", "name", "value"]
)

conn = conn(:get, "/my/path", %{"where" => "something = 'open'"})

assert {:ok,
%ShapeDefinition{
table: "items",
namespace: "my_app",
where: "something = 'open'",
columns: ["id", "name", "value"]
}} =
Electric.Phoenix.Plug.shape_from_params(conn,
table: "items",
namespace: "my_app",
columns: ["id", "name", "value"]
)
end
end
end

0 comments on commit 8bdb9d1

Please sign in to comment.