diff --git a/lib/sequin/consumers/consumers.ex b/lib/sequin/consumers/consumers.ex index 1ecfab0d4..82fed9de4 100644 --- a/lib/sequin/consumers/consumers.ex +++ b/lib/sequin/consumers/consumers.ex @@ -187,6 +187,7 @@ defmodule Sequin.Consumers do |> Enum.reduce(Function.where_account_id(account_id), fn {:name, name}, query -> Function.where_name(query, name) {:id, id}, query -> Function.where_id(query, id) + {:id_or_name, id_or_name}, query -> Function.where_id_or_name(query, id_or_name) end) |> Repo.one() |> case do @@ -229,10 +230,16 @@ defmodule Sequin.Consumers do def delete_function(account_id, id) do with {:ok, function} <- get_function_for_account(account_id, id) do + # Every column on sink_consumers that references functions(id). Constraint + # names follow the original column names (transform_id predates the + # transforms→functions rename and kept its FK name), so list each one + # explicitly rather than guessing. function |> Function.changeset(%{}) - |> Ecto.Changeset.foreign_key_constraint(:id, name: "sink_consumers_function_id_fkey") + |> Ecto.Changeset.foreign_key_constraint(:id, name: "sink_consumers_transform_id_fkey") |> Ecto.Changeset.foreign_key_constraint(:id, name: "sink_consumers_routing_id_fkey") + |> Ecto.Changeset.foreign_key_constraint(:id, name: "sink_consumers_filter_id_fkey") + |> Ecto.Changeset.foreign_key_constraint(:id, name: "sink_consumers_enrichment_id_fkey") |> Repo.delete() end end diff --git a/lib/sequin_web/controllers/function_controller.ex b/lib/sequin_web/controllers/function_controller.ex new file mode 100644 index 000000000..5cff5c5dc --- /dev/null +++ b/lib/sequin_web/controllers/function_controller.ex @@ -0,0 +1,31 @@ +defmodule SequinWeb.FunctionController do + use SequinWeb, :controller + + alias Sequin.Consumers + alias SequinWeb.ApiFallbackPlug + + action_fallback ApiFallbackPlug + + def index(conn, _params) do + account_id = conn.assigns.account_id + + render(conn, "index.json", functions: Consumers.list_functions_for_account(account_id)) + end + + def show(conn, %{"id_or_name" => id_or_name}) do + account_id = conn.assigns.account_id + + with {:ok, function} <- Consumers.find_function(account_id, id_or_name: id_or_name) do + render(conn, "show.json", function: function) + end + end + + def delete(conn, %{"id_or_name" => id_or_name}) do + account_id = conn.assigns.account_id + + with {:ok, function} <- Consumers.find_function(account_id, id_or_name: id_or_name), + {:ok, _function} <- Consumers.delete_function(account_id, function.id) do + render(conn, "delete.json", function: function) + end + end +end diff --git a/lib/sequin_web/controllers/function_json.ex b/lib/sequin_web/controllers/function_json.ex new file mode 100644 index 000000000..5fcf03bfc --- /dev/null +++ b/lib/sequin_web/controllers/function_json.ex @@ -0,0 +1,31 @@ +defmodule SequinWeb.FunctionJSON do + @doc """ + Renders a list of functions. + """ + def index(%{functions: functions}) do + %{data: for(function <- functions, do: render_one(function))} + end + + @doc """ + Renders a single function. + """ + def show(%{function: function}) do + render_one(function) + end + + @doc """ + Renders a deleted function. + """ + def delete(%{function: function}) do + %{id: function.id, deleted: true} + end + + # `Sequin.Transforms.to_external/1` intentionally omits identity fields + # (it's shared with config export, which is ID-agnostic). The management + # API needs the id so clients can follow up with show/delete. + defp render_one(function) do + function + |> Sequin.Transforms.to_external() + |> Map.put(:id, function.id) + end +end diff --git a/lib/sequin_web/router.ex b/lib/sequin_web/router.ex index 93b868dfd..4e2232d0b 100644 --- a/lib/sequin_web/router.ex +++ b/lib/sequin_web/router.ex @@ -167,6 +167,9 @@ defmodule SequinWeb.Router do # HTTP Endpoints routes resources("/destinations/http_endpoints", HttpEndpointController, except: [:new, :edit], param: "id_or_name") + # Function routes + resources("/functions", FunctionController, only: [:index, :show, :delete], param: "id_or_name") + # Sink Consumer routes resources("/sinks", SinkConsumerController, except: [:new, :edit], param: "id_or_name") # Backfill routes diff --git a/test/sequin_web/controllers/function_controller_test.exs b/test/sequin_web/controllers/function_controller_test.exs new file mode 100644 index 000000000..9513895ae --- /dev/null +++ b/test/sequin_web/controllers/function_controller_test.exs @@ -0,0 +1,86 @@ +defmodule SequinWeb.FunctionControllerTest do + use SequinWeb.ConnCase, async: true + + alias Sequin.Consumers + alias Sequin.Factory.AccountsFactory + alias Sequin.Factory.ConsumersFactory + alias Sequin.Factory.FunctionsFactory + + setup :authenticated_conn + + setup %{account: account} do + other_account = AccountsFactory.insert_account!() + function = FunctionsFactory.insert_function!(account_id: account.id) + other_function = FunctionsFactory.insert_function!(account_id: other_account.id) + + %{function: function, other_function: other_function, other_account: other_account} + end + + describe "index" do + test "lists functions in the given account", %{conn: conn, account: account, function: function} do + another_function = FunctionsFactory.insert_function!(account_id: account.id) + + conn = get(conn, ~p"/api/functions") + assert %{"data" => functions} = json_response(conn, 200) + assert length(functions) == 2 + ids = Enum.map(functions, & &1["id"]) + assert function.id in ids + assert another_function.id in ids + end + + test "does not list functions from another account", %{conn: conn, other_function: other_function} do + conn = get(conn, ~p"/api/functions") + assert %{"data" => functions} = json_response(conn, 200) + refute Enum.any?(functions, &(&1["id"] == other_function.id)) + end + end + + describe "show" do + test "shows function details by id", %{conn: conn, function: function} do + conn = get(conn, ~p"/api/functions/#{function.id}") + assert json = json_response(conn, 200) + assert json["name"] == function.name + end + + test "shows function details by name", %{conn: conn, function: function} do + conn = get(conn, ~p"/api/functions/#{function.name}") + assert json = json_response(conn, 200) + assert json["name"] == function.name + end + + test "returns 404 if function belongs to another account", %{conn: conn, other_function: other_function} do + conn = get(conn, ~p"/api/functions/#{other_function.id}") + assert json_response(conn, 404) + end + end + + describe "delete" do + test "deletes the function", %{conn: conn, function: function} do + conn = delete(conn, ~p"/api/functions/#{function.id}") + assert %{"id" => id, "deleted" => true} = json_response(conn, 200) + + assert {:error, _} = Consumers.find_function(function.account_id, id: id) + end + + test "deletes by name", %{conn: conn, function: function} do + conn = delete(conn, ~p"/api/functions/#{function.name}") + assert %{"deleted" => true} = json_response(conn, 200) + end + + test "returns 404 if function belongs to another account", %{conn: conn, other_function: other_function} do + conn = delete(conn, ~p"/api/functions/#{other_function.id}") + assert json_response(conn, 404) + end + + test "refuses to delete a function still referenced by a sink", %{conn: conn, account: account, function: function} do + sink = ConsumersFactory.insert_sink_consumer!(account_id: account.id) + {:ok, _sink} = Consumers.update_sink_consumer(sink, %{transform_id: function.id}) + + conn = delete(conn, ~p"/api/functions/#{function.id}") + assert conn.status in 400..499 + + # function must still exist + assert {:ok, _} = Consumers.find_function(account.id, id: function.id) + end + end +end