From 8bdb9d15cb4de8ad0cd5913351963d8c3bc3b42c Mon Sep 17 00:00:00 2001 From: Garry Hill Date: Wed, 20 Nov 2024 10:50:29 +0000 Subject: [PATCH] add shape_from_params utility function --- lib/electric/phoenix/plug.ex | 96 ++++++++++++++++++++++++++++ mix.exs | 4 +- test/electric/phoenix/plug_test.exs | 98 +++++++++++++++++++++++++++++ 3 files changed, 196 insertions(+), 2 deletions(-) diff --git a/lib/electric/phoenix/plug.ex b/lib/electric/phoenix/plug.ex index b9ab2ae..3610882 100644 --- a/lib/electric/phoenix/plug.ex +++ b/lib/electric/phoenix/plug.ex @@ -150,6 +150,8 @@ 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() @@ -157,6 +159,12 @@ defmodule Electric.Phoenix.Plug do @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 @@ -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 diff --git a/mix.exs b/mix.exs index ea20954..191fcd4 100644 --- a/mix.exs +++ b/mix.exs @@ -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()), @@ -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"}, diff --git a/test/electric/phoenix/plug_test.exs b/test/electric/phoenix/plug_test.exs index 1ac2f5b..512fc37 100644 --- a/test/electric/phoenix/plug_test.exs +++ b/test/electric/phoenix/plug_test.exs @@ -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!( @@ -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