From a5c40feb77bca5b5fa9602a084cc2dd539327c75 Mon Sep 17 00:00:00 2001 From: Barnabas Jovanovics Date: Sat, 14 Oct 2023 13:08:21 +0200 Subject: [PATCH 01/48] copy absinthe subscription test --- test/subscription_test.exs | 735 +++++++++++++++++++++++++++++++++++++ 1 file changed, 735 insertions(+) create mode 100644 test/subscription_test.exs diff --git a/test/subscription_test.exs b/test/subscription_test.exs new file mode 100644 index 00000000..6f64b7a7 --- /dev/null +++ b/test/subscription_test.exs @@ -0,0 +1,735 @@ +defmodule AshGraphql.SubscriptionTest do + use ExUnit.Case + + import ExUnit.CaptureLog + + def run(document, schema, options \\ []) do + Absinthe.run(document, schema, options) + end + + defmodule ResultPhase do + @moduledoc false + + alias Absinthe.Blueprint + use Absinthe.Phase + + def run(%Blueprint{} = bp, _options \\ []) do + result = Map.merge(bp.result, process(bp)) + {:ok, %{bp | result: result}} + end + + defp process(blueprint) do + data = data(blueprint.execution.result) + %{data: data} + end + + defp data(%{value: value}), do: value + + defp data(%{fields: []} = result) do + result.root_value + end + + defp data(%{fields: fields, emitter: emitter, root_value: root_value}) do + with %{put: _} <- emitter.flags, + true <- is_map(root_value) do + data = field_data(fields) + Map.merge(root_value, data) + else + _ -> + field_data(fields) + end + end + + defp field_data(fields, acc \\ []) + defp field_data([], acc), do: Map.new(acc) + + defp field_data([field | fields], acc) do + value = data(field) + field_data(fields, [{String.to_existing_atom(field.emitter.name), value} | acc]) + end + end + + defmodule PubSub do + @behaviour Absinthe.Subscription.Pubsub + + def start_link() do + Registry.start_link(keys: :duplicate, name: __MODULE__) + end + + def node_name() do + node() + end + + def subscribe(topic) do + Registry.register(__MODULE__, topic, []) + :ok + end + + def publish_subscription(topic, data) do + message = %{ + topic: topic, + event: "subscription:data", + result: data + } + + Registry.dispatch(__MODULE__, topic, fn entries -> + for {pid, _} <- entries, do: send(pid, {:broadcast, message}) + end) + end + + def publish_mutation(_proxy_topic, _mutation_result, _subscribed_fields) do + # this pubsub is local and doesn't support clusters + :ok + end + end + + defmodule Schema do + use Absinthe.Schema + + query do + field(:foo, :string) + end + + object :user do + field(:id, :id) + field(:name, :string) + + field :group, :group do + resolve(fn user, _, %{context: %{test_pid: pid}} -> + batch({__MODULE__, :batch_get_group, pid}, nil, fn _results -> + {:ok, user.group} + end) + end) + end + end + + object :group do + field(:name, :string) + end + + def batch_get_group(test_pid, _) do + # send a message to the test process every time we access this function. + # if batching is working properly, it should only happen once. + send(test_pid, :batch_get_group) + %{} + end + + subscription do + field :raises, :string do + config(fn _, _ -> + {:ok, topic: "*"} + end) + + resolve(fn _, _, _ -> + raise "boom" + end) + end + + field :user, :user do + arg(:id, :id) + + config(fn args, _ -> + {:ok, topic: args[:id] || "*"} + end) + + trigger(:update_user, + topic: fn user -> + [user.id, "*"] + end + ) + end + + field :thing, :string do + arg(:client_id, non_null(:id)) + + config(fn + _args, %{context: %{authorized: false}} -> + {:error, "unauthorized"} + + args, _ -> + { + :ok, + topic: args.client_id + } + end) + end + + field :multiple_topics, :string do + config(fn _, _ -> + {:ok, topic: ["topic_1", "topic_2", "topic_3"]} + end) + end + + field :other_user, :user do + arg(:id, :id) + + config(fn + args, %{context: %{context_id: context_id, document_id: document_id}} -> + {:ok, topic: args[:id] || "*", context_id: context_id, document_id: document_id} + + args, %{context: %{context_id: context_id}} -> + {:ok, topic: args[:id] || "*", context_id: context_id} + end) + end + + field :relies_on_document, :string do + config(fn _, %{document: %Absinthe.Blueprint{} = document} -> + %{type: :subscription, name: op_name} = Absinthe.Blueprint.current_operation(document) + {:ok, topic: "*", context_id: "*", document_id: op_name} + end) + end + end + + mutation do + field :update_user, :user do + arg(:id, non_null(:id)) + + resolve(fn _, %{id: id}, _ -> + {:ok, %{id: id, name: "foo"}} + end) + end + end + end + + setup_all do + {:ok, _} = PubSub.start_link() + {:ok, _} = Absinthe.Subscription.start_link(PubSub) + :ok + end + + @query """ + subscription ($clientId: ID!) { + thing(clientId: $clientId) + } + """ + test "should use result_phase from main pipeline" do + client_id = "abc" + + assert {:ok, %{"subscribed" => topic}} = + run_subscription( + @query, + Schema, + variables: %{"clientId" => client_id}, + context: %{pubsub: PubSub}, + result_phase: ResultPhase + ) + + Absinthe.Subscription.publish(PubSub, %{foo: "bar"}, thing: client_id) + + assert_receive({:broadcast, msg}) + + assert %{ + event: "subscription:data", + result: %{data: %{thing: %{foo: "bar"}}}, + topic: topic + } == msg + end + + @query """ + subscription ($clientId: ID!) { + thing(clientId: $clientId) + } + """ + test "can subscribe the current process" do + client_id = "abc" + + assert {:ok, %{"subscribed" => topic}} = + run_subscription( + @query, + Schema, + variables: %{"clientId" => client_id}, + context: %{pubsub: PubSub} + ) + + Absinthe.Subscription.publish(PubSub, "foo", thing: client_id) + + assert_receive({:broadcast, msg}) + + assert %{ + event: "subscription:data", + result: %{data: %{"thing" => "foo"}}, + topic: topic + } == msg + end + + @query """ + subscription ($clientId: ID!) { + thing(clientId: $clientId) + } + """ + test "can unsubscribe the current process" do + client_id = "abc" + + assert {:ok, %{"subscribed" => topic}} = + run_subscription( + @query, + Schema, + variables: %{"clientId" => client_id}, + context: %{pubsub: PubSub} + ) + + Absinthe.Subscription.unsubscribe(PubSub, topic) + + Absinthe.Subscription.publish(PubSub, "foo", thing: client_id) + + refute_receive({:broadcast, _}) + end + + @query """ + subscription { + multipleTopics + } + """ + test "schema can provide multiple topics to subscribe to" do + assert {:ok, %{"subscribed" => topic}} = + run_subscription( + @query, + Schema, + variables: %{}, + context: %{pubsub: PubSub} + ) + + msg = %{ + event: "subscription:data", + result: %{data: %{"multipleTopics" => "foo"}}, + topic: topic + } + + Absinthe.Subscription.publish(PubSub, "foo", multiple_topics: "topic_1") + + assert_receive({:broadcast, ^msg}) + + Absinthe.Subscription.publish(PubSub, "foo", multiple_topics: "topic_2") + + assert_receive({:broadcast, ^msg}) + + Absinthe.Subscription.publish(PubSub, "foo", multiple_topics: "topic_3") + + assert_receive({:broadcast, ^msg}) + end + + @query """ + subscription { + multipleTopics + } + """ + test "unsubscription works when multiple topics are provided" do + assert {:ok, %{"subscribed" => topic}} = + run_subscription( + @query, + Schema, + variables: %{}, + context: %{pubsub: PubSub} + ) + + Absinthe.Subscription.unsubscribe(PubSub, topic) + + Absinthe.Subscription.publish(PubSub, "foo", multiple_topics: "topic_1") + + refute_receive({:broadcast, _}) + + Absinthe.Subscription.publish(PubSub, "foo", multiple_topics: "topic_2") + + refute_receive({:broadcast, _}) + + Absinthe.Subscription.publish(PubSub, "foo", multiple_topics: "topic_3") + + refute_receive({:broadcast, _}) + end + + @query """ + subscription ($clientId: ID!) { + thing(clientId: $clientId, extra: 1) + } + """ + test "can return errors properly" do + assert { + :ok, + %{ + errors: [ + %{ + locations: [%{column: 30, line: 2}], + message: + "Unknown argument \"extra\" on field \"thing\" of type \"RootSubscriptionType\"." + } + ] + } + } == + run_subscription(@query, Schema, + variables: %{"clientId" => "abc"}, + context: %{pubsub: PubSub} + ) + end + + @query """ + subscription ($userId: ID!) { + user(id: $userId) { id name } + } + """ + test "subscription triggers work" do + id = "1" + + assert {:ok, %{"subscribed" => topic}} = + run_subscription( + @query, + Schema, + variables: %{"userId" => id}, + context: %{pubsub: PubSub} + ) + + mutation = """ + mutation ($userId: ID!) { + updateUser(id: $userId) { id name } + } + """ + + assert {:ok, %{data: _}} = + run_subscription(mutation, Schema, + variables: %{"userId" => id}, + context: %{pubsub: PubSub} + ) + + assert_receive({:broadcast, msg}) + + assert %{ + event: "subscription:data", + result: %{data: %{"user" => %{"id" => "1", "name" => "foo"}}}, + topic: topic + } == msg + end + + @query """ + subscription ($clientId: ID!) { + thing(clientId: $clientId) + } + """ + test "can return an error tuple from the topic function" do + assert {:ok, %{errors: [%{locations: [%{column: 3, line: 2}], message: "unauthorized"}]}} == + run_subscription( + @query, + Schema, + variables: %{"clientId" => "abc"}, + context: %{pubsub: PubSub, authorized: false} + ) + end + + @query """ + subscription Example { + reliesOnDocument + } + """ + test "topic function receives a document" do + assert {:ok, %{"subscribed" => _topic}} = + run_subscription(@query, Schema, context: %{pubsub: PubSub}) + end + + @query """ + subscription ($clientId: ID!) { + thing(clientId: $clientId) + } + """ + test "stringifies topics" do + assert {:ok, %{"subscribed" => topic}} = + run_subscription(@query, Schema, + variables: %{"clientId" => "1"}, + context: %{pubsub: PubSub} + ) + + Absinthe.Subscription.publish(PubSub, "foo", thing: 1) + + assert_receive({:broadcast, msg}) + + assert %{ + event: "subscription:data", + result: %{data: %{"thing" => "foo"}}, + topic: topic + } == msg + end + + test "isn't tripped up if one of the subscription docs raises" do + assert {:ok, %{"subscribed" => _}} = run_subscription("subscription { raises }", Schema) + + assert {:ok, %{"subscribed" => topic}} = + run_subscription("subscription { thing(clientId: \"*\")}", Schema) + + error_log = + capture_log(fn -> + Absinthe.Subscription.publish(PubSub, "foo", raises: "*", thing: "*") + + assert_receive({:broadcast, msg}) + + assert %{ + event: "subscription:data", + result: %{data: %{"thing" => "foo"}}, + topic: topic + } == msg + end) + + assert String.contains?(error_log, "boom") + end + + @tag :pending + test "different subscription docs are batched together" do + opts = [context: %{test_pid: self()}] + + assert {:ok, %{"subscribed" => doc1}} = + run_subscription("subscription { user { group { name } id} }", Schema, opts) + + # different docs required for test, otherwise they get deduplicated from the start + assert {:ok, %{"subscribed" => doc2}} = + run_subscription("subscription { user { group { name } id name} }", Schema, opts) + + user = %{id: "1", name: "Alicia", group: %{name: "Elixir Users"}} + + Absinthe.Subscription.publish(PubSub, user, user: ["*", user.id]) + + assert_receive({:broadcast, %{topic: ^doc1, result: %{data: _}}}) + assert_receive({:broadcast, %{topic: ^doc2, result: %{data: %{"user" => user}}}}) + + assert user["group"]["name"] == "Elixir Users" + + # we should get this just once due to batching + assert_receive(:batch_get_group) + refute_receive(:batch_get_group) + end + + test "subscription docs with different contexts don't leak context" do + ctx1 = %{test_pid: self(), user: 1} + + assert {:ok, %{"subscribed" => doc1}} = + run_subscription("subscription { user { group { name } id} }", Schema, context: ctx1) + + ctx2 = %{test_pid: self(), user: 2} + # different docs required for test, otherwise they get deduplicated from the start + assert {:ok, %{"subscribed" => doc2}} = + run_subscription("subscription { user { group { name } id name} }", Schema, + context: ctx2 + ) + + user = %{id: "1", name: "Alicia", group: %{name: "Elixir Users"}} + + Absinthe.Subscription.publish(PubSub, user, user: ["*", user.id]) + + assert_receive({:broadcast, %{topic: ^doc1, result: %{data: _}}}) + assert_receive({:broadcast, %{topic: ^doc2, result: %{data: %{"user" => user}}}}) + + assert user["group"]["name"] == "Elixir Users" + + # we should get this twice since the different contexts prevent batching. + assert_receive(:batch_get_group) + assert_receive(:batch_get_group) + end + + describe "subscription_ids" do + @query """ + subscription { + otherUser { id } + } + """ + test "subscriptions with the same context_id and same source document have the same subscription_id" do + assert {:ok, %{"subscribed" => doc1}} = + run_subscription(@query, Schema, context: %{context_id: "logged-in"}) + + assert {:ok, %{"subscribed" => doc2}} = + run_subscription(@query, Schema, context: %{context_id: "logged-in"}) + + assert doc1 == doc2 + end + + @query """ + subscription { + otherUser { id } + } + """ + test "subscriptions with different context_id but the same source document have different subscription_ids" do + assert {:ok, %{"subscribed" => doc1}} = + run_subscription(@query, Schema, context: %{context_id: "logged-in"}) + + assert {:ok, %{"subscribed" => doc2}} = + run_subscription(@query, Schema, context: %{context_id: "not-logged-in"}) + + assert doc1 != doc2 + end + + test "subscriptions with same context_id but different source document have different subscription_ids" do + assert {:ok, %{"subscribed" => doc1}} = + run_subscription("subscription { otherUser { id name } }", Schema, + context: %{context_id: "logged-in"} + ) + + assert {:ok, %{"subscribed" => doc2}} = + run_subscription("subscription { otherUser { id } }", Schema, + context: %{context_id: "logged-in"} + ) + + assert doc1 != doc2 + end + + test "subscriptions with different context_id and different source document have different subscription_ids" do + assert {:ok, %{"subscribed" => doc1}} = + run_subscription("subscription { otherUser { id name } }", Schema, + context: %{context_id: "logged-in"} + ) + + assert {:ok, %{"subscribed" => doc2}} = + run_subscription("subscription { otherUser { id } }", Schema, + context: %{context_id: "not-logged-in"} + ) + + assert doc1 != doc2 + end + + @query """ + subscription($id: ID!) { otherUser(id: $id) { id } } + """ + test "subscriptions with the same variables & document have the same subscription_ids" do + assert {:ok, %{"subscribed" => doc1}} = + run_subscription(@query, Schema, + variables: %{"id" => "123"}, + context: %{context_id: "logged-in"} + ) + + assert {:ok, %{"subscribed" => doc2}} = + run_subscription(@query, Schema, + variables: %{"id" => "123"}, + context: %{context_id: "logged-in"} + ) + + assert doc1 == doc2 + end + + @query """ + subscription($id: ID!) { otherUser(id: $id) { id } } + """ + test "subscriptions with different variables but same document have different subscription_ids" do + assert {:ok, %{"subscribed" => doc1}} = + run_subscription(@query, Schema, + variables: %{"id" => "123"}, + context: %{context_id: "logged-in"} + ) + + assert {:ok, %{"subscribed" => doc2}} = + run_subscription(@query, Schema, + variables: %{"id" => "456"}, + context: %{context_id: "logged-in"} + ) + + assert doc1 != doc2 + end + + test "document_id can be provided to override the default logic for deriving document_id" do + assert {:ok, %{"subscribed" => doc1}} = + run_subscription("subscription { otherUser { id name } }", Schema, + context: %{context_id: "logged-in", document_id: "abcdef"} + ) + + assert {:ok, %{"subscribed" => doc2}} = + run_subscription("subscription { otherUser { name id } }", Schema, + context: %{context_id: "logged-in", document_id: "abcdef"} + ) + + assert doc1 == doc2 + end + end + + @query """ + subscription ($clientId: ID!) { + thing(clientId: $clientId) + } + """ + test "subscription executes telemetry events", context do + client_id = "abc" + + :telemetry.attach_many( + context.test, + [ + [:absinthe, :execute, :operation, :start], + [:absinthe, :execute, :operation, :stop], + [:absinthe, :subscription, :publish, :start], + [:absinthe, :subscription, :publish, :stop] + ], + fn event, measurements, metadata, config -> + send(self(), {event, measurements, metadata, config}) + end, + %{} + ) + + assert {:ok, %{"subscribed" => topic}} = + run_subscription( + @query, + Schema, + variables: %{"clientId" => client_id}, + context: %{pubsub: PubSub} + ) + + assert_receive {[:absinthe, :execute, :operation, :start], measurements, %{id: id}, _config} + assert System.convert_time_unit(measurements[:system_time], :native, :millisecond) + + assert_receive {[:absinthe, :execute, :operation, :stop], _, %{id: ^id}, _config} + + Absinthe.Subscription.publish(PubSub, "foo", thing: client_id) + assert_receive({:broadcast, msg}) + + assert %{ + event: "subscription:data", + result: %{data: %{"thing" => "foo"}}, + topic: topic + } == msg + + # Subscription events + assert_receive {[:absinthe, :subscription, :publish, :start], _, %{id: id}, _config} + assert_receive {[:absinthe, :subscription, :publish, :stop], _, %{id: ^id}, _config} + + :telemetry.detach(context.test) + end + + @query """ + subscription { + otherUser { id } + } + """ + test "de-duplicates pushes to the same context" do + documents = + Enum.map(1..5, fn _index -> + {:ok, doc} = run_subscription(@query, Schema, context: %{context_id: "global"}) + doc + end) + + # assert that all documents are the same + assert [document] = Enum.dedup(documents) + + Absinthe.Subscription.publish( + PubSub, + %{id: "global_user_id"}, + other_user: "*" + ) + + topic_id = document["subscribed"] + + for _i <- 1..5 do + assert_receive( + {:broadcast, + %{ + event: "subscription:data", + result: %{data: %{"otherUser" => %{"id" => "global_user_id"}}}, + topic: ^topic_id + }} + ) + end + + refute_receive({:broadcast, _}) + end + + defp run_subscription(query, schema, opts \\ []) do + opts = Keyword.update(opts, :context, %{pubsub: PubSub}, &Map.put(&1, :pubsub, PubSub)) + + case run(query, schema, opts) do + {:ok, %{"subscribed" => topic}} = val -> + PubSub.subscribe(topic) + val + + val -> + val + end + end +end From e6f418f9555b388e213959a0e851d260a12c7018 Mon Sep 17 00:00:00 2001 From: Barnabas Jovanovics Date: Sun, 15 Oct 2023 13:09:30 +0200 Subject: [PATCH 02/48] wip --- config/config.exs | 3 + test/subscription_test.exs | 703 +-------------------------------- test/support/pub_sub.ex | 47 +++ test/support/resources/post.ex | 11 +- test/support/schema.ex | 27 ++ 5 files changed, 107 insertions(+), 684 deletions(-) create mode 100644 test/support/pub_sub.ex diff --git a/config/config.exs b/config/config.exs index 5635fe54..79c376b9 100644 --- a/config/config.exs +++ b/config/config.exs @@ -6,6 +6,9 @@ config :ash, :validate_domain_config_inclusion?, false config :logger, level: :warning +config :ash, :pub_sub, debug?: true +config :logger, level: :debug + if Mix.env() == :dev do config :git_ops, mix_project: AshGraphql.MixProject, diff --git a/test/subscription_test.exs b/test/subscription_test.exs index 6f64b7a7..f4f4b566 100644 --- a/test/subscription_test.exs +++ b/test/subscription_test.exs @@ -1,371 +1,22 @@ defmodule AshGraphql.SubscriptionTest do use ExUnit.Case - import ExUnit.CaptureLog - - def run(document, schema, options \\ []) do - Absinthe.run(document, schema, options) - end - - defmodule ResultPhase do - @moduledoc false - - alias Absinthe.Blueprint - use Absinthe.Phase - - def run(%Blueprint{} = bp, _options \\ []) do - result = Map.merge(bp.result, process(bp)) - {:ok, %{bp | result: result}} - end - - defp process(blueprint) do - data = data(blueprint.execution.result) - %{data: data} - end - - defp data(%{value: value}), do: value - - defp data(%{fields: []} = result) do - result.root_value - end - - defp data(%{fields: fields, emitter: emitter, root_value: root_value}) do - with %{put: _} <- emitter.flags, - true <- is_map(root_value) do - data = field_data(fields) - Map.merge(root_value, data) - else - _ -> - field_data(fields) - end - end - - defp field_data(fields, acc \\ []) - defp field_data([], acc), do: Map.new(acc) - - defp field_data([field | fields], acc) do - value = data(field) - field_data(fields, [{String.to_existing_atom(field.emitter.name), value} | acc]) - end - end - - defmodule PubSub do - @behaviour Absinthe.Subscription.Pubsub - - def start_link() do - Registry.start_link(keys: :duplicate, name: __MODULE__) - end - - def node_name() do - node() - end - - def subscribe(topic) do - Registry.register(__MODULE__, topic, []) - :ok - end - - def publish_subscription(topic, data) do - message = %{ - topic: topic, - event: "subscription:data", - result: data - } - - Registry.dispatch(__MODULE__, topic, fn entries -> - for {pid, _} <- entries, do: send(pid, {:broadcast, message}) - end) - end - - def publish_mutation(_proxy_topic, _mutation_result, _subscribed_fields) do - # this pubsub is local and doesn't support clusters - :ok - end - end - - defmodule Schema do - use Absinthe.Schema - - query do - field(:foo, :string) - end - - object :user do - field(:id, :id) - field(:name, :string) - - field :group, :group do - resolve(fn user, _, %{context: %{test_pid: pid}} -> - batch({__MODULE__, :batch_get_group, pid}, nil, fn _results -> - {:ok, user.group} - end) - end) - end - end - - object :group do - field(:name, :string) - end - - def batch_get_group(test_pid, _) do - # send a message to the test process every time we access this function. - # if batching is working properly, it should only happen once. - send(test_pid, :batch_get_group) - %{} - end - - subscription do - field :raises, :string do - config(fn _, _ -> - {:ok, topic: "*"} - end) - - resolve(fn _, _, _ -> - raise "boom" - end) - end - - field :user, :user do - arg(:id, :id) - - config(fn args, _ -> - {:ok, topic: args[:id] || "*"} - end) - - trigger(:update_user, - topic: fn user -> - [user.id, "*"] - end - ) - end - - field :thing, :string do - arg(:client_id, non_null(:id)) - - config(fn - _args, %{context: %{authorized: false}} -> - {:error, "unauthorized"} - - args, _ -> - { - :ok, - topic: args.client_id - } - end) - end - - field :multiple_topics, :string do - config(fn _, _ -> - {:ok, topic: ["topic_1", "topic_2", "topic_3"]} - end) - end - - field :other_user, :user do - arg(:id, :id) - - config(fn - args, %{context: %{context_id: context_id, document_id: document_id}} -> - {:ok, topic: args[:id] || "*", context_id: context_id, document_id: document_id} - - args, %{context: %{context_id: context_id}} -> - {:ok, topic: args[:id] || "*", context_id: context_id} - end) - end - - field :relies_on_document, :string do - config(fn _, %{document: %Absinthe.Blueprint{} = document} -> - %{type: :subscription, name: op_name} = Absinthe.Blueprint.current_operation(document) - {:ok, topic: "*", context_id: "*", document_id: op_name} - end) - end - end - - mutation do - field :update_user, :user do - arg(:id, non_null(:id)) - - resolve(fn _, %{id: id}, _ -> - {:ok, %{id: id, name: "foo"}} - end) - end - end - end + alias AshGraphql.Test.PubSub + alias AshGraphql.Test.Schema setup_all do + Application.put_env(PubSub, :notifier_test_pid, self()) {:ok, _} = PubSub.start_link() {:ok, _} = Absinthe.Subscription.start_link(PubSub) :ok end - @query """ - subscription ($clientId: ID!) { - thing(clientId: $clientId) - } - """ - test "should use result_phase from main pipeline" do - client_id = "abc" - - assert {:ok, %{"subscribed" => topic}} = - run_subscription( - @query, - Schema, - variables: %{"clientId" => client_id}, - context: %{pubsub: PubSub}, - result_phase: ResultPhase - ) - - Absinthe.Subscription.publish(PubSub, %{foo: "bar"}, thing: client_id) - - assert_receive({:broadcast, msg}) - - assert %{ - event: "subscription:data", - result: %{data: %{thing: %{foo: "bar"}}}, - topic: topic - } == msg - end - - @query """ - subscription ($clientId: ID!) { - thing(clientId: $clientId) - } - """ - test "can subscribe the current process" do - client_id = "abc" - - assert {:ok, %{"subscribed" => topic}} = - run_subscription( - @query, - Schema, - variables: %{"clientId" => client_id}, - context: %{pubsub: PubSub} - ) - - Absinthe.Subscription.publish(PubSub, "foo", thing: client_id) - - assert_receive({:broadcast, msg}) - - assert %{ - event: "subscription:data", - result: %{data: %{"thing" => "foo"}}, - topic: topic - } == msg - end - - @query """ - subscription ($clientId: ID!) { - thing(clientId: $clientId) - } - """ - test "can unsubscribe the current process" do - client_id = "abc" - - assert {:ok, %{"subscribed" => topic}} = - run_subscription( - @query, - Schema, - variables: %{"clientId" => client_id}, - context: %{pubsub: PubSub} - ) - - Absinthe.Subscription.unsubscribe(PubSub, topic) - - Absinthe.Subscription.publish(PubSub, "foo", thing: client_id) - - refute_receive({:broadcast, _}) - end - @query """ subscription { - multipleTopics - } - """ - test "schema can provide multiple topics to subscribe to" do - assert {:ok, %{"subscribed" => topic}} = - run_subscription( - @query, - Schema, - variables: %{}, - context: %{pubsub: PubSub} - ) - - msg = %{ - event: "subscription:data", - result: %{data: %{"multipleTopics" => "foo"}}, - topic: topic - } - - Absinthe.Subscription.publish(PubSub, "foo", multiple_topics: "topic_1") - - assert_receive({:broadcast, ^msg}) - - Absinthe.Subscription.publish(PubSub, "foo", multiple_topics: "topic_2") - - assert_receive({:broadcast, ^msg}) - - Absinthe.Subscription.publish(PubSub, "foo", multiple_topics: "topic_3") - - assert_receive({:broadcast, ^msg}) - end - - @query """ - subscription { - multipleTopics - } - """ - test "unsubscription works when multiple topics are provided" do - assert {:ok, %{"subscribed" => topic}} = - run_subscription( - @query, - Schema, - variables: %{}, - context: %{pubsub: PubSub} - ) - - Absinthe.Subscription.unsubscribe(PubSub, topic) - - Absinthe.Subscription.publish(PubSub, "foo", multiple_topics: "topic_1") - - refute_receive({:broadcast, _}) - - Absinthe.Subscription.publish(PubSub, "foo", multiple_topics: "topic_2") - - refute_receive({:broadcast, _}) - - Absinthe.Subscription.publish(PubSub, "foo", multiple_topics: "topic_3") - - refute_receive({:broadcast, _}) - end - - @query """ - subscription ($clientId: ID!) { - thing(clientId: $clientId, extra: 1) - } - """ - test "can return errors properly" do - assert { - :ok, - %{ - errors: [ - %{ - locations: [%{column: 30, line: 2}], - message: - "Unknown argument \"extra\" on field \"thing\" of type \"RootSubscriptionType\"." - } - ] - } - } == - run_subscription(@query, Schema, - variables: %{"clientId" => "abc"}, - context: %{pubsub: PubSub} - ) - end - - @query """ - subscription ($userId: ID!) { - user(id: $userId) { id name } + post_created { id } } """ + @tag :wip test "subscription triggers work" do id = "1" @@ -374,18 +25,26 @@ defmodule AshGraphql.SubscriptionTest do @query, Schema, variables: %{"userId" => id}, - context: %{pubsub: PubSub} + context: %{pubsub: PubSub, actor: %{id: id}} ) mutation = """ - mutation ($userId: ID!) { - updateUser(id: $userId) { id name } - } + mutation SimpleCreatePost($input: SimpleCreatePostInput) { + simpleCreatePost(input: $input) { + result{ + text1 + integerAsStringInApi + } + errors{ + message + } + } + } """ assert {:ok, %{data: _}} = run_subscription(mutation, Schema, - variables: %{"userId" => id}, + variables: %{"input" => %{"text1" => "foo", "integerAsStringInApi" => "1"}}, context: %{pubsub: PubSub} ) @@ -398,332 +57,10 @@ defmodule AshGraphql.SubscriptionTest do } == msg end - @query """ - subscription ($clientId: ID!) { - thing(clientId: $clientId) - } - """ - test "can return an error tuple from the topic function" do - assert {:ok, %{errors: [%{locations: [%{column: 3, line: 2}], message: "unauthorized"}]}} == - run_subscription( - @query, - Schema, - variables: %{"clientId" => "abc"}, - context: %{pubsub: PubSub, authorized: false} - ) - end - - @query """ - subscription Example { - reliesOnDocument - } - """ - test "topic function receives a document" do - assert {:ok, %{"subscribed" => _topic}} = - run_subscription(@query, Schema, context: %{pubsub: PubSub}) - end - - @query """ - subscription ($clientId: ID!) { - thing(clientId: $clientId) - } - """ - test "stringifies topics" do - assert {:ok, %{"subscribed" => topic}} = - run_subscription(@query, Schema, - variables: %{"clientId" => "1"}, - context: %{pubsub: PubSub} - ) - - Absinthe.Subscription.publish(PubSub, "foo", thing: 1) - - assert_receive({:broadcast, msg}) - - assert %{ - event: "subscription:data", - result: %{data: %{"thing" => "foo"}}, - topic: topic - } == msg - end - - test "isn't tripped up if one of the subscription docs raises" do - assert {:ok, %{"subscribed" => _}} = run_subscription("subscription { raises }", Schema) - - assert {:ok, %{"subscribed" => topic}} = - run_subscription("subscription { thing(clientId: \"*\")}", Schema) - - error_log = - capture_log(fn -> - Absinthe.Subscription.publish(PubSub, "foo", raises: "*", thing: "*") - - assert_receive({:broadcast, msg}) - - assert %{ - event: "subscription:data", - result: %{data: %{"thing" => "foo"}}, - topic: topic - } == msg - end) - - assert String.contains?(error_log, "boom") - end - - @tag :pending - test "different subscription docs are batched together" do - opts = [context: %{test_pid: self()}] - - assert {:ok, %{"subscribed" => doc1}} = - run_subscription("subscription { user { group { name } id} }", Schema, opts) - - # different docs required for test, otherwise they get deduplicated from the start - assert {:ok, %{"subscribed" => doc2}} = - run_subscription("subscription { user { group { name } id name} }", Schema, opts) - - user = %{id: "1", name: "Alicia", group: %{name: "Elixir Users"}} - - Absinthe.Subscription.publish(PubSub, user, user: ["*", user.id]) - - assert_receive({:broadcast, %{topic: ^doc1, result: %{data: _}}}) - assert_receive({:broadcast, %{topic: ^doc2, result: %{data: %{"user" => user}}}}) - - assert user["group"]["name"] == "Elixir Users" - - # we should get this just once due to batching - assert_receive(:batch_get_group) - refute_receive(:batch_get_group) - end - - test "subscription docs with different contexts don't leak context" do - ctx1 = %{test_pid: self(), user: 1} - - assert {:ok, %{"subscribed" => doc1}} = - run_subscription("subscription { user { group { name } id} }", Schema, context: ctx1) - - ctx2 = %{test_pid: self(), user: 2} - # different docs required for test, otherwise they get deduplicated from the start - assert {:ok, %{"subscribed" => doc2}} = - run_subscription("subscription { user { group { name } id name} }", Schema, - context: ctx2 - ) - - user = %{id: "1", name: "Alicia", group: %{name: "Elixir Users"}} - - Absinthe.Subscription.publish(PubSub, user, user: ["*", user.id]) - - assert_receive({:broadcast, %{topic: ^doc1, result: %{data: _}}}) - assert_receive({:broadcast, %{topic: ^doc2, result: %{data: %{"user" => user}}}}) - - assert user["group"]["name"] == "Elixir Users" - - # we should get this twice since the different contexts prevent batching. - assert_receive(:batch_get_group) - assert_receive(:batch_get_group) - end - - describe "subscription_ids" do - @query """ - subscription { - otherUser { id } - } - """ - test "subscriptions with the same context_id and same source document have the same subscription_id" do - assert {:ok, %{"subscribed" => doc1}} = - run_subscription(@query, Schema, context: %{context_id: "logged-in"}) - - assert {:ok, %{"subscribed" => doc2}} = - run_subscription(@query, Schema, context: %{context_id: "logged-in"}) - - assert doc1 == doc2 - end - - @query """ - subscription { - otherUser { id } - } - """ - test "subscriptions with different context_id but the same source document have different subscription_ids" do - assert {:ok, %{"subscribed" => doc1}} = - run_subscription(@query, Schema, context: %{context_id: "logged-in"}) - - assert {:ok, %{"subscribed" => doc2}} = - run_subscription(@query, Schema, context: %{context_id: "not-logged-in"}) - - assert doc1 != doc2 - end - - test "subscriptions with same context_id but different source document have different subscription_ids" do - assert {:ok, %{"subscribed" => doc1}} = - run_subscription("subscription { otherUser { id name } }", Schema, - context: %{context_id: "logged-in"} - ) - - assert {:ok, %{"subscribed" => doc2}} = - run_subscription("subscription { otherUser { id } }", Schema, - context: %{context_id: "logged-in"} - ) - - assert doc1 != doc2 - end - - test "subscriptions with different context_id and different source document have different subscription_ids" do - assert {:ok, %{"subscribed" => doc1}} = - run_subscription("subscription { otherUser { id name } }", Schema, - context: %{context_id: "logged-in"} - ) - - assert {:ok, %{"subscribed" => doc2}} = - run_subscription("subscription { otherUser { id } }", Schema, - context: %{context_id: "not-logged-in"} - ) - - assert doc1 != doc2 - end - - @query """ - subscription($id: ID!) { otherUser(id: $id) { id } } - """ - test "subscriptions with the same variables & document have the same subscription_ids" do - assert {:ok, %{"subscribed" => doc1}} = - run_subscription(@query, Schema, - variables: %{"id" => "123"}, - context: %{context_id: "logged-in"} - ) - - assert {:ok, %{"subscribed" => doc2}} = - run_subscription(@query, Schema, - variables: %{"id" => "123"}, - context: %{context_id: "logged-in"} - ) - - assert doc1 == doc2 - end - - @query """ - subscription($id: ID!) { otherUser(id: $id) { id } } - """ - test "subscriptions with different variables but same document have different subscription_ids" do - assert {:ok, %{"subscribed" => doc1}} = - run_subscription(@query, Schema, - variables: %{"id" => "123"}, - context: %{context_id: "logged-in"} - ) - - assert {:ok, %{"subscribed" => doc2}} = - run_subscription(@query, Schema, - variables: %{"id" => "456"}, - context: %{context_id: "logged-in"} - ) - - assert doc1 != doc2 - end - - test "document_id can be provided to override the default logic for deriving document_id" do - assert {:ok, %{"subscribed" => doc1}} = - run_subscription("subscription { otherUser { id name } }", Schema, - context: %{context_id: "logged-in", document_id: "abcdef"} - ) - - assert {:ok, %{"subscribed" => doc2}} = - run_subscription("subscription { otherUser { name id } }", Schema, - context: %{context_id: "logged-in", document_id: "abcdef"} - ) - - assert doc1 == doc2 - end - end - - @query """ - subscription ($clientId: ID!) { - thing(clientId: $clientId) - } - """ - test "subscription executes telemetry events", context do - client_id = "abc" - - :telemetry.attach_many( - context.test, - [ - [:absinthe, :execute, :operation, :start], - [:absinthe, :execute, :operation, :stop], - [:absinthe, :subscription, :publish, :start], - [:absinthe, :subscription, :publish, :stop] - ], - fn event, measurements, metadata, config -> - send(self(), {event, measurements, metadata, config}) - end, - %{} - ) - - assert {:ok, %{"subscribed" => topic}} = - run_subscription( - @query, - Schema, - variables: %{"clientId" => client_id}, - context: %{pubsub: PubSub} - ) - - assert_receive {[:absinthe, :execute, :operation, :start], measurements, %{id: id}, _config} - assert System.convert_time_unit(measurements[:system_time], :native, :millisecond) - - assert_receive {[:absinthe, :execute, :operation, :stop], _, %{id: ^id}, _config} - - Absinthe.Subscription.publish(PubSub, "foo", thing: client_id) - assert_receive({:broadcast, msg}) - - assert %{ - event: "subscription:data", - result: %{data: %{"thing" => "foo"}}, - topic: topic - } == msg - - # Subscription events - assert_receive {[:absinthe, :subscription, :publish, :start], _, %{id: id}, _config} - assert_receive {[:absinthe, :subscription, :publish, :stop], _, %{id: ^id}, _config} - - :telemetry.detach(context.test) - end - - @query """ - subscription { - otherUser { id } - } - """ - test "de-duplicates pushes to the same context" do - documents = - Enum.map(1..5, fn _index -> - {:ok, doc} = run_subscription(@query, Schema, context: %{context_id: "global"}) - doc - end) - - # assert that all documents are the same - assert [document] = Enum.dedup(documents) - - Absinthe.Subscription.publish( - PubSub, - %{id: "global_user_id"}, - other_user: "*" - ) - - topic_id = document["subscribed"] - - for _i <- 1..5 do - assert_receive( - {:broadcast, - %{ - event: "subscription:data", - result: %{data: %{"otherUser" => %{"id" => "global_user_id"}}}, - topic: ^topic_id - }} - ) - end - - refute_receive({:broadcast, _}) - end - - defp run_subscription(query, schema, opts \\ []) do + defp run_subscription(query, schema, opts) do opts = Keyword.update(opts, :context, %{pubsub: PubSub}, &Map.put(&1, :pubsub, PubSub)) - case run(query, schema, opts) do + case Absinthe.run(query, schema, opts) do {:ok, %{"subscribed" => topic}} = val -> PubSub.subscribe(topic) val diff --git a/test/support/pub_sub.ex b/test/support/pub_sub.ex new file mode 100644 index 00000000..aad95a4d --- /dev/null +++ b/test/support/pub_sub.ex @@ -0,0 +1,47 @@ +defmodule AshGraphql.Test.PubSub do + @behaviour Absinthe.Subscription.Pubsub + + def start_link() do + Registry.start_link(keys: :duplicate, name: __MODULE__) + end + + def node_name() do + node() + end + + def subscribe(topic) do + Registry.register(__MODULE__, topic, []) + :ok + end + + def publish_subscription(topic, data) do + message = %{ + topic: topic, + event: "subscription:data", + result: data + } + + Registry.dispatch(__MODULE__, topic, fn entries -> + for {pid, _} <- entries, do: send(pid, {:broadcast, message}) + end) + end + + def broadcast(topic, event, notification) do + message = + %{ + topic: topic, + event: event, + result: notification + } + |> IO.inspect(label: :message) + + Registry.dispatch(__MODULE__, topic, fn entries -> + for {pid, _} <- entries, do: send(pid, {:broadcast, message}) + end) + end + + def publish_mutation(_proxy_topic, _mutation_result, _subscribed_fields) do + # this pubsub is local and doesn't support clusters + :ok + end +end diff --git a/test/support/resources/post.ex b/test/support/resources/post.ex index edccef54..47667313 100644 --- a/test/support/resources/post.ex +++ b/test/support/resources/post.ex @@ -131,12 +131,13 @@ defmodule AshGraphql.Test.Post do alias AshGraphql.Test.CommonMap alias AshGraphql.Test.CommonMapStruct alias AshGraphql.Test.SponsoredComment + alias AshGraphql.Test.PubSub use Ash.Resource, domain: AshGraphql.Test.Domain, data_layer: Ash.DataLayer.Ets, authorizers: [Ash.Policy.Authorizer], - extensions: [AshGraphql.Resource] + extensions: [AshGraphql.Resource, Ash.Notifier.PubSub] require Ash.Query @@ -232,6 +233,14 @@ defmodule AshGraphql.Test.Post do end end + pub_sub do + module(PubSub) + prefix("post") + broadcast_type(:notification) + + publish_all(:create, "created") + end + actions do default_accept(:*) diff --git a/test/support/schema.ex b/test/support/schema.ex index e4fc6f18..f247505a 100644 --- a/test/support/schema.ex +++ b/test/support/schema.ex @@ -7,6 +7,10 @@ defmodule AshGraphql.Test.Schema do use AshGraphql, domains: @domains, generate_sdl_file: "priv/schema.graphql" + alias AshGraphql.Test.Post + + require Ash.Query + query do end @@ -27,4 +31,27 @@ defmodule AshGraphql.Test.Schema do value(:open, description: "The post is open") value(:closed, description: "The post is closed") end + + subscription do + field :post_created, :post do + config(fn + _args, %{context: %{actor: %{id: user_id}}} -> + {:ok, topic: user_id, context_id: "user/#{user_id}"} + + _args, _context -> + {:error, :unauthorized} + end) + + resolve(fn args, _, resolution -> + # loads all the data you need + AshGraphql.Subscription.query_for_subscription( + Post, + Api, + resolution + ) + |> Ash.Query.filter(id == ^args.id) + |> Ash.read(actor: resolution.context.current_user) + end) + end + end end From f7227b4c57a2eaa65fec46c219d5efeb603f8c6b Mon Sep 17 00:00:00 2001 From: Barnabas Jovanovics Date: Fri, 27 Oct 2023 14:50:51 +0200 Subject: [PATCH 03/48] wip --- test/subscription_test.exs | 20 +++++++----- test/support/pub_sub.ex | 19 +++++++----- test/support/registry.ex | 29 +++++++++++++++++ test/support/resources/post.ex | 11 +------ test/support/resources/subscribable.ex | 43 ++++++++++++++++++++++++++ test/support/schema.ex | 4 +-- 6 files changed, 99 insertions(+), 27 deletions(-) create mode 100644 test/support/registry.ex create mode 100644 test/support/resources/subscribable.ex diff --git a/test/subscription_test.exs b/test/subscription_test.exs index f4f4b566..add660ca 100644 --- a/test/subscription_test.exs +++ b/test/subscription_test.exs @@ -13,7 +13,7 @@ defmodule AshGraphql.SubscriptionTest do @query """ subscription { - post_created { id } + subscribableCreated { id } } """ @tag :wip @@ -28,12 +28,13 @@ defmodule AshGraphql.SubscriptionTest do context: %{pubsub: PubSub, actor: %{id: id}} ) + PubSub.subscribe("subscribable:created") + mutation = """ - mutation SimpleCreatePost($input: SimpleCreatePostInput) { - simpleCreatePost(input: $input) { + mutation CreateSubscribable($input: CreateSubscribableInput) { + createSubscribable(input: $input) { result{ - text1 - integerAsStringInApi + text } errors{ message @@ -42,14 +43,17 @@ defmodule AshGraphql.SubscriptionTest do } """ - assert {:ok, %{data: _}} = + assert {:ok, %{data: data}} = run_subscription(mutation, Schema, - variables: %{"input" => %{"text1" => "foo", "integerAsStringInApi" => "1"}}, + variables: %{"input" => %{"text" => "foo"}}, context: %{pubsub: PubSub} ) assert_receive({:broadcast, msg}) + Absinthe.Subscription.publish(PubSub, data, subscribable_created: nil) + |> IO.inspect(label: :publish) + assert %{ event: "subscription:data", result: %{data: %{"user" => %{"id" => "1", "name" => "foo"}}}, @@ -60,7 +64,7 @@ defmodule AshGraphql.SubscriptionTest do defp run_subscription(query, schema, opts) do opts = Keyword.update(opts, :context, %{pubsub: PubSub}, &Map.put(&1, :pubsub, PubSub)) - case Absinthe.run(query, schema, opts) do + case Absinthe.run(query, schema, opts) |> IO.inspect(label: :absinthe_run) do {:ok, %{"subscribed" => topic}} = val -> PubSub.subscribe(topic) val diff --git a/test/support/pub_sub.ex b/test/support/pub_sub.ex index aad95a4d..f33a6572 100644 --- a/test/support/pub_sub.ex +++ b/test/support/pub_sub.ex @@ -10,16 +10,19 @@ defmodule AshGraphql.Test.PubSub do end def subscribe(topic) do - Registry.register(__MODULE__, topic, []) + IO.inspect([topic: topic], label: "subscribe") + Registry.register(__MODULE__, topic, [self()]) :ok end def publish_subscription(topic, data) do - message = %{ - topic: topic, - event: "subscription:data", - result: data - } + message = + %{ + topic: topic, + event: "subscription:data", + result: data + } + |> IO.inspect(label: :publish_subscription) Registry.dispatch(__MODULE__, topic, fn entries -> for {pid, _} <- entries, do: send(pid, {:broadcast, message}) @@ -27,13 +30,14 @@ defmodule AshGraphql.Test.PubSub do end def broadcast(topic, event, notification) do + IO.inspect([topic: topic, event: event, notification: notification], label: "broadcast") + message = %{ topic: topic, event: event, result: notification } - |> IO.inspect(label: :message) Registry.dispatch(__MODULE__, topic, fn entries -> for {pid, _} <- entries, do: send(pid, {:broadcast, message}) @@ -42,6 +46,7 @@ defmodule AshGraphql.Test.PubSub do def publish_mutation(_proxy_topic, _mutation_result, _subscribed_fields) do # this pubsub is local and doesn't support clusters + IO.inspect("publish mutation") :ok end end diff --git a/test/support/registry.ex b/test/support/registry.ex new file mode 100644 index 00000000..530d1fcf --- /dev/null +++ b/test/support/registry.ex @@ -0,0 +1,29 @@ +defmodule AshGraphql.Test.Registry do + @moduledoc false + use Ash.Registry + + entries do + entry(AshGraphql.Test.Comment) + entry(AshGraphql.Test.CompositePrimaryKey) + entry(AshGraphql.Test.CompositePrimaryKeyNotEncoded) + entry(AshGraphql.Test.DoubleRelRecursive) + entry(AshGraphql.Test.DoubleRelToRecursiveParentOfEmbed) + entry(AshGraphql.Test.MapTypes) + entry(AshGraphql.Test.MultitenantPostTag) + entry(AshGraphql.Test.MultitenantTag) + entry(AshGraphql.Test.NoObject) + entry(AshGraphql.Test.NonIdPrimaryKey) + entry(AshGraphql.Test.Post) + entry(AshGraphql.Test.PostTag) + entry(AshGraphql.Test.RelayPostTag) + entry(AshGraphql.Test.RelayTag) + entry(AshGraphql.Test.SponsoredComment) + entry(AshGraphql.Test.Subscribable) + entry(AshGraphql.Test.Tag) + entry(AshGraphql.Test.User) + entry(AshGraphql.Test.Channel) + entry(AshGraphql.Test.Message) + entry(AshGraphql.Test.TextMessage) + entry(AshGraphql.Test.ImageMessage) + end +end diff --git a/test/support/resources/post.ex b/test/support/resources/post.ex index 47667313..edccef54 100644 --- a/test/support/resources/post.ex +++ b/test/support/resources/post.ex @@ -131,13 +131,12 @@ defmodule AshGraphql.Test.Post do alias AshGraphql.Test.CommonMap alias AshGraphql.Test.CommonMapStruct alias AshGraphql.Test.SponsoredComment - alias AshGraphql.Test.PubSub use Ash.Resource, domain: AshGraphql.Test.Domain, data_layer: Ash.DataLayer.Ets, authorizers: [Ash.Policy.Authorizer], - extensions: [AshGraphql.Resource, Ash.Notifier.PubSub] + extensions: [AshGraphql.Resource] require Ash.Query @@ -233,14 +232,6 @@ defmodule AshGraphql.Test.Post do end end - pub_sub do - module(PubSub) - prefix("post") - broadcast_type(:notification) - - publish_all(:create, "created") - end - actions do default_accept(:*) diff --git a/test/support/resources/subscribable.ex b/test/support/resources/subscribable.ex new file mode 100644 index 00000000..d5cdc407 --- /dev/null +++ b/test/support/resources/subscribable.ex @@ -0,0 +1,43 @@ +defmodule AshGraphql.Test.Subscribable do + @moduledoc false + alias AshGraphql.Test.PubSub + + use Ash.Resource, + data_layer: Ash.DataLayer.Ets, + notifiers: [Ash.Notifier.PubSub], + extensions: [AshGraphql.Resource] + + require Ash.Query + + graphql do + type :subscribable + + queries do + get :get_subscribable, :read + end + + mutations do + create :create_subscribable, :create + end + end + + pub_sub do + module(PubSub) + prefix("subscribable") + broadcast_type(:notification) + + publish_all(:create, "created") + end + + actions do + defaults([:create, :read, :update, :destroy]) + end + + attributes do + uuid_primary_key(:id) + + attribute(:text, :string) + create_timestamp(:created_at) + update_timestamp(:updated_at) + end +end diff --git a/test/support/schema.ex b/test/support/schema.ex index f247505a..359ca3d5 100644 --- a/test/support/schema.ex +++ b/test/support/schema.ex @@ -33,10 +33,10 @@ defmodule AshGraphql.Test.Schema do end subscription do - field :post_created, :post do + field :subscribable_created, :subscribable do config(fn _args, %{context: %{actor: %{id: user_id}}} -> - {:ok, topic: user_id, context_id: "user/#{user_id}"} + {:ok, topic: "subscribable:created", context_id: "user/#{user_id}"} _args, _context -> {:error, :unauthorized} From b9781481ab1e64b840b2905d281a849688a74970 Mon Sep 17 00:00:00 2001 From: Barnabas Jovanovics Date: Fri, 24 Nov 2023 10:19:01 +0100 Subject: [PATCH 04/48] wip --- lib/graphql/resolver.ex | 2 ++ test/subscription_test.exs | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/graphql/resolver.ex b/lib/graphql/resolver.ex index 18e7863c..11f2036a 100644 --- a/lib/graphql/resolver.ex +++ b/lib/graphql/resolver.ex @@ -13,6 +13,8 @@ defmodule AshGraphql.Graphql.Resolver do %{arguments: arguments, context: context} = resolution, {domain, resource, %AshGraphql.Resource.Action{name: query_name, action: action}, input?} ) do + dbg() + arguments = if input? do arguments[:input] || %{} diff --git a/test/subscription_test.exs b/test/subscription_test.exs index add660ca..0189ef26 100644 --- a/test/subscription_test.exs +++ b/test/subscription_test.exs @@ -51,7 +51,7 @@ defmodule AshGraphql.SubscriptionTest do assert_receive({:broadcast, msg}) - Absinthe.Subscription.publish(PubSub, data, subscribable_created: nil) + Absinthe.Subscription.publish(PubSub, data, subscribable_created: "subscribable:created") |> IO.inspect(label: :publish) assert %{ From 05fc1f995b4c983c44267097681a2df75e6f5ce9 Mon Sep 17 00:00:00 2001 From: Barnabas Jovanovics Date: Fri, 8 Dec 2023 11:59:32 +0100 Subject: [PATCH 05/48] a step in the right direction? --- lib/resource/notifier.ex | 12 ++++++++++++ test/subscription_test.exs | 22 +++++++--------------- test/support/pub_sub.ex | 16 ++++++++++++---- test/support/resources/post.ex | 4 ++++ test/support/resources/subscribable.ex | 13 ++++--------- test/support/schema.ex | 7 ++----- 6 files changed, 41 insertions(+), 33 deletions(-) create mode 100644 lib/resource/notifier.ex diff --git a/lib/resource/notifier.ex b/lib/resource/notifier.ex new file mode 100644 index 00000000..96f41644 --- /dev/null +++ b/lib/resource/notifier.ex @@ -0,0 +1,12 @@ +defmodule AshGraphql.Resource.Notifier do + use Ash.Notifier + + @impl Ash.Notifier + def notify(notification) do + IO.inspect(notification, label: :Notifier) + + Absinthe.Subscription.publish(AshGraphql.Test.PubSub, notification.data, + subscrible_created: "*" + ) + end +end diff --git a/test/subscription_test.exs b/test/subscription_test.exs index 0189ef26..4c473a3d 100644 --- a/test/subscription_test.exs +++ b/test/subscription_test.exs @@ -4,8 +4,8 @@ defmodule AshGraphql.SubscriptionTest do alias AshGraphql.Test.PubSub alias AshGraphql.Test.Schema - setup_all do - Application.put_env(PubSub, :notifier_test_pid, self()) + setup do + Application.put_env(PubSub, :notifier_test_pid, self() |> IO.inspect(label: :test_process)) {:ok, _} = PubSub.start_link() {:ok, _} = Absinthe.Subscription.start_link(PubSub) :ok @@ -28,8 +28,6 @@ defmodule AshGraphql.SubscriptionTest do context: %{pubsub: PubSub, actor: %{id: id}} ) - PubSub.subscribe("subscribable:created") - mutation = """ mutation CreateSubscribable($input: CreateSubscribableInput) { createSubscribable(input: $input) { @@ -43,28 +41,22 @@ defmodule AshGraphql.SubscriptionTest do } """ + IO.inspect(self()) + assert {:ok, %{data: data}} = run_subscription(mutation, Schema, variables: %{"input" => %{"text" => "foo"}}, context: %{pubsub: PubSub} ) - assert_receive({:broadcast, msg}) - - Absinthe.Subscription.publish(PubSub, data, subscribable_created: "subscribable:created") - |> IO.inspect(label: :publish) - - assert %{ - event: "subscription:data", - result: %{data: %{"user" => %{"id" => "1", "name" => "foo"}}}, - topic: topic - } == msg + assert_receive({:broadcast, absinthe_proxy, data, fields}) end defp run_subscription(query, schema, opts) do opts = Keyword.update(opts, :context, %{pubsub: PubSub}, &Map.put(&1, :pubsub, PubSub)) - case Absinthe.run(query, schema, opts) |> IO.inspect(label: :absinthe_run) do + case Absinthe.run(query, schema, opts) do + # |> IO.inspect(label: :absinthe_run) do {:ok, %{"subscribed" => topic}} = val -> PubSub.subscribe(topic) val diff --git a/test/support/pub_sub.ex b/test/support/pub_sub.ex index f33a6572..6d02d0fa 100644 --- a/test/support/pub_sub.ex +++ b/test/support/pub_sub.ex @@ -10,7 +10,7 @@ defmodule AshGraphql.Test.PubSub do end def subscribe(topic) do - IO.inspect([topic: topic], label: "subscribe") + # IO.inspect([topic: topic], label: "subscribe") Registry.register(__MODULE__, topic, [self()]) :ok end @@ -22,7 +22,8 @@ defmodule AshGraphql.Test.PubSub do event: "subscription:data", result: data } - |> IO.inspect(label: :publish_subscription) + + # |> IO.inspect(label: :publish_subscription) Registry.dispatch(__MODULE__, topic, fn entries -> for {pid, _} <- entries, do: send(pid, {:broadcast, message}) @@ -30,7 +31,7 @@ defmodule AshGraphql.Test.PubSub do end def broadcast(topic, event, notification) do - IO.inspect([topic: topic, event: event, notification: notification], label: "broadcast") + # IO.inspect([topic: topic, event: event, notification: notification], label: "broadcast") message = %{ @@ -44,9 +45,16 @@ defmodule AshGraphql.Test.PubSub do end) end - def publish_mutation(_proxy_topic, _mutation_result, _subscribed_fields) do + def publish_mutation(proxy_topic, mutation_result, subscribed_fields) do # this pubsub is local and doesn't support clusters IO.inspect("publish mutation") + + send( + Application.get_env(__MODULE__, :notifier_test_pid) |> IO.inspect(label: :send_to), + {:broadcast, proxy_topic, mutation_result, subscribed_fields} + ) + |> IO.inspect(label: :send) + :ok end end diff --git a/test/support/resources/post.ex b/test/support/resources/post.ex index edccef54..d57ab76d 100644 --- a/test/support/resources/post.ex +++ b/test/support/resources/post.ex @@ -140,6 +140,10 @@ defmodule AshGraphql.Test.Post do require Ash.Query + resource do + simple_notifiers [AshGraphql.Resource.Notifier] + end + policies do policy always() do authorize_if(always()) diff --git a/test/support/resources/subscribable.ex b/test/support/resources/subscribable.ex index d5cdc407..de701311 100644 --- a/test/support/resources/subscribable.ex +++ b/test/support/resources/subscribable.ex @@ -4,11 +4,14 @@ defmodule AshGraphql.Test.Subscribable do use Ash.Resource, data_layer: Ash.DataLayer.Ets, - notifiers: [Ash.Notifier.PubSub], extensions: [AshGraphql.Resource] require Ash.Query + resource do + simple_notifiers([AshGraphql.Resource.Notifier]) + end + graphql do type :subscribable @@ -21,14 +24,6 @@ defmodule AshGraphql.Test.Subscribable do end end - pub_sub do - module(PubSub) - prefix("subscribable") - broadcast_type(:notification) - - publish_all(:create, "created") - end - actions do defaults([:create, :read, :update, :destroy]) end diff --git a/test/support/schema.ex b/test/support/schema.ex index 359ca3d5..afc26f5d 100644 --- a/test/support/schema.ex +++ b/test/support/schema.ex @@ -35,11 +35,8 @@ defmodule AshGraphql.Test.Schema do subscription do field :subscribable_created, :subscribable do config(fn - _args, %{context: %{actor: %{id: user_id}}} -> - {:ok, topic: "subscribable:created", context_id: "user/#{user_id}"} - - _args, _context -> - {:error, :unauthorized} + _args, _info -> + {:ok, topic: "*"} end) resolve(fn args, _, resolution -> From 026f2f1fa2360c1b047d44c3d49821c4757434f4 Mon Sep 17 00:00:00 2001 From: Barnabas Jovanovics Date: Thu, 4 Jan 2024 12:53:59 +0100 Subject: [PATCH 06/48] wip --- lib/ash_graphql.ex | 13 +++++- lib/domain/domain.ex | 8 ++++ lib/resource/info.ex | 5 +++ lib/resource/resource.ex | 44 ++++++++++++++++++- lib/resource/subscription.ex | 34 ++++++++++++++ lib/resource/subscription/config.ex | 9 ++++ lib/resource/subscription/config_function.ex | 13 ++++++ lib/resource/subscription/default_resolve.ex | 13 ++++++ lib/resource/subscription/resolve.ex | 10 +++++ lib/resource/subscription/resolve_function.ex | 13 ++++++ test/support/resources/subscribable.ex | 2 - 11 files changed, 160 insertions(+), 4 deletions(-) create mode 100644 lib/resource/subscription.ex create mode 100644 lib/resource/subscription/config.ex create mode 100644 lib/resource/subscription/config_function.ex create mode 100644 lib/resource/subscription/default_resolve.ex create mode 100644 lib/resource/subscription/resolve.ex create mode 100644 lib/resource/subscription/resolve_function.ex diff --git a/lib/ash_graphql.ex b/lib/ash_graphql.ex index 441b6763..7d779f10 100644 --- a/lib/ash_graphql.ex +++ b/lib/ash_graphql.ex @@ -202,6 +202,17 @@ defmodule AshGraphql do ) |> Enum.reduce(blueprint_with_queries, fn mutation, blueprint -> Absinthe.Blueprint.add_field(blueprint, "RootMutationType", mutation) + + blueprint_with_subscriptions = + api + |> AshGraphql.Api.subscriptions( + unquote(resources), + action_middleware, + __MODULE__ + ) + |> Enum.reduce(blueprint_with_queries, fn subscription, blueprint -> + Absinthe.Blueprint.add_field(blueprint, "RootSubscriptionType", mutation) + end) end) managed_relationship_types = @@ -212,7 +223,7 @@ defmodule AshGraphql do |> Enum.uniq_by(& &1.identifier) |> Enum.reject(fn type -> existing_types = - case blueprint_with_mutations do + case blueprint_with_subscriptions do %{schema_definitions: [%{type_definitions: type_definitions}]} -> type_definitions diff --git a/lib/domain/domain.ex b/lib/domain/domain.ex index 6aff12ff..d3a7d6f7 100644 --- a/lib/domain/domain.ex +++ b/lib/domain/domain.ex @@ -209,6 +209,14 @@ defmodule AshGraphql.Domain do ) end + def subscriptions(api, resources, action_middleware, schema) do + resources + |> Enum.filter(fn resource -> + AshGraphql.Resource in Spark.extensions(resource) + end) + |> Enum.flat_map(&AshGraphql.Resource.subscriptions(api, &1, action_middleware, schema)) + end + @doc false def type_definitions( domain, diff --git a/lib/resource/info.ex b/lib/resource/info.ex index 6b298ab7..8e0e0020 100644 --- a/lib/resource/info.ex +++ b/lib/resource/info.ex @@ -35,6 +35,11 @@ defmodule AshGraphql.Resource.Info do |> Enum.concat(Extension.get_entities(resource, [:graphql, :mutations]) || []) end + @doc "The subscriptions exposed for the resource" + def subscriptions(resource) do + Extension.get_entities(resource, [:graphql, :subscriptions]) || [] + end + @doc "Wether or not to encode the primary key as a single `id` field when reading and getting" def encode_primary_key?(resource) do Extension.get_opt(resource, [:graphql], :encode_primary_key?, true) diff --git a/lib/resource/resource.ex b/lib/resource/resource.ex index 25b6687a..d26e4a59 100644 --- a/lib/resource/resource.ex +++ b/lib/resource/resource.ex @@ -2,7 +2,7 @@ defmodule AshGraphql.Resource do alias Ash.Changeset.ManagedRelationshipHelpers alias Ash.Query.Aggregate alias AshGraphql.Resource - alias AshGraphql.Resource.{ManagedRelationship, Mutation, Query} + alias AshGraphql.Resource.{ManagedRelationship, Mutation, Query, Subscription} @get %Spark.Dsl.Entity{ name: :get, @@ -272,6 +272,34 @@ defmodule AshGraphql.Resource do def mutations, do: [@create, @update, @destroy, @action] + @subscribe %Spark.Dsl.Entity{ + name: :subscribe, + args: [:name, :config], + describe: "A query to fetch a record by primary key", + examples: [ + "get :get_post, :read" + ], + schema: Subscription.schema(), + target: Subscription + } + + @subscriptions %Spark.Dsl.Section{ + name: :subscriptions, + describe: """ + Subscriptions (notifications) to expose for the resource. + """, + examples: [ + """ + subscriptions do + subscribe :subscription_name, fn notifications -> ... end + end + """ + ], + entities: [ + @subscribe + ] + } + @graphql %Spark.Dsl.Section{ name: :graphql, imports: [AshGraphql.Resource.Helpers], @@ -406,6 +434,7 @@ defmodule AshGraphql.Resource do sections: [ @queries, @mutations, + @subscriptions, @managed_relationships ] } @@ -436,6 +465,9 @@ defmodule AshGraphql.Resource do @deprecated "See `AshGraphql.Resource.Info.mutations/1`" defdelegate mutations(resource, domain \\ []), to: AshGraphql.Resource.Info + @deprecated "See `AshGraphql.Resource.Info.mutations/1`" + defdelegate subscriptions(resource), to: AshGraphql.Resource.Info + @deprecated "See `AshGraphql.Resource.Info.managed_relationships/1`" defdelegate managed_relationships(resource), to: AshGraphql.Resource.Info @@ -1116,6 +1148,16 @@ defmodule AshGraphql.Resource do end end + # sobelow_skip ["DOS.StringToAtom"] + @doc false + def subscriptions(api, resource, action_middleware, schema) do + resource + |> subscriptions() + |> dbg() + + [] + end + @doc false # sobelow_skip ["DOS.StringToAtom"] def embedded_type_input(source_resource, attribute, resource, schema) do diff --git a/lib/resource/subscription.ex b/lib/resource/subscription.ex new file mode 100644 index 00000000..6f8ae30b --- /dev/null +++ b/lib/resource/subscription.ex @@ -0,0 +1,34 @@ +defmodule AshGraphql.Resource.Subscription do + @moduledoc "Represents a configured query on a resource" + defstruct [ + :name, + :config, + :resolve + ] + + @subscription_schema [ + name: [ + type: :atom, + doc: "The name to use for the subscription." + ], + config: [ + type: + {:spark_function_behaviour, AshGraphql.Resource.Subscription.Config, + {AshGraphql.Resource.Subscription.Config.Function, 2}}, + doc: """ + Function that creates the config for the subscription + """ + ], + resolve: [ + type: + {:spark_function_behaviour, AshGraphql.Resource.Subscription.Resolve, + {AshGraphql.Resource.Subscription.Resolve.Function, 3}}, + doc: """ + Function that creates the config for the subscription + """, + default: AshGraphql.Resource.Subscription.DefaultResolve + ] + ] + + def schema, do: @subscription_schema +end diff --git a/lib/resource/subscription/config.ex b/lib/resource/subscription/config.ex new file mode 100644 index 00000000..0b924231 --- /dev/null +++ b/lib/resource/subscription/config.ex @@ -0,0 +1,9 @@ +defmodule AshGraphql.Resource.Subscription.Config do + @callback config(args :: map(), info :: map()) :: {:ok, Keyword.t()} | {:error, Keyword.t()} + + defmacro __using__(_) do + quote do + @behaviour AshGraphql.Resource.Subscription.Config + end + end +end diff --git a/lib/resource/subscription/config_function.ex b/lib/resource/subscription/config_function.ex new file mode 100644 index 00000000..e5d5dfa9 --- /dev/null +++ b/lib/resource/subscription/config_function.ex @@ -0,0 +1,13 @@ +defmodule AshGraphql.Resource.Subscription.ConfigFunction do + use AshGraphql.Resource.Subscription.Config + + @impl true + def config(changeset, [fun: {m, f, a}], context) do + apply(m, f, [changeset, context | a]) + end + + @impl true + def config(changeset, [fun: fun], context) do + fun.(changeset, context) + end +end diff --git a/lib/resource/subscription/default_resolve.ex b/lib/resource/subscription/default_resolve.ex new file mode 100644 index 00000000..2be89cd1 --- /dev/null +++ b/lib/resource/subscription/default_resolve.ex @@ -0,0 +1,13 @@ +defmodule AshGraphql.Resource.Subscription.DefaultResolve do + require Ash.Query + + def resolve(args, _, resolution) do + AshGraphql.Subscription.query_for_subscription( + Post, + Api, + resolution + ) + |> Ash.Query.filter(id == ^args.id) + |> Api.read(actor: resolution.context.current_user) + end +end diff --git a/lib/resource/subscription/resolve.ex b/lib/resource/subscription/resolve.ex new file mode 100644 index 00000000..0c4a08c0 --- /dev/null +++ b/lib/resource/subscription/resolve.ex @@ -0,0 +1,10 @@ +defmodule AshGraphql.Resource.Subscription.Resolve do + @callback resolve(args :: map(), info :: map(), resolution :: map()) :: + {:ok, list()} | {:error, binary()} + + defmacro __using__(_) do + quote do + @behaviour AshGraphql.Resource.Subscription.Resolve + end + end +end diff --git a/lib/resource/subscription/resolve_function.ex b/lib/resource/subscription/resolve_function.ex new file mode 100644 index 00000000..290ab766 --- /dev/null +++ b/lib/resource/subscription/resolve_function.ex @@ -0,0 +1,13 @@ +defmodule AshGraphql.Resource.Subscription.ResolveFunction do + use AshGraphql.Resource.Subscription.Resolve + + @impl true + def resolve(changeset, [fun: {m, f, a}], context) do + apply(m, f, [changeset, context | a]) + end + + @impl true + def resolve(changeset, [fun: fun], context) do + fun.(changeset, context) + end +end diff --git a/test/support/resources/subscribable.ex b/test/support/resources/subscribable.ex index de701311..5b15e404 100644 --- a/test/support/resources/subscribable.ex +++ b/test/support/resources/subscribable.ex @@ -1,7 +1,5 @@ defmodule AshGraphql.Test.Subscribable do @moduledoc false - alias AshGraphql.Test.PubSub - use Ash.Resource, data_layer: Ash.DataLayer.Ets, extensions: [AshGraphql.Resource] From 90ad223198c00d112884b7da6f0c1f409bca38cc Mon Sep 17 00:00:00 2001 From: Barnabas Jovanovics Date: Mon, 8 Jan 2024 17:47:53 +0100 Subject: [PATCH 07/48] add subscription field to schema --- lib/ash_graphql.ex | 11 +++++++++-- lib/domain/domain.ex | 1 + lib/resource/resource.ex | 10 ++++++++-- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/lib/ash_graphql.ex b/lib/ash_graphql.ex index 7d779f10..d26ad38d 100644 --- a/lib/ash_graphql.ex +++ b/lib/ash_graphql.ex @@ -215,6 +215,13 @@ defmodule AshGraphql do end) end) + blueprint_with_subscriptions = + api + |> AshGraphql.Api.subscriptions(unquote(resources), action_middleware, __MODULE__) + |> Enum.reduce(blueprint_with_mutations, fn subscription, blueprint -> + Absinthe.Blueprint.add_field(blueprint, "RootSubscriptionType", subscription) + end) + managed_relationship_types = AshGraphql.Resource.managed_relationship_definitions( Process.get(:managed_relationship_requirements, []), @@ -304,7 +311,7 @@ defmodule AshGraphql do end new_defs = - List.update_at(blueprint_with_mutations.schema_definitions, 0, fn schema_def -> + List.update_at(blueprint_with_subscriptions.schema_definitions, 0, fn schema_def -> %{ schema_def | type_definitions: @@ -313,7 +320,7 @@ defmodule AshGraphql do } end) - {:ok, %{blueprint_with_mutations | schema_definitions: new_defs}} + {:ok, %{blueprint_with_subscriptions | schema_definitions: new_defs}} end end diff --git a/lib/domain/domain.ex b/lib/domain/domain.ex index d3a7d6f7..c8e0c0e7 100644 --- a/lib/domain/domain.ex +++ b/lib/domain/domain.ex @@ -211,6 +211,7 @@ defmodule AshGraphql.Domain do def subscriptions(api, resources, action_middleware, schema) do resources + |> IO.inspect(label: :subscriptions) |> Enum.filter(fn resource -> AshGraphql.Resource in Spark.extensions(resource) end) diff --git a/lib/resource/resource.ex b/lib/resource/resource.ex index d26e4a59..f48995fa 100644 --- a/lib/resource/resource.ex +++ b/lib/resource/resource.ex @@ -1153,9 +1153,15 @@ defmodule AshGraphql.Resource do def subscriptions(api, resource, action_middleware, schema) do resource |> subscriptions() + |> Enum.map(fn %Subscription{name: name, config: config} -> + %Absinthe.Blueprint.Schema.FieldDefinition{ + identifier: name, + name: to_string(name), + type: AshGraphql.Resource.Info.type(resource), + __reference__: ref(__ENV__) + } + end) |> dbg() - - [] end @doc false From 5b64553379a288cf85300d90080abe40d4a32a26 Mon Sep 17 00:00:00 2001 From: Barnabas Jovanovics Date: Wed, 10 Jan 2024 20:02:15 +0100 Subject: [PATCH 08/48] returned my first result from a subscription --- lib/ash_graphql.ex | 5 ++++ lib/resource/resource.ex | 10 ++++++- lib/resource/subscription.ex | 8 ++--- lib/resource/subscription/config.ex | 9 ------ lib/resource/subscription/config_function.ex | 13 -------- lib/resource/subscription/default_resolve.ex | 30 ++++++++++++++----- lib/resource/subscription/resolve.ex | 10 ------- lib/resource/subscription/resolve_function.ex | 13 -------- 8 files changed, 38 insertions(+), 60 deletions(-) delete mode 100644 lib/resource/subscription/config.ex delete mode 100644 lib/resource/subscription/config_function.ex delete mode 100644 lib/resource/subscription/resolve.ex delete mode 100644 lib/resource/subscription/resolve_function.ex diff --git a/lib/ash_graphql.ex b/lib/ash_graphql.ex index d26ad38d..b6b1225a 100644 --- a/lib/ash_graphql.ex +++ b/lib/ash_graphql.ex @@ -157,6 +157,11 @@ defmodule AshGraphql do @dialyzer {:nowarn_function, {:run, 2}} def run(blueprint, _opts) do domain = unquote(domain) + # IO.inspect( + # blueprint.schema_definitions + # |> Enum.find(&(&1.name == "RootSubscriptionType").fields) + # ) + action_middleware = unquote(action_middleware) all_domains = unquote(Enum.map(domains, &elem(&1, 0))) diff --git a/lib/resource/resource.ex b/lib/resource/resource.ex index f48995fa..6d18dcbe 100644 --- a/lib/resource/resource.ex +++ b/lib/resource/resource.ex @@ -1153,10 +1153,18 @@ defmodule AshGraphql.Resource do def subscriptions(api, resource, action_middleware, schema) do resource |> subscriptions() - |> Enum.map(fn %Subscription{name: name, config: config} -> + |> Enum.map(fn %Subscription{name: name, config: config} = subscription -> %Absinthe.Blueprint.Schema.FieldDefinition{ identifier: name, name: to_string(name), + config: config, + module: schema, + middleware: + action_middleware ++ + [ + {{AshGraphql.Resource.Subscription.DefaultResolve, :resolve}, + {api, resource, subscription, true}} + ], type: AshGraphql.Resource.Info.type(resource), __reference__: ref(__ENV__) } diff --git a/lib/resource/subscription.ex b/lib/resource/subscription.ex index 6f8ae30b..8d74d5c0 100644 --- a/lib/resource/subscription.ex +++ b/lib/resource/subscription.ex @@ -12,17 +12,13 @@ defmodule AshGraphql.Resource.Subscription do doc: "The name to use for the subscription." ], config: [ - type: - {:spark_function_behaviour, AshGraphql.Resource.Subscription.Config, - {AshGraphql.Resource.Subscription.Config.Function, 2}}, + type: {:mfa_or_fun, 2}, doc: """ Function that creates the config for the subscription """ ], resolve: [ - type: - {:spark_function_behaviour, AshGraphql.Resource.Subscription.Resolve, - {AshGraphql.Resource.Subscription.Resolve.Function, 3}}, + type: {:mfa_or_fun, 3}, doc: """ Function that creates the config for the subscription """, diff --git a/lib/resource/subscription/config.ex b/lib/resource/subscription/config.ex deleted file mode 100644 index 0b924231..00000000 --- a/lib/resource/subscription/config.ex +++ /dev/null @@ -1,9 +0,0 @@ -defmodule AshGraphql.Resource.Subscription.Config do - @callback config(args :: map(), info :: map()) :: {:ok, Keyword.t()} | {:error, Keyword.t()} - - defmacro __using__(_) do - quote do - @behaviour AshGraphql.Resource.Subscription.Config - end - end -end diff --git a/lib/resource/subscription/config_function.ex b/lib/resource/subscription/config_function.ex deleted file mode 100644 index e5d5dfa9..00000000 --- a/lib/resource/subscription/config_function.ex +++ /dev/null @@ -1,13 +0,0 @@ -defmodule AshGraphql.Resource.Subscription.ConfigFunction do - use AshGraphql.Resource.Subscription.Config - - @impl true - def config(changeset, [fun: {m, f, a}], context) do - apply(m, f, [changeset, context | a]) - end - - @impl true - def config(changeset, [fun: fun], context) do - fun.(changeset, context) - end -end diff --git a/lib/resource/subscription/default_resolve.ex b/lib/resource/subscription/default_resolve.ex index 2be89cd1..980481d7 100644 --- a/lib/resource/subscription/default_resolve.ex +++ b/lib/resource/subscription/default_resolve.ex @@ -1,13 +1,27 @@ defmodule AshGraphql.Resource.Subscription.DefaultResolve do require Ash.Query - def resolve(args, _, resolution) do - AshGraphql.Subscription.query_for_subscription( - Post, - Api, - resolution - ) - |> Ash.Query.filter(id == ^args.id) - |> Api.read(actor: resolution.context.current_user) + def resolve(%Absinthe.Resolution{state: :resolved} = resolution, _), + do: resolution + + def resolve( + %{arguments: arguments, context: context} = resolution, + {api, resource, %AshGraphql.Resource.Subscription{}, input?} + ) do + dbg() + + result = + AshGraphql.Subscription.query_for_subscription( + resource, + api, + resolution + ) + # |> Ash.Query.filter(id == ^args.id) + |> Ash.Query.limit(1) + |> api.read_one(actor: resolution.context[:current_user]) + |> IO.inspect() + + resolution + |> Absinthe.Resolution.put_result(result) end end diff --git a/lib/resource/subscription/resolve.ex b/lib/resource/subscription/resolve.ex deleted file mode 100644 index 0c4a08c0..00000000 --- a/lib/resource/subscription/resolve.ex +++ /dev/null @@ -1,10 +0,0 @@ -defmodule AshGraphql.Resource.Subscription.Resolve do - @callback resolve(args :: map(), info :: map(), resolution :: map()) :: - {:ok, list()} | {:error, binary()} - - defmacro __using__(_) do - quote do - @behaviour AshGraphql.Resource.Subscription.Resolve - end - end -end diff --git a/lib/resource/subscription/resolve_function.ex b/lib/resource/subscription/resolve_function.ex deleted file mode 100644 index 290ab766..00000000 --- a/lib/resource/subscription/resolve_function.ex +++ /dev/null @@ -1,13 +0,0 @@ -defmodule AshGraphql.Resource.Subscription.ResolveFunction do - use AshGraphql.Resource.Subscription.Resolve - - @impl true - def resolve(changeset, [fun: {m, f, a}], context) do - apply(m, f, [changeset, context | a]) - end - - @impl true - def resolve(changeset, [fun: fun], context) do - fun.(changeset, context) - end -end From f610c74e7da7161b4a46431ff96e523930d51ee0 Mon Sep 17 00:00:00 2001 From: Barnabas Jovanovics Date: Tue, 6 Feb 2024 09:24:44 +0100 Subject: [PATCH 09/48] wip --- lib/graphql/resolver.ex | 2 -- lib/resource/resource.ex | 4 +-- lib/resource/subscription.ex | 5 ++- lib/resource/subscription/default_resolve.ex | 3 -- lib/resource/subscription/notifier.ex | 12 ++++++++ lib/resource/transformers/subscription.ex | 32 ++++++++++++++++++++ test/support/resources/subscribable.ex | 11 ++++--- 7 files changed, 57 insertions(+), 12 deletions(-) create mode 100644 lib/resource/subscription/notifier.ex create mode 100644 lib/resource/transformers/subscription.ex diff --git a/lib/graphql/resolver.ex b/lib/graphql/resolver.ex index 11f2036a..18e7863c 100644 --- a/lib/graphql/resolver.ex +++ b/lib/graphql/resolver.ex @@ -13,8 +13,6 @@ defmodule AshGraphql.Graphql.Resolver do %{arguments: arguments, context: context} = resolution, {domain, resource, %AshGraphql.Resource.Action{name: query_name, action: action}, input?} ) do - dbg() - arguments = if input? do arguments[:input] || %{} diff --git a/lib/resource/resource.ex b/lib/resource/resource.ex index 6d18dcbe..333d9e93 100644 --- a/lib/resource/resource.ex +++ b/lib/resource/resource.ex @@ -442,7 +442,8 @@ defmodule AshGraphql.Resource do @transformers [ AshGraphql.Resource.Transformers.RequireKeysetForRelayQueries, AshGraphql.Resource.Transformers.ValidateActions, - AshGraphql.Resource.Transformers.ValidateCompatibleNames + AshGraphql.Resource.Transformers.ValidateCompatibleNames, + AshGraphql.Resource.Transformers.Subscription ] @verifiers [ @@ -1169,7 +1170,6 @@ defmodule AshGraphql.Resource do __reference__: ref(__ENV__) } end) - |> dbg() end @doc false diff --git a/lib/resource/subscription.ex b/lib/resource/subscription.ex index 8d74d5c0..c9054604 100644 --- a/lib/resource/subscription.ex +++ b/lib/resource/subscription.ex @@ -2,8 +2,11 @@ defmodule AshGraphql.Resource.Subscription do @moduledoc "Represents a configured query on a resource" defstruct [ :name, + # :arg = filter, :config, - :resolve + :read_action + # :topic, fn _, _ -> {:ok, topic} | :error, + # :trigger fn notification -> {:ok, topics} ] @subscription_schema [ diff --git a/lib/resource/subscription/default_resolve.ex b/lib/resource/subscription/default_resolve.ex index 980481d7..bad0a127 100644 --- a/lib/resource/subscription/default_resolve.ex +++ b/lib/resource/subscription/default_resolve.ex @@ -8,8 +8,6 @@ defmodule AshGraphql.Resource.Subscription.DefaultResolve do %{arguments: arguments, context: context} = resolution, {api, resource, %AshGraphql.Resource.Subscription{}, input?} ) do - dbg() - result = AshGraphql.Subscription.query_for_subscription( resource, @@ -19,7 +17,6 @@ defmodule AshGraphql.Resource.Subscription.DefaultResolve do # |> Ash.Query.filter(id == ^args.id) |> Ash.Query.limit(1) |> api.read_one(actor: resolution.context[:current_user]) - |> IO.inspect() resolution |> Absinthe.Resolution.put_result(result) diff --git a/lib/resource/subscription/notifier.ex b/lib/resource/subscription/notifier.ex new file mode 100644 index 00000000..fd5d350f --- /dev/null +++ b/lib/resource/subscription/notifier.ex @@ -0,0 +1,12 @@ +defmodule AshGraphq.Resource.Subscription.Notifier do + use Ash.Notifier + + @impl Ash.Notifier + def notify(notification) do + IO.inspect(notification, label: :Notifier) + + Absinthe.Subscription.publish(AshGraphql.Test.PubSub, notification.data, + subscrible_created: "*" + ) + end +end diff --git a/lib/resource/transformers/subscription.ex b/lib/resource/transformers/subscription.ex new file mode 100644 index 00000000..d43686b5 --- /dev/null +++ b/lib/resource/transformers/subscription.ex @@ -0,0 +1,32 @@ +defmodule AshGraphql.Resource.Transformers.Subscription do + @moduledoc """ + Adds the notifier for Subscriptions to the Resource + """ + + use Spark.Dsl.Transformer + + alias Spark.Dsl.Transformer + + def transform(dsl) do + case dsl + |> Transformer.get_entities([:graphql, :subscriptions]) do + [] -> + {:ok, dsl} + + _ -> + {:ok, + dsl + |> Transformer.set_option( + [:resource], + :simple_notifiers, + [ + AshGraphq.Resource.Subscription.Notifier + ] ++ + Transformer.get_option(dsl, [:resource], :simple_notifiers, []) + ) + |> dbg()} + end + + {:ok, dsl} + end +end diff --git a/test/support/resources/subscribable.ex b/test/support/resources/subscribable.ex index 5b15e404..cd41ac1f 100644 --- a/test/support/resources/subscribable.ex +++ b/test/support/resources/subscribable.ex @@ -6,10 +6,6 @@ defmodule AshGraphql.Test.Subscribable do require Ash.Query - resource do - simple_notifiers([AshGraphql.Resource.Notifier]) - end - graphql do type :subscribable @@ -20,6 +16,13 @@ defmodule AshGraphql.Test.Subscribable do mutations do create :create_subscribable, :create end + + subscriptions do + subscribe(:subscribable_created, fn _, _ -> + IO.inspect("bucket_created") + {:ok, topic: "*"} + end) + end end actions do From 3eaa6bf54bebac7059116e39440ab42d574e6ea7 Mon Sep 17 00:00:00 2001 From: Barnabas Jovanovics Date: Tue, 20 Feb 2024 19:22:49 +0100 Subject: [PATCH 10/48] subscribed and recieved data in playground --- lib/resource/info.ex | 4 ++++ lib/resource/notifier.ex | 12 ------------ lib/resource/resource.ex | 13 +++++++++++-- lib/resource/subscription.ex | 3 ++- lib/resource/subscription/default_config.ex | 3 +++ lib/resource/subscription/notifier.ex | 11 ++++++----- lib/resource/transformers/subscription.ex | 10 +++------- 7 files changed, 29 insertions(+), 27 deletions(-) delete mode 100644 lib/resource/notifier.ex create mode 100644 lib/resource/subscription/default_config.ex diff --git a/lib/resource/info.ex b/lib/resource/info.ex index 8e0e0020..835d18e2 100644 --- a/lib/resource/info.ex +++ b/lib/resource/info.ex @@ -40,6 +40,10 @@ defmodule AshGraphql.Resource.Info do Extension.get_entities(resource, [:graphql, :subscriptions]) || [] end + def subscription_pubsub(resource) do + Extension.get_opt(resource, [:graphql, :subscriptions], :pubsub) + end + @doc "Wether or not to encode the primary key as a single `id` field when reading and getting" def encode_primary_key?(resource) do Extension.get_opt(resource, [:graphql], :encode_primary_key?, true) diff --git a/lib/resource/notifier.ex b/lib/resource/notifier.ex deleted file mode 100644 index 96f41644..00000000 --- a/lib/resource/notifier.ex +++ /dev/null @@ -1,12 +0,0 @@ -defmodule AshGraphql.Resource.Notifier do - use Ash.Notifier - - @impl Ash.Notifier - def notify(notification) do - IO.inspect(notification, label: :Notifier) - - Absinthe.Subscription.publish(AshGraphql.Test.PubSub, notification.data, - subscrible_created: "*" - ) - end -end diff --git a/lib/resource/resource.ex b/lib/resource/resource.ex index 333d9e93..8d0a4d59 100644 --- a/lib/resource/resource.ex +++ b/lib/resource/resource.ex @@ -274,7 +274,7 @@ defmodule AshGraphql.Resource do @subscribe %Spark.Dsl.Entity{ name: :subscribe, - args: [:name, :config], + args: [:name], describe: "A query to fetch a record by primary key", examples: [ "get :get_post, :read" @@ -285,6 +285,13 @@ defmodule AshGraphql.Resource do @subscriptions %Spark.Dsl.Section{ name: :subscriptions, + schema: [ + pubsub: [ + type: :module, + required: true, + doc: "The pubsub module to use for the subscription" + ] + ], describe: """ Subscriptions (notifications) to expose for the resource. """, @@ -1155,10 +1162,12 @@ defmodule AshGraphql.Resource do resource |> subscriptions() |> Enum.map(fn %Subscription{name: name, config: config} = subscription -> + dbg(config) + %Absinthe.Blueprint.Schema.FieldDefinition{ identifier: name, name: to_string(name), - config: config, + config: &AshGraphql.Resource.Subscription.DefaultConfig.config/2, module: schema, middleware: action_middleware ++ diff --git a/lib/resource/subscription.ex b/lib/resource/subscription.ex index c9054604..79d38fd3 100644 --- a/lib/resource/subscription.ex +++ b/lib/resource/subscription.ex @@ -18,7 +18,8 @@ defmodule AshGraphql.Resource.Subscription do type: {:mfa_or_fun, 2}, doc: """ Function that creates the config for the subscription - """ + """, + default: AshGraphql.Resource.Subscription.DefaultConfig ], resolve: [ type: {:mfa_or_fun, 3}, diff --git a/lib/resource/subscription/default_config.ex b/lib/resource/subscription/default_config.ex new file mode 100644 index 00000000..7af5e67e --- /dev/null +++ b/lib/resource/subscription/default_config.ex @@ -0,0 +1,3 @@ +defmodule AshGraphql.Resource.Subscription.DefaultConfig do + def config(_, _), do: dbg({:ok, topic: "*"}) +end diff --git a/lib/resource/subscription/notifier.ex b/lib/resource/subscription/notifier.ex index fd5d350f..1404eecc 100644 --- a/lib/resource/subscription/notifier.ex +++ b/lib/resource/subscription/notifier.ex @@ -1,12 +1,13 @@ -defmodule AshGraphq.Resource.Subscription.Notifier do +defmodule AshGraphql.Resource.Subscription.Notifier do + alias AshGraphql.Resource.Info use Ash.Notifier @impl Ash.Notifier def notify(notification) do - IO.inspect(notification, label: :Notifier) + pub_sub = Info.subscription_pubsub(notification.resource) - Absinthe.Subscription.publish(AshGraphql.Test.PubSub, notification.data, - subscrible_created: "*" - ) + for subscription <- AshGraphql.Resource.Info.subscriptions(notification.resource) do + Absinthe.Subscription.publish(pub_sub, notification.data, [{subscription.name, "*"}]) + end end end diff --git a/lib/resource/transformers/subscription.ex b/lib/resource/transformers/subscription.ex index d43686b5..2577002c 100644 --- a/lib/resource/transformers/subscription.ex +++ b/lib/resource/transformers/subscription.ex @@ -8,8 +8,7 @@ defmodule AshGraphql.Resource.Transformers.Subscription do alias Spark.Dsl.Transformer def transform(dsl) do - case dsl - |> Transformer.get_entities([:graphql, :subscriptions]) do + case dsl |> Transformer.get_entities([:graphql, :subscriptions]) do [] -> {:ok, dsl} @@ -20,13 +19,10 @@ defmodule AshGraphql.Resource.Transformers.Subscription do [:resource], :simple_notifiers, [ - AshGraphq.Resource.Subscription.Notifier + AshGraphql.Resource.Subscription.Notifier ] ++ Transformer.get_option(dsl, [:resource], :simple_notifiers, []) - ) - |> dbg()} + )} end - - {:ok, dsl} end end From fbe17005eecb5314152cd7288ef6c26bf0bb530b Mon Sep 17 00:00:00 2001 From: Barnabas Jovanovics Date: Wed, 21 Feb 2024 19:01:20 +0100 Subject: [PATCH 11/48] create config that can use the filter to create a context_id for de-duplication --- lib/resource/resource.ex | 7 +++- lib/resource/subscription/default_config.ex | 39 +++++++++++++++++++- lib/resource/subscription/default_resolve.ex | 8 +++- lib/resource/subscription/notifier.ex | 1 + 4 files changed, 51 insertions(+), 4 deletions(-) diff --git a/lib/resource/resource.ex b/lib/resource/resource.ex index 8d0a4d59..60c5d1e9 100644 --- a/lib/resource/resource.ex +++ b/lib/resource/resource.ex @@ -1167,7 +1167,12 @@ defmodule AshGraphql.Resource do %Absinthe.Blueprint.Schema.FieldDefinition{ identifier: name, name: to_string(name), - config: &AshGraphql.Resource.Subscription.DefaultConfig.config/2, + config: + AshGraphql.Resource.Subscription.DefaultConfig.create_config( + subscription, + api, + resource + ), module: schema, middleware: action_middleware ++ diff --git a/lib/resource/subscription/default_config.ex b/lib/resource/subscription/default_config.ex index 7af5e67e..ed86f9aa 100644 --- a/lib/resource/subscription/default_config.ex +++ b/lib/resource/subscription/default_config.ex @@ -1,3 +1,40 @@ defmodule AshGraphql.Resource.Subscription.DefaultConfig do - def config(_, _), do: dbg({:ok, topic: "*"}) + alias AshGraphql.Resource.Subscription + + def create_config(%Subscription{} = subscription, api, resource) do + config_module = String.to_atom(Macro.camelize(Atom.to_string(subscription.name)) <> ".Config") + dbg() + + defmodule config_module do + require Ash.Query + + @subscription subscription + @resource resource + @api api + def config(_args, %{context: context}) do + read_action = + @subscription.read_action || Ash.Resource.Info.primary_action!(@resource, :read).name + + case Ash.Api.can( + @api, + Ash.Query.for_read(@resource, read_action) + |> Ash.Query.filter(id == "test"), + context[:actor], + run_queries?: false, + alter_source?: true + ) do + {:ok, true} -> + {:ok, topic: "*", context_id: "global"} + + {:ok, true, filter} -> + {:ok, topic: "*", context_id: Base.encode64(:erlang.term_to_binary(filter))} + + _ -> + {:error, "unauthorized"} + end + end + end + + &config_module.config/2 + end end diff --git a/lib/resource/subscription/default_resolve.ex b/lib/resource/subscription/default_resolve.ex index bad0a127..333ca2e8 100644 --- a/lib/resource/subscription/default_resolve.ex +++ b/lib/resource/subscription/default_resolve.ex @@ -1,13 +1,17 @@ defmodule AshGraphql.Resource.Subscription.DefaultResolve do require Ash.Query - def resolve(%Absinthe.Resolution{state: :resolved} = resolution, _), - do: resolution + def resolve(%Absinthe.Resolution{state: :resolved} = resolution, _) do + dbg() + resolution + end def resolve( %{arguments: arguments, context: context} = resolution, {api, resource, %AshGraphql.Resource.Subscription{}, input?} ) do + dbg() + result = AshGraphql.Subscription.query_for_subscription( resource, diff --git a/lib/resource/subscription/notifier.ex b/lib/resource/subscription/notifier.ex index 1404eecc..2ea844f2 100644 --- a/lib/resource/subscription/notifier.ex +++ b/lib/resource/subscription/notifier.ex @@ -4,6 +4,7 @@ defmodule AshGraphql.Resource.Subscription.Notifier do @impl Ash.Notifier def notify(notification) do + dbg() pub_sub = Info.subscription_pubsub(notification.resource) for subscription <- AshGraphql.Resource.Info.subscriptions(notification.resource) do From c26af7a0d2986068c0fc6d43f66f6a1b001cca66 Mon Sep 17 00:00:00 2001 From: Barnabas Jovanovics Date: Fri, 8 Mar 2024 16:36:08 +0100 Subject: [PATCH 12/48] move resolve logic in existing resolver module --- lib/graphql/resolver.ex | 23 ++++++++++++++++ lib/resource/resource.ex | 6 +++-- lib/resource/subscription.ex | 3 --- lib/resource/subscription/default_resolve.ex | 28 -------------------- 4 files changed, 27 insertions(+), 33 deletions(-) delete mode 100644 lib/resource/subscription/default_resolve.ex diff --git a/lib/graphql/resolver.ex b/lib/graphql/resolver.ex index 18e7863c..4a97c7ce 100644 --- a/lib/graphql/resolver.ex +++ b/lib/graphql/resolver.ex @@ -505,6 +505,29 @@ defmodule AshGraphql.Graphql.Resolver do end end + def resolve( + %{arguments: arguments, context: context} = resolution, + {api, resource, %AshGraphql.Resource.Subscription{}, _input?} + ) do + dbg() + + result = + AshGraphql.Subscription.query_for_subscription( + resource, + api, + resolution + ) + # > Ash.Query.filter(id == ^args.id) + |> Ash.Query.limit(1) + |> api.read_one(actor: resolution.context[:current_user]) + |> dbg() + + resolution + |> Absinthe.Resolution.put_result(result) + + resolution + end + defp read_one_query(resource, args) do case Map.fetch(args, :filter) do {:ok, filter} when filter != %{} -> diff --git a/lib/resource/resource.ex b/lib/resource/resource.ex index 60c5d1e9..3effdf67 100644 --- a/lib/resource/resource.ex +++ b/lib/resource/resource.ex @@ -1165,6 +1165,9 @@ defmodule AshGraphql.Resource do dbg(config) %Absinthe.Blueprint.Schema.FieldDefinition{ + arguments: + args(:subscription, resource, nil, schema, nil) + |> IO.inspect(label: "args"), identifier: name, name: to_string(name), config: @@ -1177,8 +1180,7 @@ defmodule AshGraphql.Resource do middleware: action_middleware ++ [ - {{AshGraphql.Resource.Subscription.DefaultResolve, :resolve}, - {api, resource, subscription, true}} + {{AshGraphql.Graphql.Resolver, :resolve}, {api, resource, subscription, true}} ], type: AshGraphql.Resource.Info.type(resource), __reference__: ref(__ENV__) diff --git a/lib/resource/subscription.ex b/lib/resource/subscription.ex index 79d38fd3..9176a8d1 100644 --- a/lib/resource/subscription.ex +++ b/lib/resource/subscription.ex @@ -2,11 +2,8 @@ defmodule AshGraphql.Resource.Subscription do @moduledoc "Represents a configured query on a resource" defstruct [ :name, - # :arg = filter, :config, :read_action - # :topic, fn _, _ -> {:ok, topic} | :error, - # :trigger fn notification -> {:ok, topics} ] @subscription_schema [ diff --git a/lib/resource/subscription/default_resolve.ex b/lib/resource/subscription/default_resolve.ex deleted file mode 100644 index 333ca2e8..00000000 --- a/lib/resource/subscription/default_resolve.ex +++ /dev/null @@ -1,28 +0,0 @@ -defmodule AshGraphql.Resource.Subscription.DefaultResolve do - require Ash.Query - - def resolve(%Absinthe.Resolution{state: :resolved} = resolution, _) do - dbg() - resolution - end - - def resolve( - %{arguments: arguments, context: context} = resolution, - {api, resource, %AshGraphql.Resource.Subscription{}, input?} - ) do - dbg() - - result = - AshGraphql.Subscription.query_for_subscription( - resource, - api, - resolution - ) - # |> Ash.Query.filter(id == ^args.id) - |> Ash.Query.limit(1) - |> api.read_one(actor: resolution.context[:current_user]) - - resolution - |> Absinthe.Resolution.put_result(result) - end -end From a96587b8893c4493af26231ff150c52b2b176729 Mon Sep 17 00:00:00 2001 From: Barnabas Jovanovics Date: Mon, 6 May 2024 09:24:10 +0200 Subject: [PATCH 13/48] wip --- lib/graphql/resolver.ex | 5 +++-- lib/resource/subscription/default_config.ex | 2 +- lib/resource/subscription/notifier.ex | 3 +-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/graphql/resolver.ex b/lib/graphql/resolver.ex index 4a97c7ce..1a049e6b 100644 --- a/lib/graphql/resolver.ex +++ b/lib/graphql/resolver.ex @@ -518,9 +518,10 @@ defmodule AshGraphql.Graphql.Resolver do resolution ) # > Ash.Query.filter(id == ^args.id) - |> Ash.Query.limit(1) + # |> Ash.Query.limit(1) |> api.read_one(actor: resolution.context[:current_user]) - |> dbg() + + dbg(result) resolution |> Absinthe.Resolution.put_result(result) diff --git a/lib/resource/subscription/default_config.ex b/lib/resource/subscription/default_config.ex index ed86f9aa..52efd05c 100644 --- a/lib/resource/subscription/default_config.ex +++ b/lib/resource/subscription/default_config.ex @@ -24,7 +24,7 @@ defmodule AshGraphql.Resource.Subscription.DefaultConfig do alter_source?: true ) do {:ok, true} -> - {:ok, topic: "*", context_id: "global"} + dbg({:ok, topic: "*", context_id: "global"}) {:ok, true, filter} -> {:ok, topic: "*", context_id: Base.encode64(:erlang.term_to_binary(filter))} diff --git a/lib/resource/subscription/notifier.ex b/lib/resource/subscription/notifier.ex index 2ea844f2..c012fe83 100644 --- a/lib/resource/subscription/notifier.ex +++ b/lib/resource/subscription/notifier.ex @@ -4,10 +4,9 @@ defmodule AshGraphql.Resource.Subscription.Notifier do @impl Ash.Notifier def notify(notification) do - dbg() pub_sub = Info.subscription_pubsub(notification.resource) - for subscription <- AshGraphql.Resource.Info.subscriptions(notification.resource) do + for subscription <- dbg(AshGraphql.Resource.Info.subscriptions(notification.resource)) do Absinthe.Subscription.publish(pub_sub, notification.data, [{subscription.name, "*"}]) end end From cb576c88dfdc8d445ae401800ee01048cbc142c7 Mon Sep 17 00:00:00 2001 From: Barnabas Jovanovics Date: Mon, 1 Jul 2024 21:01:05 +0200 Subject: [PATCH 14/48] rebase on main --- lib/ash_graphql.ex | 19 +++++---------- lib/graphql/resolver.ex | 26 ++++++++++++--------- lib/resource/resource.ex | 26 ++++++++++++++++++--- lib/resource/subscription.ex | 14 ----------- lib/resource/subscription/default_config.ex | 6 ++--- lib/resource/transformers/subscription.ex | 5 ++-- test/support/resources/post.ex | 5 +--- 7 files changed, 49 insertions(+), 52 deletions(-) diff --git a/lib/ash_graphql.ex b/lib/ash_graphql.ex index b6b1225a..9f8c5b42 100644 --- a/lib/ash_graphql.ex +++ b/lib/ash_graphql.ex @@ -207,22 +207,15 @@ defmodule AshGraphql do ) |> Enum.reduce(blueprint_with_queries, fn mutation, blueprint -> Absinthe.Blueprint.add_field(blueprint, "RootMutationType", mutation) - - blueprint_with_subscriptions = - api - |> AshGraphql.Api.subscriptions( - unquote(resources), - action_middleware, - __MODULE__ - ) - |> Enum.reduce(blueprint_with_queries, fn subscription, blueprint -> - Absinthe.Blueprint.add_field(blueprint, "RootSubscriptionType", mutation) - end) end) blueprint_with_subscriptions = - api - |> AshGraphql.Api.subscriptions(unquote(resources), action_middleware, __MODULE__) + domain + |> AshGraphql.Domain.subscriptions( + unquote(resources), + action_middleware, + __MODULE__ + ) |> Enum.reduce(blueprint_with_mutations, fn subscription, blueprint -> Absinthe.Blueprint.add_field(blueprint, "RootSubscriptionType", subscription) end) diff --git a/lib/graphql/resolver.ex b/lib/graphql/resolver.ex index 1a049e6b..75872f96 100644 --- a/lib/graphql/resolver.ex +++ b/lib/graphql/resolver.ex @@ -3,6 +3,7 @@ defmodule AshGraphql.Graphql.Resolver do require Logger import Ash.Expr + require Ash.Query import AshGraphql.TraceHelpers import AshGraphql.ContextHelpers @@ -506,27 +507,30 @@ defmodule AshGraphql.Graphql.Resolver do end def resolve( - %{arguments: arguments, context: context} = resolution, + %{arguments: arguments, context: context, root_value: data} = resolution, {api, resource, %AshGraphql.Resource.Subscription{}, _input?} ) do - dbg() - - result = + query = AshGraphql.Subscription.query_for_subscription( - resource, + resource + |> Ash.Query.new(), api, resolution ) - # > Ash.Query.filter(id == ^args.id) - # |> Ash.Query.limit(1) - |> api.read_one(actor: resolution.context[:current_user]) - dbg(result) + query = + Ash.Resource.Info.primary_key(resource) + |> Enum.reduce(query, fn key, query -> + value = Map.get(data, key) + Ash.Query.filter(query, ^ref(key) == ^value) + end) - resolution - |> Absinthe.Resolution.put_result(result) + result = + query + |> api.read_one(actor: resolution.context[:current_user]) resolution + |> Absinthe.Resolution.put_result(result) end defp read_one_query(resource, args) do diff --git a/lib/resource/resource.ex b/lib/resource/resource.ex index 3effdf67..1b9a45a3 100644 --- a/lib/resource/resource.ex +++ b/lib/resource/resource.ex @@ -1161,9 +1161,7 @@ defmodule AshGraphql.Resource do def subscriptions(api, resource, action_middleware, schema) do resource |> subscriptions() - |> Enum.map(fn %Subscription{name: name, config: config} = subscription -> - dbg(config) - + |> Enum.map(fn %Subscription{name: name} = subscription -> %Absinthe.Blueprint.Schema.FieldDefinition{ arguments: args(:subscription, resource, nil, schema, nil) @@ -1709,6 +1707,28 @@ defmodule AshGraphql.Resource do read_args(resource, action, schema, hide_inputs) end + defp args(:subscription, resource, _action, schema, _identity, _hide_inputs, _query) do + if AshGraphql.Resource.Info.derive_filter?(resource) do + case resource_filter_fields(resource, schema) do + [] -> + [] + + _ -> + [ + %Absinthe.Blueprint.Schema.InputValueDefinition{ + name: "filter", + identifier: :filter, + type: resource_filter_type(resource), + description: "A filter to limit the results", + __reference__: ref(__ENV__) + } + ] + end + else + [] + end + end + defp related_list_args(resource, related_resource, relationship_name, action, schema) do args(:list, related_resource, action, schema) ++ relationship_pagination_args(resource, relationship_name, action) diff --git a/lib/resource/subscription.ex b/lib/resource/subscription.ex index 9176a8d1..f87f18c6 100644 --- a/lib/resource/subscription.ex +++ b/lib/resource/subscription.ex @@ -10,20 +10,6 @@ defmodule AshGraphql.Resource.Subscription do name: [ type: :atom, doc: "The name to use for the subscription." - ], - config: [ - type: {:mfa_or_fun, 2}, - doc: """ - Function that creates the config for the subscription - """, - default: AshGraphql.Resource.Subscription.DefaultConfig - ], - resolve: [ - type: {:mfa_or_fun, 3}, - doc: """ - Function that creates the config for the subscription - """, - default: AshGraphql.Resource.Subscription.DefaultResolve ] ] diff --git a/lib/resource/subscription/default_config.ex b/lib/resource/subscription/default_config.ex index 52efd05c..c1e05ad9 100644 --- a/lib/resource/subscription/default_config.ex +++ b/lib/resource/subscription/default_config.ex @@ -1,7 +1,7 @@ defmodule AshGraphql.Resource.Subscription.DefaultConfig do alias AshGraphql.Resource.Subscription - def create_config(%Subscription{} = subscription, api, resource) do + def create_config(%Subscription{} = subscription, _domain, resource) do config_module = String.to_atom(Macro.camelize(Atom.to_string(subscription.name)) <> ".Config") dbg() @@ -10,13 +10,11 @@ defmodule AshGraphql.Resource.Subscription.DefaultConfig do @subscription subscription @resource resource - @api api def config(_args, %{context: context}) do read_action = @subscription.read_action || Ash.Resource.Info.primary_action!(@resource, :read).name - case Ash.Api.can( - @api, + case Ash.can( Ash.Query.for_read(@resource, read_action) |> Ash.Query.filter(id == "test"), context[:actor], diff --git a/lib/resource/transformers/subscription.ex b/lib/resource/transformers/subscription.ex index 2577002c..d02757cb 100644 --- a/lib/resource/transformers/subscription.ex +++ b/lib/resource/transformers/subscription.ex @@ -15,13 +15,12 @@ defmodule AshGraphql.Resource.Transformers.Subscription do _ -> {:ok, dsl - |> Transformer.set_option( - [:resource], + |> Transformer.persist( :simple_notifiers, [ AshGraphql.Resource.Subscription.Notifier ] ++ - Transformer.get_option(dsl, [:resource], :simple_notifiers, []) + Transformer.get_persisted(dsl, :simple_notifiers, []) )} end end diff --git a/test/support/resources/post.ex b/test/support/resources/post.ex index d57ab76d..0240deb5 100644 --- a/test/support/resources/post.ex +++ b/test/support/resources/post.ex @@ -136,14 +136,11 @@ defmodule AshGraphql.Test.Post do domain: AshGraphql.Test.Domain, data_layer: Ash.DataLayer.Ets, authorizers: [Ash.Policy.Authorizer], + simple_notifiers: [AshGraphql.Resource.Notifier], extensions: [AshGraphql.Resource] require Ash.Query - resource do - simple_notifiers [AshGraphql.Resource.Notifier] - end - policies do policy always() do authorize_if(always()) From 62b258fa481c52d8e61c59136714f175c99c315e Mon Sep 17 00:00:00 2001 From: Barnabas Jovanovics Date: Mon, 5 Aug 2024 13:56:42 +0200 Subject: [PATCH 15/48] add endpoint with custom run_docset function --- lib/endpoint.ex | 68 +++++++++++++++++++++ lib/graphql/resolver.ex | 19 +++--- lib/resource/subscription/default_config.ex | 9 +-- lib/resource/subscription/notifier.ex | 2 +- 4 files changed, 85 insertions(+), 13 deletions(-) create mode 100644 lib/endpoint.ex diff --git a/lib/endpoint.ex b/lib/endpoint.ex new file mode 100644 index 00000000..26b791a4 --- /dev/null +++ b/lib/endpoint.ex @@ -0,0 +1,68 @@ +defmodule AshGraphql.Endpoint do + defmacro __using__(_opts) do + quote do + use Absinthe.Phoenix.Endpoint + + require Logger + + def run_docset(pubsub, docs_and_topics, mutation_result) do + for {topic, key_strategy, doc} <- docs_and_topics do + try do + pipeline = + Absinthe.Subscription.Local.pipeline(doc, mutation_result) + # why though? + |> List.flatten() + |> Absinthe.Pipeline.insert_before( + Absinthe.Phase.Document.OverrideRoot, + {Absinthe.Phase.Document.Context, context: %{ash_filter: get_filter(topic)}} + ) + + {:ok, %{result: data}, _} = Absinthe.Pipeline.run(doc.source, pipeline) + + Logger.debug(""" + Absinthe Subscription Publication + Field Topic: #{inspect(key_strategy)} + Subscription id: #{inspect(topic)} + Data: #{inspect(data)} + """) + + case is_forbidden(data) do + true -> + # do not send anything to the client if he is not allowed to see it + :ok + + false -> + :ok = pubsub.publish_subscription(topic, data) + end + rescue + e -> + BatchResolver.pipeline_error(e, __STACKTRACE__) + end + end + end + + defp is_forbidden(%{errors: errors}) do + errors + |> List.wrap() + |> Enum.any?(fn error -> Map.get(error, :code) == "forbidden" end) + end + + defp is_forbidden(_), do: false + + defp get_filter(topic) do + [_, rest] = String.split(topic, "__absinthe__:doc:") + [filter, _] = String.split(rest, ":") + + case Base.decode64(filter) do + {:ok, filter} -> + :erlang.binary_to_term(filter) + + _ -> + nil + end + rescue + _ -> nil + end + end + end +end diff --git a/lib/graphql/resolver.ex b/lib/graphql/resolver.ex index 75872f96..ff38a06a 100644 --- a/lib/graphql/resolver.ex +++ b/lib/graphql/resolver.ex @@ -507,14 +507,14 @@ defmodule AshGraphql.Graphql.Resolver do end def resolve( - %{arguments: arguments, context: context, root_value: data} = resolution, - {api, resource, %AshGraphql.Resource.Subscription{}, _input?} + %{arguments: _arguments, context: context, root_value: data} = resolution, + {domain, resource, %AshGraphql.Resource.Subscription{}, _input?} ) do query = AshGraphql.Subscription.query_for_subscription( resource |> Ash.Query.new(), - api, + domain, resolution ) @@ -525,12 +525,15 @@ defmodule AshGraphql.Graphql.Resolver do Ash.Query.filter(query, ^ref(key) == ^value) end) - result = - query - |> api.read_one(actor: resolution.context[:current_user]) + case query |> domain.read_one(actor: resolution.context[:current_user]) do + {:ok, result} -> + resolution + |> Absinthe.Resolution.put_result({:ok, result}) - resolution - |> Absinthe.Resolution.put_result(result) + {:error, error} -> + resolution + |> Absinthe.Resolution.put_result({:error, to_errors([error], context, domain)}) + end end defp read_one_query(resource, args) do diff --git a/lib/resource/subscription/default_config.ex b/lib/resource/subscription/default_config.ex index c1e05ad9..67e66f0e 100644 --- a/lib/resource/subscription/default_config.ex +++ b/lib/resource/subscription/default_config.ex @@ -3,7 +3,6 @@ defmodule AshGraphql.Resource.Subscription.DefaultConfig do def create_config(%Subscription{} = subscription, _domain, resource) do config_module = String.to_atom(Macro.camelize(Atom.to_string(subscription.name)) <> ".Config") - dbg() defmodule config_module do require Ash.Query @@ -15,16 +14,18 @@ defmodule AshGraphql.Resource.Subscription.DefaultConfig do @subscription.read_action || Ash.Resource.Info.primary_action!(@resource, :read).name case Ash.can( - Ash.Query.for_read(@resource, read_action) - |> Ash.Query.filter(id == "test"), + Ash.Query.for_read(@resource, read_action), context[:actor], run_queries?: false, alter_source?: true ) do {:ok, true} -> - dbg({:ok, topic: "*", context_id: "global"}) + {:ok, topic: "*", context_id: "global"} {:ok, true, filter} -> + # context_id is exposed to the client so we might need to encrypt it + # or save it in ets or something and send generate a hash or something + # as the context_id {:ok, topic: "*", context_id: Base.encode64(:erlang.term_to_binary(filter))} _ -> diff --git a/lib/resource/subscription/notifier.ex b/lib/resource/subscription/notifier.ex index c012fe83..1404eecc 100644 --- a/lib/resource/subscription/notifier.ex +++ b/lib/resource/subscription/notifier.ex @@ -6,7 +6,7 @@ defmodule AshGraphql.Resource.Subscription.Notifier do def notify(notification) do pub_sub = Info.subscription_pubsub(notification.resource) - for subscription <- dbg(AshGraphql.Resource.Info.subscriptions(notification.resource)) do + for subscription <- AshGraphql.Resource.Info.subscriptions(notification.resource) do Absinthe.Subscription.publish(pub_sub, notification.data, [{subscription.name, "*"}]) end end From 2de384664a60f10ca20f9195c5aa87bdf1d68079 Mon Sep 17 00:00:00 2001 From: Barnabas Jovanovics Date: Mon, 5 Aug 2024 13:58:20 +0200 Subject: [PATCH 16/48] add missing alias --- lib/endpoint.ex | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/endpoint.ex b/lib/endpoint.ex index 26b791a4..151e8521 100644 --- a/lib/endpoint.ex +++ b/lib/endpoint.ex @@ -3,6 +3,8 @@ defmodule AshGraphql.Endpoint do quote do use Absinthe.Phoenix.Endpoint + alias Absinthe.Pipeline.BatchResolver + require Logger def run_docset(pubsub, docs_and_topics, mutation_result) do From e1d2d2355ec1904a071d88519a7c4954b7d8c821 Mon Sep 17 00:00:00 2001 From: Barnabas Jovanovics Date: Mon, 5 Aug 2024 15:05:03 +0200 Subject: [PATCH 17/48] handle not found case --- lib/endpoint.ex | 2 +- lib/graphql/resolver.ex | 11 +++++++++-- lib/resource/subscription/default_config.ex | 5 +++-- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/lib/endpoint.ex b/lib/endpoint.ex index 151e8521..396ba984 100644 --- a/lib/endpoint.ex +++ b/lib/endpoint.ex @@ -46,7 +46,7 @@ defmodule AshGraphql.Endpoint do defp is_forbidden(%{errors: errors}) do errors |> List.wrap() - |> Enum.any?(fn error -> Map.get(error, :code) == "forbidden" end) + |> Enum.any?(fn error -> Map.get(error, :code) in ["forbidden", "not_found"] end) end defp is_forbidden(_), do: false diff --git a/lib/graphql/resolver.ex b/lib/graphql/resolver.ex index ff38a06a..f28f6c8a 100644 --- a/lib/graphql/resolver.ex +++ b/lib/graphql/resolver.ex @@ -513,7 +513,7 @@ defmodule AshGraphql.Graphql.Resolver do query = AshGraphql.Subscription.query_for_subscription( resource - |> Ash.Query.new(), + |> Ash.Query.for_read(:read, %{}, actor: Map.get(context, :actor)), domain, resolution ) @@ -525,7 +525,14 @@ defmodule AshGraphql.Graphql.Resolver do Ash.Query.filter(query, ^ref(key) == ^value) end) - case query |> domain.read_one(actor: resolution.context[:current_user]) do + case query |> domain.read_one() do + # should only happen if a resource is created/updated and the subscribed user is not allowed to see it + {:ok, nil} -> + resolution + |> Absinthe.Resolution.put_result( + {:error, to_errors([Ash.Error.Query.NotFound.exception()], context, domain)} + ) + {:ok, result} -> resolution |> Absinthe.Resolution.put_result({:ok, result}) diff --git a/lib/resource/subscription/default_config.ex b/lib/resource/subscription/default_config.ex index 67e66f0e..cc7f70b0 100644 --- a/lib/resource/subscription/default_config.ex +++ b/lib/resource/subscription/default_config.ex @@ -26,9 +26,10 @@ defmodule AshGraphql.Resource.Subscription.DefaultConfig do # context_id is exposed to the client so we might need to encrypt it # or save it in ets or something and send generate a hash or something # as the context_id - {:ok, topic: "*", context_id: Base.encode64(:erlang.term_to_binary(filter))} + dbg(filter) + {:ok, topic: "*", context_id: dbg(Base.encode64(:erlang.term_to_binary(filter)))} - _ -> + e -> {:error, "unauthorized"} end end From a8fe4314869024937f20b960db4d99d0a3e25a15 Mon Sep 17 00:00:00 2001 From: Barnabas Jovanovics Date: Mon, 5 Aug 2024 16:34:32 +0200 Subject: [PATCH 18/48] use the configured read action to get the data in the resolver --- lib/graphql/resolver.ex | 7 +++++-- lib/resource/subscription.ex | 4 ++++ lib/resource/subscription/default_config.ex | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/lib/graphql/resolver.ex b/lib/graphql/resolver.ex index f28f6c8a..c4278041 100644 --- a/lib/graphql/resolver.ex +++ b/lib/graphql/resolver.ex @@ -508,12 +508,15 @@ defmodule AshGraphql.Graphql.Resolver do def resolve( %{arguments: _arguments, context: context, root_value: data} = resolution, - {domain, resource, %AshGraphql.Resource.Subscription{}, _input?} + {domain, resource, %AshGraphql.Resource.Subscription{read_action: read_action}, _input?} ) do + read_action = + read_action || Ash.Resource.Info.primary_action!(resource, :read).name + query = AshGraphql.Subscription.query_for_subscription( resource - |> Ash.Query.for_read(:read, %{}, actor: Map.get(context, :actor)), + |> Ash.Query.for_read(read_action, %{}, actor: Map.get(context, :actor)), domain, resolution ) diff --git a/lib/resource/subscription.ex b/lib/resource/subscription.ex index f87f18c6..b8b25acf 100644 --- a/lib/resource/subscription.ex +++ b/lib/resource/subscription.ex @@ -10,6 +10,10 @@ defmodule AshGraphql.Resource.Subscription do name: [ type: :atom, doc: "The name to use for the subscription." + ], + read_action: [ + type: :atom, + doc: "The read action to use for reading data" ] ] diff --git a/lib/resource/subscription/default_config.ex b/lib/resource/subscription/default_config.ex index cc7f70b0..733d44df 100644 --- a/lib/resource/subscription/default_config.ex +++ b/lib/resource/subscription/default_config.ex @@ -29,7 +29,7 @@ defmodule AshGraphql.Resource.Subscription.DefaultConfig do dbg(filter) {:ok, topic: "*", context_id: dbg(Base.encode64(:erlang.term_to_binary(filter)))} - e -> + _ -> {:error, "unauthorized"} end end From 6eaf388dabe17ccf85f0c88d71dddab553f61661 Mon Sep 17 00:00:00 2001 From: Barnabas Jovanovics Date: Mon, 5 Aug 2024 16:35:00 +0200 Subject: [PATCH 19/48] only listen to the configured actions --- lib/resource/subscription.ex | 6 +++++- lib/resource/subscription/notifier.ex | 5 ++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/lib/resource/subscription.ex b/lib/resource/subscription.ex index b8b25acf..8404dca6 100644 --- a/lib/resource/subscription.ex +++ b/lib/resource/subscription.ex @@ -2,7 +2,7 @@ defmodule AshGraphql.Resource.Subscription do @moduledoc "Represents a configured query on a resource" defstruct [ :name, - :config, + :actions, :read_action ] @@ -11,6 +11,10 @@ defmodule AshGraphql.Resource.Subscription do type: :atom, doc: "The name to use for the subscription." ], + actions: [ + type: {:or, [{:list, :atom}, :atom]}, + doc: "The create/update/destroy actions the subsciption should listen to. Defaults to all." + ], read_action: [ type: :atom, doc: "The read action to use for reading data" diff --git a/lib/resource/subscription/notifier.ex b/lib/resource/subscription/notifier.ex index 1404eecc..5e73a7e4 100644 --- a/lib/resource/subscription/notifier.ex +++ b/lib/resource/subscription/notifier.ex @@ -7,7 +7,10 @@ defmodule AshGraphql.Resource.Subscription.Notifier do pub_sub = Info.subscription_pubsub(notification.resource) for subscription <- AshGraphql.Resource.Info.subscriptions(notification.resource) do - Absinthe.Subscription.publish(pub_sub, notification.data, [{subscription.name, "*"}]) + if is_nil(subscription.actions) or + notification.action.name in List.wrap(subscription.actions) do + Absinthe.Subscription.publish(pub_sub, notification.data, [{subscription.name, "*"}]) + end end end end From 4afd8ea9798e13f19df1eb224a82636aec6902b5 Mon Sep 17 00:00:00 2001 From: Barnabas Jovanovics Date: Wed, 7 Aug 2024 11:30:57 +0200 Subject: [PATCH 20/48] do some renaming --- lib/graphql/resolver.ex | 4 +++- lib/resource/resource.ex | 2 +- lib/resource/subscription.ex | 9 ++++++++- lib/resource/subscription/actor.ex | 3 +++ lib/resource/transformers/subscription.ex | 2 +- .../default_config.ex => subscription/config.ex} | 8 +++++--- lib/{ => subscription}/endpoint.ex | 6 ++++-- lib/{resource => }/subscription/notifier.ex | 6 ++++-- 8 files changed, 29 insertions(+), 11 deletions(-) create mode 100644 lib/resource/subscription/actor.ex rename lib/{resource/subscription/default_config.ex => subscription/config.ex} (85%) rename lib/{ => subscription}/endpoint.ex (94%) rename lib/{resource => }/subscription/notifier.ex (69%) diff --git a/lib/graphql/resolver.ex b/lib/graphql/resolver.ex index c4278041..c86a9db7 100644 --- a/lib/graphql/resolver.ex +++ b/lib/graphql/resolver.ex @@ -528,7 +528,9 @@ defmodule AshGraphql.Graphql.Resolver do Ash.Query.filter(query, ^ref(key) == ^value) end) - case query |> domain.read_one() do + dbg() + + case query |> Ash.read_one() do # should only happen if a resource is created/updated and the subscribed user is not allowed to see it {:ok, nil} -> resolution diff --git a/lib/resource/resource.ex b/lib/resource/resource.ex index 1b9a45a3..4f915224 100644 --- a/lib/resource/resource.ex +++ b/lib/resource/resource.ex @@ -1169,7 +1169,7 @@ defmodule AshGraphql.Resource do identifier: name, name: to_string(name), config: - AshGraphql.Resource.Subscription.DefaultConfig.create_config( + AshGraphql.Subscription.Config.create_config( subscription, api, resource diff --git a/lib/resource/subscription.ex b/lib/resource/subscription.ex index 8404dca6..ac90a9d8 100644 --- a/lib/resource/subscription.ex +++ b/lib/resource/subscription.ex @@ -3,7 +3,8 @@ defmodule AshGraphql.Resource.Subscription do defstruct [ :name, :actions, - :read_action + :read_action, + :actor ] @subscription_schema [ @@ -11,6 +12,12 @@ defmodule AshGraphql.Resource.Subscription do type: :atom, doc: "The name to use for the subscription." ], + actor: [ + type: + {:spark_function_behaviour, AshGraphql.Resource.Subscription.Actor, + {AshGraphql.Resource.Subscription.Actor, 1}}, + doc: "The actor to use for authorization." + ], actions: [ type: {:or, [{:list, :atom}, :atom]}, doc: "The create/update/destroy actions the subsciption should listen to. Defaults to all." diff --git a/lib/resource/subscription/actor.ex b/lib/resource/subscription/actor.ex new file mode 100644 index 00000000..c6f04770 --- /dev/null +++ b/lib/resource/subscription/actor.ex @@ -0,0 +1,3 @@ +defmodule AshGraphql.Resource.Subscription.Actor do + @callback author(actor :: any) :: actor :: any +end diff --git a/lib/resource/transformers/subscription.ex b/lib/resource/transformers/subscription.ex index d02757cb..74a28140 100644 --- a/lib/resource/transformers/subscription.ex +++ b/lib/resource/transformers/subscription.ex @@ -18,7 +18,7 @@ defmodule AshGraphql.Resource.Transformers.Subscription do |> Transformer.persist( :simple_notifiers, [ - AshGraphql.Resource.Subscription.Notifier + AshGraphql.Subscription.Notifier ] ++ Transformer.get_persisted(dsl, :simple_notifiers, []) )} diff --git a/lib/resource/subscription/default_config.ex b/lib/subscription/config.ex similarity index 85% rename from lib/resource/subscription/default_config.ex rename to lib/subscription/config.ex index 733d44df..0f0c025f 100644 --- a/lib/resource/subscription/default_config.ex +++ b/lib/subscription/config.ex @@ -1,4 +1,4 @@ -defmodule AshGraphql.Resource.Subscription.DefaultConfig do +defmodule AshGraphql.Subscription.Config do alias AshGraphql.Resource.Subscription def create_config(%Subscription{} = subscription, _domain, resource) do @@ -26,8 +26,10 @@ defmodule AshGraphql.Resource.Subscription.DefaultConfig do # context_id is exposed to the client so we might need to encrypt it # or save it in ets or something and send generate a hash or something # as the context_id - dbg(filter) - {:ok, topic: "*", context_id: dbg(Base.encode64(:erlang.term_to_binary(filter)))} + dbg(filter, structs: false) + + {:ok, + topic: "*", context_id: dbg(Base.encode64(:erlang.term_to_binary(filter.filter)))} _ -> {:error, "unauthorized"} diff --git a/lib/endpoint.ex b/lib/subscription/endpoint.ex similarity index 94% rename from lib/endpoint.ex rename to lib/subscription/endpoint.ex index 396ba984..834565a6 100644 --- a/lib/endpoint.ex +++ b/lib/subscription/endpoint.ex @@ -1,4 +1,4 @@ -defmodule AshGraphql.Endpoint do +defmodule AshGraphql.Subscription.Endpoint do defmacro __using__(_opts) do quote do use Absinthe.Phoenix.Endpoint @@ -8,10 +8,12 @@ defmodule AshGraphql.Endpoint do require Logger def run_docset(pubsub, docs_and_topics, mutation_result) do + dbg(mutation_result, structs: false) + for {topic, key_strategy, doc} <- docs_and_topics do try do pipeline = - Absinthe.Subscription.Local.pipeline(doc, mutation_result) + Absinthe.Subscription.Local.pipeline(doc, mutation_result.data) # why though? |> List.flatten() |> Absinthe.Pipeline.insert_before( diff --git a/lib/resource/subscription/notifier.ex b/lib/subscription/notifier.ex similarity index 69% rename from lib/resource/subscription/notifier.ex rename to lib/subscription/notifier.ex index 5e73a7e4..ed039988 100644 --- a/lib/resource/subscription/notifier.ex +++ b/lib/subscription/notifier.ex @@ -1,4 +1,4 @@ -defmodule AshGraphql.Resource.Subscription.Notifier do +defmodule AshGraphql.Subscription.Notifier do alias AshGraphql.Resource.Info use Ash.Notifier @@ -6,10 +6,12 @@ defmodule AshGraphql.Resource.Subscription.Notifier do def notify(notification) do pub_sub = Info.subscription_pubsub(notification.resource) + dbg(notification, structs: false) + for subscription <- AshGraphql.Resource.Info.subscriptions(notification.resource) do if is_nil(subscription.actions) or notification.action.name in List.wrap(subscription.actions) do - Absinthe.Subscription.publish(pub_sub, notification.data, [{subscription.name, "*"}]) + Absinthe.Subscription.publish(pub_sub, notification, [{subscription.name, "*"}]) end end end From 4aa4fc529c62e313d358d904151d0b1f293ee8b8 Mon Sep 17 00:00:00 2001 From: Barnabas Jovanovics Date: Wed, 7 Aug 2024 12:04:33 +0200 Subject: [PATCH 21/48] add actor function --- lib/graphql/resolver.ex | 7 ++++--- lib/subscription/config.ex | 36 ++++++++++++++++++++++++------------ lib/subscription/endpoint.ex | 15 ++++++--------- 3 files changed, 34 insertions(+), 24 deletions(-) diff --git a/lib/graphql/resolver.ex b/lib/graphql/resolver.ex index c86a9db7..af2624d7 100644 --- a/lib/graphql/resolver.ex +++ b/lib/graphql/resolver.ex @@ -516,7 +516,10 @@ defmodule AshGraphql.Graphql.Resolver do query = AshGraphql.Subscription.query_for_subscription( resource - |> Ash.Query.for_read(read_action, %{}, actor: Map.get(context, :actor)), + |> Ash.Query.for_read(read_action, %{}, + actor: Map.get(context, :actor), + tenant: Map.get(context, :tenant) + ), domain, resolution ) @@ -528,8 +531,6 @@ defmodule AshGraphql.Graphql.Resolver do Ash.Query.filter(query, ^ref(key) == ^value) end) - dbg() - case query |> Ash.read_one() do # should only happen if a resource is created/updated and the subscribed user is not allowed to see it {:ok, nil} -> diff --git a/lib/subscription/config.ex b/lib/subscription/config.ex index 0f0c025f..513531b1 100644 --- a/lib/subscription/config.ex +++ b/lib/subscription/config.ex @@ -9,32 +9,44 @@ defmodule AshGraphql.Subscription.Config do @subscription subscription @resource resource - def config(_args, %{context: context}) do + def config(args, %{context: context}) do read_action = @subscription.read_action || Ash.Resource.Info.primary_action!(@resource, :read).name + actor = + if is_function(@subscription.actor) do + @subscription.actor.(context[:actor]) + else + context[:actor] + end + + # check with Ash.can? to make sure the user is able to read the resource + # otherwise we return an error here instead of just never sending something + # in the subscription case Ash.can( - Ash.Query.for_read(@resource, read_action), - context[:actor], + @resource + |> Ash.Query.new() + |> Ash.Query.set_tenant(context[:tenant]) + |> Ash.Query.for_read(read_action), + actor, + tenant: context[:tenant], run_queries?: false, alter_source?: true ) do {:ok, true} -> - {:ok, topic: "*", context_id: "global"} - - {:ok, true, filter} -> - # context_id is exposed to the client so we might need to encrypt it - # or save it in ets or something and send generate a hash or something - # as the context_id - dbg(filter, structs: false) + {:ok, topic: "*", context_id: create_context_id(args, actor, context[:tenant])} - {:ok, - topic: "*", context_id: dbg(Base.encode64(:erlang.term_to_binary(filter.filter)))} + {:ok, true, _} -> + {:ok, topic: "*", context_id: create_context_id(args, actor, context[:tenant])} _ -> {:error, "unauthorized"} end end + + def create_context_id(args, actor, tenant) do + Base.encode64(:crypto.hash(:sha256, :erlang.term_to_binary({args, actor, tenant}))) + end end &config_module.config/2 diff --git a/lib/subscription/endpoint.ex b/lib/subscription/endpoint.ex index 834565a6..dc6ebba9 100644 --- a/lib/subscription/endpoint.ex +++ b/lib/subscription/endpoint.ex @@ -14,12 +14,6 @@ defmodule AshGraphql.Subscription.Endpoint do try do pipeline = Absinthe.Subscription.Local.pipeline(doc, mutation_result.data) - # why though? - |> List.flatten() - |> Absinthe.Pipeline.insert_before( - Absinthe.Phase.Document.OverrideRoot, - {Absinthe.Phase.Document.Context, context: %{ash_filter: get_filter(topic)}} - ) {:ok, %{result: data}, _} = Absinthe.Pipeline.run(doc.source, pipeline) @@ -30,9 +24,8 @@ defmodule AshGraphql.Subscription.Endpoint do Data: #{inspect(data)} """) - case is_forbidden(data) do + case should_send?(data) do true -> - # do not send anything to the client if he is not allowed to see it :ok false -> @@ -45,7 +38,11 @@ defmodule AshGraphql.Subscription.Endpoint do end end - defp is_forbidden(%{errors: errors}) do + defp should_send?(%{errors: errors}) do + # if the user is not allowed to see the data or the query didn't + # return any data we do not send the error to the client + # because it would just expose unnecessary information + # and the user can not really do anything usefull with it errors |> List.wrap() |> Enum.any?(fn error -> Map.get(error, :code) in ["forbidden", "not_found"] end) From 2d6501bbae8153d01a7c7c6676a8ee0e43f3c812 Mon Sep 17 00:00:00 2001 From: Barnabas Jovanovics Date: Wed, 7 Aug 2024 12:37:02 +0200 Subject: [PATCH 22/48] handle filter --- lib/graphql/resolver.ex | 30 ++++++++++++++++++------------ lib/resource/resource.ex | 4 +--- lib/resource/subscription/actor.ex | 2 ++ lib/subscription/config.ex | 6 ++++++ lib/subscription/endpoint.ex | 14 ++++++-------- lib/subscription/notifier.ex | 2 -- 6 files changed, 33 insertions(+), 25 deletions(-) diff --git a/lib/graphql/resolver.ex b/lib/graphql/resolver.ex index af2624d7..92474d82 100644 --- a/lib/graphql/resolver.ex +++ b/lib/graphql/resolver.ex @@ -507,15 +507,28 @@ defmodule AshGraphql.Graphql.Resolver do end def resolve( - %{arguments: _arguments, context: context, root_value: data} = resolution, + %{arguments: arguments, context: context, root_value: notification} = resolution, {domain, resource, %AshGraphql.Resource.Subscription{read_action: read_action}, _input?} ) do + dbg(NOTIFICATION: notification) + data = notification.data + read_action = read_action || Ash.Resource.Info.primary_action!(resource, :read).name + query = + Ash.Resource.Info.primary_key(resource) + |> Enum.reduce(resource, fn key, query -> + value = Map.get(data, key) + Ash.Query.filter(query, ^ref(key) == ^value) + end) + + query = + Ash.Query.do_filter(query, massage_filter(query.resource, Map.get(arguments, :filter))) + query = AshGraphql.Subscription.query_for_subscription( - resource + query |> Ash.Query.for_read(read_action, %{}, actor: Map.get(context, :actor), tenant: Map.get(context, :tenant) @@ -524,13 +537,6 @@ defmodule AshGraphql.Graphql.Resolver do resolution ) - query = - Ash.Resource.Info.primary_key(resource) - |> Enum.reduce(query, fn key, query -> - value = Map.get(data, key) - Ash.Query.filter(query, ^ref(key) == ^value) - end) - case query |> Ash.read_one() do # should only happen if a resource is created/updated and the subscribed user is not allowed to see it {:ok, nil} -> @@ -1641,9 +1647,9 @@ defmodule AshGraphql.Graphql.Resolver do end)} end - defp massage_filter(_resource, nil), do: nil + def massage_filter(_resource, nil), do: nil - defp massage_filter(resource, filter) when is_map(filter) do + def massage_filter(resource, filter) when is_map(filter) do Map.new(filter, fn {key, value} -> cond do rel = Ash.Resource.Info.relationship(resource, key) -> @@ -1658,7 +1664,7 @@ defmodule AshGraphql.Graphql.Resolver do end) end - defp massage_filter(_resource, other), do: other + def massage_filter(_resource, other), do: other defp calc_input(key, value) do case Map.fetch(value, :input) do diff --git a/lib/resource/resource.ex b/lib/resource/resource.ex index 4f915224..38137bb7 100644 --- a/lib/resource/resource.ex +++ b/lib/resource/resource.ex @@ -1163,9 +1163,7 @@ defmodule AshGraphql.Resource do |> subscriptions() |> Enum.map(fn %Subscription{name: name} = subscription -> %Absinthe.Blueprint.Schema.FieldDefinition{ - arguments: - args(:subscription, resource, nil, schema, nil) - |> IO.inspect(label: "args"), + arguments: args(:subscription, resource, nil, schema, nil), identifier: name, name: to_string(name), config: diff --git a/lib/resource/subscription/actor.ex b/lib/resource/subscription/actor.ex index c6f04770..9f46084d 100644 --- a/lib/resource/subscription/actor.ex +++ b/lib/resource/subscription/actor.ex @@ -1,3 +1,5 @@ defmodule AshGraphql.Resource.Subscription.Actor do + # I'd like to have the typespsay that actor can be anything + # but that the input and output must be the same @callback author(actor :: any) :: actor :: any end diff --git a/lib/subscription/config.ex b/lib/subscription/config.ex index 513531b1..707e057a 100644 --- a/lib/subscription/config.ex +++ b/lib/subscription/config.ex @@ -15,6 +15,8 @@ defmodule AshGraphql.Subscription.Config do actor = if is_function(@subscription.actor) do + # might be nice to also pass in the subscription, that way you could potentially + # deduplicate on an action basis as well if you wanted to @subscription.actor.(context[:actor]) else context[:actor] @@ -26,6 +28,10 @@ defmodule AshGraphql.Subscription.Config do case Ash.can( @resource |> Ash.Query.new() + # not sure if we need this here + |> Ash.Query.do_filter( + AshGraphql.Graphql.Resolver.massage_filter(@resource, Map.get(args, :filter)) + ) |> Ash.Query.set_tenant(context[:tenant]) |> Ash.Query.for_read(read_action), actor, diff --git a/lib/subscription/endpoint.ex b/lib/subscription/endpoint.ex index dc6ebba9..db6fc3ec 100644 --- a/lib/subscription/endpoint.ex +++ b/lib/subscription/endpoint.ex @@ -7,13 +7,11 @@ defmodule AshGraphql.Subscription.Endpoint do require Logger - def run_docset(pubsub, docs_and_topics, mutation_result) do - dbg(mutation_result, structs: false) - + def run_docset(pubsub, docs_and_topics, notification) do for {topic, key_strategy, doc} <- docs_and_topics do try do pipeline = - Absinthe.Subscription.Local.pipeline(doc, mutation_result.data) + Absinthe.Subscription.Local.pipeline(doc, notification) {:ok, %{result: data}, _} = Absinthe.Pipeline.run(doc.source, pipeline) @@ -43,12 +41,12 @@ defmodule AshGraphql.Subscription.Endpoint do # return any data we do not send the error to the client # because it would just expose unnecessary information # and the user can not really do anything usefull with it - errors - |> List.wrap() - |> Enum.any?(fn error -> Map.get(error, :code) in ["forbidden", "not_found"] end) + not (errors + |> List.wrap() + |> Enum.any?(fn error -> Map.get(error, :code) in ["forbidden", "not_found"] end)) end - defp is_forbidden(_), do: false + defp should_send?(_), do: true defp get_filter(topic) do [_, rest] = String.split(topic, "__absinthe__:doc:") diff --git a/lib/subscription/notifier.ex b/lib/subscription/notifier.ex index ed039988..0644f3f0 100644 --- a/lib/subscription/notifier.ex +++ b/lib/subscription/notifier.ex @@ -6,8 +6,6 @@ defmodule AshGraphql.Subscription.Notifier do def notify(notification) do pub_sub = Info.subscription_pubsub(notification.resource) - dbg(notification, structs: false) - for subscription <- AshGraphql.Resource.Info.subscriptions(notification.resource) do if is_nil(subscription.actions) or notification.action.name in List.wrap(subscription.actions) do From 6b2addde77ea5a00b300bf13705bf41116b7ef59 Mon Sep 17 00:00:00 2001 From: Barnabas Jovanovics Date: Wed, 7 Aug 2024 14:35:48 +0200 Subject: [PATCH 23/48] wip: add subscription result type --- lib/domain/domain.ex | 3 ++- lib/graphql/resolver.ex | 34 ++++++++++++++++++++++++---- lib/resource/resource.ex | 44 +++++++++++++++++++++++++++++++++++- lib/subscription/endpoint.ex | 6 +++-- lib/subscriptions.ex | 15 ++++++++++-- 5 files changed, 91 insertions(+), 11 deletions(-) diff --git a/lib/domain/domain.ex b/lib/domain/domain.ex index c8e0c0e7..7fe8cb5a 100644 --- a/lib/domain/domain.ex +++ b/lib/domain/domain.ex @@ -235,7 +235,8 @@ defmodule AshGraphql.Domain do |> Enum.flat_map(fn resource -> if AshGraphql.Resource in Spark.extensions(resource) do AshGraphql.Resource.type_definitions(resource, domain, all_domains, schema, relay_ids?) ++ - AshGraphql.Resource.mutation_types(resource, all_domains, schema) + AshGraphql.Resource.mutation_types(resource, all_domains, schema) ++ + AshGraphql.Resource.subscription_types(resource, all_domains, schema) else AshGraphql.Resource.no_graphql_types(resource, schema) end diff --git a/lib/graphql/resolver.ex b/lib/graphql/resolver.ex index 92474d82..76e8d859 100644 --- a/lib/graphql/resolver.ex +++ b/lib/graphql/resolver.ex @@ -508,7 +508,9 @@ defmodule AshGraphql.Graphql.Resolver do def resolve( %{arguments: arguments, context: context, root_value: notification} = resolution, - {domain, resource, %AshGraphql.Resource.Subscription{read_action: read_action}, _input?} + {domain, resource, + %AshGraphql.Resource.Subscription{read_action: read_action, name: name} = subscription, + _input?} ) do dbg(NOTIFICATION: notification) data = notification.data @@ -534,7 +536,9 @@ defmodule AshGraphql.Graphql.Resolver do tenant: Map.get(context, :tenant) ), domain, - resolution + resolution, + "#{name}_result", + [to_string(notification.action.type) <> "d"] ) case query |> Ash.read_one() do @@ -546,8 +550,12 @@ defmodule AshGraphql.Graphql.Resolver do ) {:ok, result} -> + dbg(result) + resolution - |> Absinthe.Resolution.put_result({:ok, result}) + |> Absinthe.Resolution.put_result( + {:ok, %{String.to_existing_atom(to_string(notification.action.type) <> "d") => result}} + ) {:error, error} -> resolution @@ -2260,6 +2268,10 @@ defmodule AshGraphql.Graphql.Resolver do String.to_atom("#{mutation_name}_result") end + defp subscription_result_type(subscription_name) do + String.to_atom("#{subscription_name}_result") + end + # sobelow_skip ["DOS.StringToAtom"] defp page_type(resource, strategy, relay?) do type = AshGraphql.Resource.Info.type(resource) @@ -2277,9 +2289,17 @@ defmodule AshGraphql.Graphql.Resolver do end @doc false - def select_fields(query_or_changeset, resource, resolution, type_override, nested \\ []) do + def select_fields( + query_or_changeset, + resource, + resolution, + type_override, + nested \\ [] + ) do subfields = get_select(resource, resolution, type_override, nested) + dbg(type_override: type_override, subfields: subfields, nested: nested) + case query_or_changeset do %Ash.Query{} = query -> query |> Ash.Query.select(subfields) @@ -2317,8 +2337,12 @@ defmodule AshGraphql.Graphql.Resolver do end defp fields(%Absinthe.Resolution{} = resolution, [], type) do + dbg("FIELDS") + dbg(type) + resolution - |> Absinthe.Resolution.project(type) + |> Absinthe.Resolution.project() + |> dbg() end defp fields(%Absinthe.Resolution{} = resolution, names, _type) do diff --git a/lib/resource/resource.ex b/lib/resource/resource.ex index 38137bb7..a0eddefd 100644 --- a/lib/resource/resource.ex +++ b/lib/resource/resource.ex @@ -1110,6 +1110,46 @@ defmodule AshGraphql.Resource do end) end + def subscription_types(resource, _all_domains, schema) do + resource + |> subscriptions() + |> Enum.map(fn %Subscription{name: name} -> + resource_type = AshGraphql.Resource.Info.type(resource) + + result_type = name |> to_string() |> then(&(&1 <> "_result")) |> String.to_atom() + + %Absinthe.Blueprint.Schema.ObjectTypeDefinition{ + module: schema, + identifier: result_type, + name: name |> to_string() |> then(&(&1 <> "_result")) |> Macro.camelize(), + fields: [ + %Absinthe.Blueprint.Schema.FieldDefinition{ + __reference__: ref(__ENV__), + identifier: :created, + module: schema, + name: "created", + type: resource_type + }, + %Absinthe.Blueprint.Schema.FieldDefinition{ + __reference__: ref(__ENV__), + identifier: :updated, + module: schema, + name: "updated", + type: resource_type + }, + %Absinthe.Blueprint.Schema.FieldDefinition{ + __reference__: ref(__ENV__), + identifier: :destroyed, + module: schema, + name: "destroyed", + type: resource_type + } + ], + __reference__: ref(__ENV__) + } + end) + end + defp id_translation_middleware(relay_id_translations, true) do [{{AshGraphql.Graphql.IdTranslator, :translate_relay_ids}, relay_id_translations}] end @@ -1162,6 +1202,8 @@ defmodule AshGraphql.Resource do resource |> subscriptions() |> Enum.map(fn %Subscription{name: name} = subscription -> + result_type = name |> to_string() |> then(&(&1 <> "_result")) |> String.to_atom() + %Absinthe.Blueprint.Schema.FieldDefinition{ arguments: args(:subscription, resource, nil, schema, nil), identifier: name, @@ -1178,7 +1220,7 @@ defmodule AshGraphql.Resource do [ {{AshGraphql.Graphql.Resolver, :resolve}, {api, resource, subscription, true}} ], - type: AshGraphql.Resource.Info.type(resource), + type: result_type, __reference__: ref(__ENV__) } end) diff --git a/lib/subscription/endpoint.ex b/lib/subscription/endpoint.ex index db6fc3ec..563e778d 100644 --- a/lib/subscription/endpoint.ex +++ b/lib/subscription/endpoint.ex @@ -22,11 +22,13 @@ defmodule AshGraphql.Subscription.Endpoint do Data: #{inspect(data)} """) + dbg(DATA: data) + case should_send?(data) do - true -> + false -> :ok - false -> + true -> :ok = pubsub.publish_subscription(topic, data) end rescue diff --git a/lib/subscriptions.ex b/lib/subscriptions.ex index 1bec15e5..1e8f935b 100644 --- a/lib/subscriptions.ex +++ b/lib/subscriptions.ex @@ -8,12 +8,23 @@ defmodule AshGraphql.Subscription do @doc """ Produce a query that will load the correct data for a subscription. """ - def query_for_subscription(query, domain, %{context: context} = resolution) do + def query_for_subscription( + query, + domain, + %{context: context} = resolution, + type_override \\ nil, + nested \\ [] + ) do query |> Ash.Query.new() |> Ash.Query.set_tenant(Map.get(context, :tenant)) |> Ash.Query.set_context(get_context(context)) - |> AshGraphql.Graphql.Resolver.select_fields(query.resource, resolution, nil) + |> AshGraphql.Graphql.Resolver.select_fields( + query.resource, + resolution, + type_override, + nested + ) |> AshGraphql.Graphql.Resolver.load_fields( [ domain: domain, From 16a2fffb8ed3a96cb595e08478b557d7a3a482dd Mon Sep 17 00:00:00 2001 From: Barnabas Jovanovics Date: Thu, 15 Aug 2024 15:15:47 +0200 Subject: [PATCH 24/48] use trace and handle arguments --- lib/graphql/resolver.ex | 120 +++++++++++++++++++++++++-------------- lib/resource/resource.ex | 13 ++++- 2 files changed, 89 insertions(+), 44 deletions(-) diff --git a/lib/graphql/resolver.ex b/lib/graphql/resolver.ex index 76e8d859..d776beac 100644 --- a/lib/graphql/resolver.ex +++ b/lib/graphql/resolver.ex @@ -507,62 +507,98 @@ defmodule AshGraphql.Graphql.Resolver do end def resolve( - %{arguments: arguments, context: context, root_value: notification} = resolution, + %{arguments: args, context: context, root_value: notification} = resolution, {domain, resource, - %AshGraphql.Resource.Subscription{read_action: read_action, name: name} = subscription, - _input?} + %AshGraphql.Resource.Subscription{read_action: read_action, name: name}, _input?} ) do - dbg(NOTIFICATION: notification) - data = notification.data + case handle_arguments(resource, read_action, args) do + {:ok, args} -> + metadata = %{ + domain: domain, + resource: resource, + resource_short_name: Ash.Resource.Info.short_name(resource), + actor: Map.get(context, :actor), + tenant: Map.get(context, :tenant), + action: read_action, + source: :graphql, + subscription: name, + authorize?: AshGraphql.Domain.Info.authorize?(domain) + } - read_action = - read_action || Ash.Resource.Info.primary_action!(resource, :read).name + trace domain, + resource, + :gql_subscription, + name, + metadata do + opts = [ + actor: Map.get(context, :actor), + action: read_action, + authorize?: AshGraphql.Domain.Info.authorize?(domain), + tenant: Map.get(context, :tenant) + ] - query = - Ash.Resource.Info.primary_key(resource) - |> Enum.reduce(resource, fn key, query -> - value = Map.get(data, key) - Ash.Query.filter(query, ^ref(key) == ^value) - end) + data = notification.data - query = - Ash.Query.do_filter(query, massage_filter(query.resource, Map.get(arguments, :filter))) + read_action = + read_action || Ash.Resource.Info.primary_action!(resource, :read).name - query = - AshGraphql.Subscription.query_for_subscription( - query - |> Ash.Query.for_read(read_action, %{}, - actor: Map.get(context, :actor), - tenant: Map.get(context, :tenant) - ), - domain, - resolution, - "#{name}_result", - [to_string(notification.action.type) <> "d"] - ) + query = + Ash.Resource.Info.primary_key(resource) + |> Enum.reduce(resource, fn key, query -> + value = Map.get(data, key) + Ash.Query.filter(query, ^ref(key) == ^value) + end) - case query |> Ash.read_one() do - # should only happen if a resource is created/updated and the subscribed user is not allowed to see it - {:ok, nil} -> - resolution - |> Absinthe.Resolution.put_result( - {:error, to_errors([Ash.Error.Query.NotFound.exception()], context, domain)} - ) + query = + Ash.Query.do_filter( + query, + massage_filter(query.resource, Map.get(args, :filter)) + ) - {:ok, result} -> - dbg(result) + query = + AshGraphql.Subscription.query_for_subscription( + query + |> Ash.Query.for_read(read_action, %{}, opts), + domain, + resolution, + "#{name}_result", + [subcription_field_from_action_type(notification.action.type)] + ) - resolution - |> Absinthe.Resolution.put_result( - {:ok, %{String.to_existing_atom(to_string(notification.action.type) <> "d") => result}} - ) + case query |> Ash.read_one() do + # should only happen if a resource is created/updated and the subscribed user is not allowed to see it + {:ok, nil} -> + resolution + |> Absinthe.Resolution.put_result( + {:error, to_errors([Ash.Error.Query.NotFound.exception()], context, domain)} + ) + + {:ok, result} -> + resolution + |> Absinthe.Resolution.put_result( + {:ok, + %{ + String.to_existing_atom( + subcription_field_from_action_type(notification.action.type) + ) => result + }} + ) + + {:error, error} -> + resolution + |> Absinthe.Resolution.put_result({:error, to_errors([error], context, domain)}) + end + end {:error, error} -> - resolution - |> Absinthe.Resolution.put_result({:error, to_errors([error], context, domain)}) + {:error, error} end end + defp subcription_field_from_action_type(:create), do: "created" + defp subcription_field_from_action_type(:update), do: "updated" + defp subcription_field_from_action_type(:destroy), do: "destroyed" + defp read_one_query(resource, args) do case Map.fetch(args, :filter) do {:ok, filter} when filter != %{} -> diff --git a/lib/resource/resource.ex b/lib/resource/resource.ex index a0eddefd..c5dc9109 100644 --- a/lib/resource/resource.ex +++ b/lib/resource/resource.ex @@ -1110,18 +1110,27 @@ defmodule AshGraphql.Resource do end) end + @doc false + # sobelow_skip ["DOS.StringToAtom"] def subscription_types(resource, _all_domains, schema) do resource |> subscriptions() |> Enum.map(fn %Subscription{name: name} -> resource_type = AshGraphql.Resource.Info.type(resource) - result_type = name |> to_string() |> then(&(&1 <> "_result")) |> String.to_atom() + result_type_name = + name + |> to_string() + |> then(&(&1 <> "_result")) + + result_type = + result_type_name + |> String.to_atom() %Absinthe.Blueprint.Schema.ObjectTypeDefinition{ module: schema, identifier: result_type, - name: name |> to_string() |> then(&(&1 <> "_result")) |> Macro.camelize(), + name: result_type_name, fields: [ %Absinthe.Blueprint.Schema.FieldDefinition{ __reference__: ref(__ENV__), From 772a054a1d1ef6f22874614ad3e39a2757d2866f Mon Sep 17 00:00:00 2001 From: Barnabas Jovanovics Date: Thu, 15 Aug 2024 15:51:02 +0200 Subject: [PATCH 25/48] clean up part 1 --- lib/ash_graphql.ex | 4 ---- lib/domain/domain.ex | 1 - lib/graphql/resolver.ex | 11 +++-------- lib/resource/info.ex | 1 + lib/resource/resource.ex | 5 ++++- lib/resource/subscription.ex | 2 +- lib/resource/subscription/actor.ex | 4 ++-- lib/resource/subscription/actor_function.ex | 15 +++++++++++++++ lib/subscription/config.ex | 19 ++++++++++--------- lib/subscription/endpoint.ex | 2 -- 10 files changed, 36 insertions(+), 28 deletions(-) create mode 100644 lib/resource/subscription/actor_function.ex diff --git a/lib/ash_graphql.ex b/lib/ash_graphql.ex index 9f8c5b42..2bb78513 100644 --- a/lib/ash_graphql.ex +++ b/lib/ash_graphql.ex @@ -157,10 +157,6 @@ defmodule AshGraphql do @dialyzer {:nowarn_function, {:run, 2}} def run(blueprint, _opts) do domain = unquote(domain) - # IO.inspect( - # blueprint.schema_definitions - # |> Enum.find(&(&1.name == "RootSubscriptionType").fields) - # ) action_middleware = unquote(action_middleware) diff --git a/lib/domain/domain.ex b/lib/domain/domain.ex index 7fe8cb5a..17c0f409 100644 --- a/lib/domain/domain.ex +++ b/lib/domain/domain.ex @@ -211,7 +211,6 @@ defmodule AshGraphql.Domain do def subscriptions(api, resources, action_middleware, schema) do resources - |> IO.inspect(label: :subscriptions) |> Enum.filter(fn resource -> AshGraphql.Resource in Spark.extensions(resource) end) diff --git a/lib/graphql/resolver.ex b/lib/graphql/resolver.ex index d776beac..89819394 100644 --- a/lib/graphql/resolver.ex +++ b/lib/graphql/resolver.ex @@ -561,7 +561,7 @@ defmodule AshGraphql.Graphql.Resolver do |> Ash.Query.for_read(read_action, %{}, opts), domain, resolution, - "#{name}_result", + subscription_result_type(name), [subcription_field_from_action_type(notification.action.type)] ) @@ -2304,6 +2304,7 @@ defmodule AshGraphql.Graphql.Resolver do String.to_atom("#{mutation_name}_result") end + # sobelow_skip ["DOS.StringToAtom"] defp subscription_result_type(subscription_name) do String.to_atom("#{subscription_name}_result") end @@ -2334,8 +2335,6 @@ defmodule AshGraphql.Graphql.Resolver do ) do subfields = get_select(resource, resolution, type_override, nested) - dbg(type_override: type_override, subfields: subfields, nested: nested) - case query_or_changeset do %Ash.Query{} = query -> query |> Ash.Query.select(subfields) @@ -2373,12 +2372,8 @@ defmodule AshGraphql.Graphql.Resolver do end defp fields(%Absinthe.Resolution{} = resolution, [], type) do - dbg("FIELDS") - dbg(type) - resolution - |> Absinthe.Resolution.project() - |> dbg() + |> Absinthe.Resolution.project(type) end defp fields(%Absinthe.Resolution{} = resolution, names, _type) do diff --git a/lib/resource/info.ex b/lib/resource/info.ex index 835d18e2..af105627 100644 --- a/lib/resource/info.ex +++ b/lib/resource/info.ex @@ -40,6 +40,7 @@ defmodule AshGraphql.Resource.Info do Extension.get_entities(resource, [:graphql, :subscriptions]) || [] end + @doc "The pubsub module used for subscriptions" def subscription_pubsub(resource) do Extension.get_opt(resource, [:graphql, :subscriptions], :pubsub) end diff --git a/lib/resource/resource.ex b/lib/resource/resource.ex index c5dc9109..dfc47b37 100644 --- a/lib/resource/resource.ex +++ b/lib/resource/resource.ex @@ -298,7 +298,10 @@ defmodule AshGraphql.Resource do examples: [ """ subscriptions do - subscribe :subscription_name, fn notifications -> ... end + subscribe :bucket_created do + actions :create + read_action :read + end end """ ], diff --git a/lib/resource/subscription.ex b/lib/resource/subscription.ex index ac90a9d8..36857da9 100644 --- a/lib/resource/subscription.ex +++ b/lib/resource/subscription.ex @@ -15,7 +15,7 @@ defmodule AshGraphql.Resource.Subscription do actor: [ type: {:spark_function_behaviour, AshGraphql.Resource.Subscription.Actor, - {AshGraphql.Resource.Subscription.Actor, 1}}, + {AshGraphql.Resource.Subscription.ActorFunction, 1}}, doc: "The actor to use for authorization." ], actions: [ diff --git a/lib/resource/subscription/actor.ex b/lib/resource/subscription/actor.ex index 9f46084d..779a1746 100644 --- a/lib/resource/subscription/actor.ex +++ b/lib/resource/subscription/actor.ex @@ -1,5 +1,5 @@ defmodule AshGraphql.Resource.Subscription.Actor do - # I'd like to have the typespsay that actor can be anything + # I'd like to have the typesp say that actor can be anything # but that the input and output must be the same - @callback author(actor :: any) :: actor :: any + @callback actor(actor :: any, opts :: Keyword.t()) :: actor :: any end diff --git a/lib/resource/subscription/actor_function.ex b/lib/resource/subscription/actor_function.ex new file mode 100644 index 00000000..ed3ceaff --- /dev/null +++ b/lib/resource/subscription/actor_function.ex @@ -0,0 +1,15 @@ +defmodule AshGraphql.Resource.Subscription.ActorFunction do + @moduledoc false + + @behaviour AshGraphql.Resource.Subscription.Actor + + @impl true + def actor(actor, [{:fun, {m, f, a}}]) do + apply(m, f, [actor | a]) + end + + @impl true + def actor(actor, [{:fun, fun}]) do + fun.(actor) + end +end diff --git a/lib/subscription/config.ex b/lib/subscription/config.ex index 707e057a..a732bdf0 100644 --- a/lib/subscription/config.ex +++ b/lib/subscription/config.ex @@ -6,6 +6,7 @@ defmodule AshGraphql.Subscription.Config do defmodule config_module do require Ash.Query + alias AshGraphql.Graphql.Resolver @subscription subscription @resource resource @@ -13,13 +14,15 @@ defmodule AshGraphql.Subscription.Config do read_action = @subscription.read_action || Ash.Resource.Info.primary_action!(@resource, :read).name + dbg(@subscription) + actor = - if is_function(@subscription.actor) do - # might be nice to also pass in the subscription, that way you could potentially - # deduplicate on an action basis as well if you wanted to - @subscription.actor.(context[:actor]) - else - context[:actor] + case @subscription.actor do + {module, opts} -> + module.actor(context[:actor], opts) + + _ -> + context[:actor] end # check with Ash.can? to make sure the user is able to read the resource @@ -29,9 +32,7 @@ defmodule AshGraphql.Subscription.Config do @resource |> Ash.Query.new() # not sure if we need this here - |> Ash.Query.do_filter( - AshGraphql.Graphql.Resolver.massage_filter(@resource, Map.get(args, :filter)) - ) + |> Ash.Query.do_filter(Resolver.massage_filter(@resource, Map.get(args, :filter))) |> Ash.Query.set_tenant(context[:tenant]) |> Ash.Query.for_read(read_action), actor, diff --git a/lib/subscription/endpoint.ex b/lib/subscription/endpoint.ex index 563e778d..cda2e1ad 100644 --- a/lib/subscription/endpoint.ex +++ b/lib/subscription/endpoint.ex @@ -22,8 +22,6 @@ defmodule AshGraphql.Subscription.Endpoint do Data: #{inspect(data)} """) - dbg(DATA: data) - case should_send?(data) do false -> :ok From 617b625d596fc90ec5d63755769bff47c966476b Mon Sep 17 00:00:00 2001 From: Barnabas Jovanovics Date: Thu, 15 Aug 2024 16:04:56 +0200 Subject: [PATCH 26/48] handle destroys --- lib/graphql/resolver.ex | 86 +++++++++++++++++++++++----------------- lib/resource/resource.ex | 2 +- 2 files changed, 51 insertions(+), 37 deletions(-) diff --git a/lib/graphql/resolver.ex b/lib/graphql/resolver.ex index 89819394..75253a3c 100644 --- a/lib/graphql/resolver.ex +++ b/lib/graphql/resolver.ex @@ -537,56 +537,70 @@ defmodule AshGraphql.Graphql.Resolver do tenant: Map.get(context, :tenant) ] - data = notification.data + cond do + notification.action.type in [:create, :update] -> + data = notification.data - read_action = - read_action || Ash.Resource.Info.primary_action!(resource, :read).name + read_action = + read_action || Ash.Resource.Info.primary_action!(resource, :read).name - query = - Ash.Resource.Info.primary_key(resource) - |> Enum.reduce(resource, fn key, query -> - value = Map.get(data, key) - Ash.Query.filter(query, ^ref(key) == ^value) - end) + query = + Ash.Resource.Info.primary_key(resource) + |> Enum.reduce(resource, fn key, query -> + value = Map.get(data, key) + Ash.Query.filter(query, ^ref(key) == ^value) + end) - query = - Ash.Query.do_filter( - query, - massage_filter(query.resource, Map.get(args, :filter)) - ) + query = + Ash.Query.do_filter( + query, + massage_filter(query.resource, Map.get(args, :filter)) + ) - query = - AshGraphql.Subscription.query_for_subscription( - query - |> Ash.Query.for_read(read_action, %{}, opts), - domain, - resolution, - subscription_result_type(name), - [subcription_field_from_action_type(notification.action.type)] - ) + query = + AshGraphql.Subscription.query_for_subscription( + query + |> Ash.Query.for_read(read_action, %{}, opts), + domain, + resolution, + subscription_result_type(name), + [subcription_field_from_action_type(notification.action.type)] + ) - case query |> Ash.read_one() do - # should only happen if a resource is created/updated and the subscribed user is not allowed to see it - {:ok, nil} -> - resolution - |> Absinthe.Resolution.put_result( - {:error, to_errors([Ash.Error.Query.NotFound.exception()], context, domain)} - ) + case query |> Ash.read_one() do + # should only happen if a resource is created/updated and the subscribed user is not allowed to see it + {:ok, nil} -> + resolution + |> Absinthe.Resolution.put_result( + {:error, to_errors([Ash.Error.Query.NotFound.exception()], context, domain)} + ) + + {:ok, result} -> + resolution + |> Absinthe.Resolution.put_result( + {:ok, + %{ + String.to_existing_atom( + subcription_field_from_action_type(notification.action.type) + ) => result + }} + ) - {:ok, result} -> + {:error, error} -> + resolution + |> Absinthe.Resolution.put_result({:error, to_errors([error], context, domain)}) + end + + notification.action.type in [:destroy] -> resolution |> Absinthe.Resolution.put_result( {:ok, %{ String.to_existing_atom( subcription_field_from_action_type(notification.action.type) - ) => result + ) => AshGraphql.Resource.encode_id(notification.data, false) }} ) - - {:error, error} -> - resolution - |> Absinthe.Resolution.put_result({:error, to_errors([error], context, domain)}) end end diff --git a/lib/resource/resource.ex b/lib/resource/resource.ex index dfc47b37..5a7361d4 100644 --- a/lib/resource/resource.ex +++ b/lib/resource/resource.ex @@ -1154,7 +1154,7 @@ defmodule AshGraphql.Resource do identifier: :destroyed, module: schema, name: "destroyed", - type: resource_type + type: :id } ], __reference__: ref(__ENV__) From 8a565cfb329067368e57fb661cbffe7bdcd61bf1 Mon Sep 17 00:00:00 2001 From: Barnabas Jovanovics Date: Mon, 26 Aug 2024 13:38:14 +0200 Subject: [PATCH 27/48] update test --- config/config.exs | 2 +- lib/subscription/config.ex | 2 - lib/subscription/endpoint.ex | 61 +------------------------- lib/subscription/notifier.ex | 2 + lib/subscription/runner.ex | 46 +++++++++++++++++++ test/subscription_test.exs | 39 +++++++--------- test/support/domain.ex | 1 + test/support/pub_sub.ex | 51 +++++---------------- test/support/registry.ex | 29 ------------ test/support/resources/post.ex | 1 - test/support/resources/subscribable.ex | 11 ++--- test/support/schema.ex | 21 --------- 12 files changed, 84 insertions(+), 182 deletions(-) create mode 100644 lib/subscription/runner.ex delete mode 100644 test/support/registry.ex diff --git a/config/config.exs b/config/config.exs index 79c376b9..0c6831b9 100644 --- a/config/config.exs +++ b/config/config.exs @@ -7,7 +7,7 @@ config :ash, :validate_domain_config_inclusion?, false config :logger, level: :warning config :ash, :pub_sub, debug?: true -config :logger, level: :debug +config :logger, level: :info if Mix.env() == :dev do config :git_ops, diff --git a/lib/subscription/config.ex b/lib/subscription/config.ex index a732bdf0..d45b1743 100644 --- a/lib/subscription/config.ex +++ b/lib/subscription/config.ex @@ -14,8 +14,6 @@ defmodule AshGraphql.Subscription.Config do read_action = @subscription.read_action || Ash.Resource.Info.primary_action!(@resource, :read).name - dbg(@subscription) - actor = case @subscription.actor do {module, opts} -> diff --git a/lib/subscription/endpoint.ex b/lib/subscription/endpoint.ex index cda2e1ad..14fc3933 100644 --- a/lib/subscription/endpoint.ex +++ b/lib/subscription/endpoint.ex @@ -3,65 +3,8 @@ defmodule AshGraphql.Subscription.Endpoint do quote do use Absinthe.Phoenix.Endpoint - alias Absinthe.Pipeline.BatchResolver - - require Logger - - def run_docset(pubsub, docs_and_topics, notification) do - for {topic, key_strategy, doc} <- docs_and_topics do - try do - pipeline = - Absinthe.Subscription.Local.pipeline(doc, notification) - - {:ok, %{result: data}, _} = Absinthe.Pipeline.run(doc.source, pipeline) - - Logger.debug(""" - Absinthe Subscription Publication - Field Topic: #{inspect(key_strategy)} - Subscription id: #{inspect(topic)} - Data: #{inspect(data)} - """) - - case should_send?(data) do - false -> - :ok - - true -> - :ok = pubsub.publish_subscription(topic, data) - end - rescue - e -> - BatchResolver.pipeline_error(e, __STACKTRACE__) - end - end - end - - defp should_send?(%{errors: errors}) do - # if the user is not allowed to see the data or the query didn't - # return any data we do not send the error to the client - # because it would just expose unnecessary information - # and the user can not really do anything usefull with it - not (errors - |> List.wrap() - |> Enum.any?(fn error -> Map.get(error, :code) in ["forbidden", "not_found"] end)) - end - - defp should_send?(_), do: true - - defp get_filter(topic) do - [_, rest] = String.split(topic, "__absinthe__:doc:") - [filter, _] = String.split(rest, ":") - - case Base.decode64(filter) do - {:ok, filter} -> - :erlang.binary_to_term(filter) - - _ -> - nil - end - rescue - _ -> nil - end + defdelegate run_docset(pubsub, docs_and_topics, notification), + to: AshGraphql.Subscription.Runner end end end diff --git a/lib/subscription/notifier.ex b/lib/subscription/notifier.ex index 0644f3f0..3c91da1e 100644 --- a/lib/subscription/notifier.ex +++ b/lib/subscription/notifier.ex @@ -12,5 +12,7 @@ defmodule AshGraphql.Subscription.Notifier do Absinthe.Subscription.publish(pub_sub, notification, [{subscription.name, "*"}]) end end + + :ok end end diff --git a/lib/subscription/runner.ex b/lib/subscription/runner.ex new file mode 100644 index 00000000..fc39934a --- /dev/null +++ b/lib/subscription/runner.ex @@ -0,0 +1,46 @@ +defmodule AshGraphql.Subscription.Runner do + alias Absinthe.Pipeline.BatchResolver + + require Logger + + def run_docset(pubsub, docs_and_topics, notification) do + for {topic, key_strategy, doc} <- docs_and_topics do + try do + pipeline = + Absinthe.Subscription.Local.pipeline(doc, notification) + + {:ok, %{result: data}, _} = Absinthe.Pipeline.run(doc.source, pipeline) + + Logger.debug(""" + Absinthe Subscription Publication + Field Topic: #{inspect(key_strategy)} + Subscription id: #{inspect(topic)} + Data: #{inspect(data)} + """) + + case should_send?(data) do + false -> + :ok + + true -> + :ok = pubsub.publish_subscription(topic, data) + end + rescue + e -> + BatchResolver.pipeline_error(e, __STACKTRACE__) + end + end + end + + defp should_send?(%{errors: errors}) do + # if the user is not allowed to see the data or the query didn't + # return any data we do not send the error to the client + # because it would just expose unnecessary information + # and the user can not really do anything usefull with it + not (errors + |> List.wrap() + |> Enum.any?(fn error -> Map.get(error, :code) in ["forbidden", "not_found"] end)) + end + + defp should_send?(_), do: true +end diff --git a/test/subscription_test.exs b/test/subscription_test.exs index 4c473a3d..288b8c1a 100644 --- a/test/subscription_test.exs +++ b/test/subscription_test.exs @@ -5,7 +5,7 @@ defmodule AshGraphql.SubscriptionTest do alias AshGraphql.Test.Schema setup do - Application.put_env(PubSub, :notifier_test_pid, self() |> IO.inspect(label: :test_process)) + Application.put_env(PubSub, :notifier_test_pid, self()) {:ok, _} = PubSub.start_link() {:ok, _} = Absinthe.Subscription.start_link(PubSub) :ok @@ -13,25 +13,30 @@ defmodule AshGraphql.SubscriptionTest do @query """ subscription { - subscribableCreated { id } + subscribableCreated { + created { + id + } + } } """ @tag :wip - test "subscription triggers work" do + test "can subscribe to a resource" do id = "1" assert {:ok, %{"subscribed" => topic}} = - run_subscription( + Absinthe.run( @query, Schema, variables: %{"userId" => id}, - context: %{pubsub: PubSub, actor: %{id: id}} + context: %{actor: %{id: id}, pubsub: PubSub} ) mutation = """ mutation CreateSubscribable($input: CreateSubscribableInput) { createSubscribable(input: $input) { result{ + id text } errors{ @@ -41,28 +46,14 @@ defmodule AshGraphql.SubscriptionTest do } """ - IO.inspect(self()) - assert {:ok, %{data: data}} = - run_subscription(mutation, Schema, - variables: %{"input" => %{"text" => "foo"}}, - context: %{pubsub: PubSub} - ) - - assert_receive({:broadcast, absinthe_proxy, data, fields}) - end + Absinthe.run(mutation, Schema, variables: %{"input" => %{"text" => "foo"}}) - defp run_subscription(query, schema, opts) do - opts = Keyword.update(opts, :context, %{pubsub: PubSub}, &Map.put(&1, :pubsub, PubSub)) + assert Enum.empty?(data["createSubscribable"]["errors"]) - case Absinthe.run(query, schema, opts) do - # |> IO.inspect(label: :absinthe_run) do - {:ok, %{"subscribed" => topic}} = val -> - PubSub.subscribe(topic) - val + assert_receive({^topic, data}) - val -> - val - end + assert data["createSubscribable"]["result"]["id"] == + data["subscribableCreated"]["created"]["id"] end end diff --git a/test/support/domain.ex b/test/support/domain.ex index 916b6825..74c08ba8 100644 --- a/test/support/domain.ex +++ b/test/support/domain.ex @@ -45,5 +45,6 @@ defmodule AshGraphql.Test.Domain do resource(AshGraphql.Test.Message) resource(AshGraphql.Test.TextMessage) resource(AshGraphql.Test.ImageMessage) + resource(AshGraphql.Test.Subscribable) end end diff --git a/test/support/pub_sub.ex b/test/support/pub_sub.ex index 6d02d0fa..ce02ef1b 100644 --- a/test/support/pub_sub.ex +++ b/test/support/pub_sub.ex @@ -6,55 +6,26 @@ defmodule AshGraphql.Test.PubSub do end def node_name() do - node() + Atom.to_string(node()) end - def subscribe(topic) do - # IO.inspect([topic: topic], label: "subscribe") - Registry.register(__MODULE__, topic, [self()]) + def subscribe(_topic) do :ok end - def publish_subscription(topic, data) do - message = - %{ - topic: topic, - event: "subscription:data", - result: data - } - - # |> IO.inspect(label: :publish_subscription) - - Registry.dispatch(__MODULE__, topic, fn entries -> - for {pid, _} <- entries, do: send(pid, {:broadcast, message}) - end) - end - - def broadcast(topic, event, notification) do - # IO.inspect([topic: topic, event: event, notification: notification], label: "broadcast") - - message = - %{ - topic: topic, - event: event, - result: notification - } - - Registry.dispatch(__MODULE__, topic, fn entries -> - for {pid, _} <- entries, do: send(pid, {:broadcast, message}) - end) - end - - def publish_mutation(proxy_topic, mutation_result, subscribed_fields) do - # this pubsub is local and doesn't support clusters - IO.inspect("publish mutation") + defdelegate run_docset(pubsub, docs_and_topics, mutation_result), + to: AshGraphql.Subscription.Runner + def publish_subscription(topic, data) do send( - Application.get_env(__MODULE__, :notifier_test_pid) |> IO.inspect(label: :send_to), - {:broadcast, proxy_topic, mutation_result, subscribed_fields} + Application.get_env(__MODULE__, :notifier_test_pid), + {topic, data} ) - |> IO.inspect(label: :send) :ok end + + def publish_mutation(_proxy_topic, _mutation_result, _subscribed_fields) do + :ok + end end diff --git a/test/support/registry.ex b/test/support/registry.ex deleted file mode 100644 index 530d1fcf..00000000 --- a/test/support/registry.ex +++ /dev/null @@ -1,29 +0,0 @@ -defmodule AshGraphql.Test.Registry do - @moduledoc false - use Ash.Registry - - entries do - entry(AshGraphql.Test.Comment) - entry(AshGraphql.Test.CompositePrimaryKey) - entry(AshGraphql.Test.CompositePrimaryKeyNotEncoded) - entry(AshGraphql.Test.DoubleRelRecursive) - entry(AshGraphql.Test.DoubleRelToRecursiveParentOfEmbed) - entry(AshGraphql.Test.MapTypes) - entry(AshGraphql.Test.MultitenantPostTag) - entry(AshGraphql.Test.MultitenantTag) - entry(AshGraphql.Test.NoObject) - entry(AshGraphql.Test.NonIdPrimaryKey) - entry(AshGraphql.Test.Post) - entry(AshGraphql.Test.PostTag) - entry(AshGraphql.Test.RelayPostTag) - entry(AshGraphql.Test.RelayTag) - entry(AshGraphql.Test.SponsoredComment) - entry(AshGraphql.Test.Subscribable) - entry(AshGraphql.Test.Tag) - entry(AshGraphql.Test.User) - entry(AshGraphql.Test.Channel) - entry(AshGraphql.Test.Message) - entry(AshGraphql.Test.TextMessage) - entry(AshGraphql.Test.ImageMessage) - end -end diff --git a/test/support/resources/post.ex b/test/support/resources/post.ex index 0240deb5..edccef54 100644 --- a/test/support/resources/post.ex +++ b/test/support/resources/post.ex @@ -136,7 +136,6 @@ defmodule AshGraphql.Test.Post do domain: AshGraphql.Test.Domain, data_layer: Ash.DataLayer.Ets, authorizers: [Ash.Policy.Authorizer], - simple_notifiers: [AshGraphql.Resource.Notifier], extensions: [AshGraphql.Resource] require Ash.Query diff --git a/test/support/resources/subscribable.ex b/test/support/resources/subscribable.ex index cd41ac1f..bf25b9f0 100644 --- a/test/support/resources/subscribable.ex +++ b/test/support/resources/subscribable.ex @@ -1,6 +1,7 @@ defmodule AshGraphql.Test.Subscribable do @moduledoc false use Ash.Resource, + domain: AshGraphql.Test.Domain, data_layer: Ash.DataLayer.Ets, extensions: [AshGraphql.Resource] @@ -18,21 +19,21 @@ defmodule AshGraphql.Test.Subscribable do end subscriptions do - subscribe(:subscribable_created, fn _, _ -> - IO.inspect("bucket_created") - {:ok, topic: "*"} - end) + pubsub(AshGraphql.Test.PubSub) + + subscribe(:subscribable_created) end end actions do + default_accept(:*) defaults([:create, :read, :update, :destroy]) end attributes do uuid_primary_key(:id) - attribute(:text, :string) + attribute(:text, :string, public?: true) create_timestamp(:created_at) update_timestamp(:updated_at) end diff --git a/test/support/schema.ex b/test/support/schema.ex index afc26f5d..64b2ca72 100644 --- a/test/support/schema.ex +++ b/test/support/schema.ex @@ -7,10 +7,6 @@ defmodule AshGraphql.Test.Schema do use AshGraphql, domains: @domains, generate_sdl_file: "priv/schema.graphql" - alias AshGraphql.Test.Post - - require Ash.Query - query do end @@ -33,22 +29,5 @@ defmodule AshGraphql.Test.Schema do end subscription do - field :subscribable_created, :subscribable do - config(fn - _args, _info -> - {:ok, topic: "*"} - end) - - resolve(fn args, _, resolution -> - # loads all the data you need - AshGraphql.Subscription.query_for_subscription( - Post, - Api, - resolution - ) - |> Ash.Query.filter(id == ^args.id) - |> Ash.read(actor: resolution.context.current_user) - end) - end end end From c403c09e8d2540146b0eb958d783d48c876b45b3 Mon Sep 17 00:00:00 2001 From: Barnabas Jovanovics Date: Mon, 23 Sep 2024 18:20:16 +0200 Subject: [PATCH 28/48] test all mutation types with subscriptions --- test/subscription_test.exs | 73 ++++++++++++++++++++++---- test/support/resources/subscribable.ex | 6 ++- 2 files changed, 69 insertions(+), 10 deletions(-) diff --git a/test/subscription_test.exs b/test/subscription_test.exs index 288b8c1a..3e12b1f9 100644 --- a/test/subscription_test.exs +++ b/test/subscription_test.exs @@ -13,10 +13,16 @@ defmodule AshGraphql.SubscriptionTest do @query """ subscription { - subscribableCreated { + subscribableEvents { created { id + text } + updated { + id + text + } + destroyed } } """ @@ -28,11 +34,10 @@ defmodule AshGraphql.SubscriptionTest do Absinthe.run( @query, Schema, - variables: %{"userId" => id}, context: %{actor: %{id: id}, pubsub: PubSub} ) - mutation = """ + create_mutation = """ mutation CreateSubscribable($input: CreateSubscribableInput) { createSubscribable(input: $input) { result{ @@ -46,14 +51,64 @@ defmodule AshGraphql.SubscriptionTest do } """ - assert {:ok, %{data: data}} = - Absinthe.run(mutation, Schema, variables: %{"input" => %{"text" => "foo"}}) + assert {:ok, %{data: mutation_result}} = + Absinthe.run(create_mutation, Schema, variables: %{"input" => %{"text" => "foo"}}) + + assert Enum.empty?(mutation_result["createSubscribable"]["errors"]) + + subscribable_id = mutation_result["createSubscribable"]["result"]["id"] + refute is_nil(subscribable_id) + + assert_receive({^topic, %{data: subscription_data}}) + + assert subscribable_id == + subscription_data["subscribableEvents"]["created"]["id"] + + update_mutation = """ + mutation CreateSubscribable($id: ID! $input: UpdateSubscribableInput) { + updateSubscribable(id: $id, input: $input) { + result{ + id + text + } + errors{ + message + } + } + } + """ + + assert {:ok, %{data: mutation_result}} = + Absinthe.run(update_mutation, Schema, + variables: %{"id" => subscribable_id, "input" => %{"text" => "bar"}} + ) + + assert Enum.empty?(mutation_result["updateSubscribable"]["errors"]) + + assert_receive({^topic, %{data: subscription_data}}) + + assert subscription_data["subscribableEvents"]["updated"]["text"] == "bar" + + destroy_mutation = """ + mutation CreateSubscribable($id: ID!) { + destroySubscribable(id: $id) { + result{ + id + } + errors{ + message + } + } + } + """ + + assert {:ok, %{data: mutation_result}} = + Absinthe.run(destroy_mutation, Schema, variables: %{"id" => subscribable_id}) - assert Enum.empty?(data["createSubscribable"]["errors"]) + assert Enum.empty?(mutation_result["destroySubscribable"]["errors"]) - assert_receive({^topic, data}) + assert_receive({^topic, %{data: subscription_data}}) - assert data["createSubscribable"]["result"]["id"] == - data["subscribableCreated"]["created"]["id"] + assert subscription_data["subscribableEvents"]["destroyed"] == subscribable_id end end diff --git a/test/support/resources/subscribable.ex b/test/support/resources/subscribable.ex index bf25b9f0..5940db9a 100644 --- a/test/support/resources/subscribable.ex +++ b/test/support/resources/subscribable.ex @@ -16,12 +16,16 @@ defmodule AshGraphql.Test.Subscribable do mutations do create :create_subscribable, :create + update :update_subscribable, :update + destroy :destroy_subscribable, :destroy end subscriptions do pubsub(AshGraphql.Test.PubSub) - subscribe(:subscribable_created) + subscribe(:subscribable_events) do + actions([:create, :update, :destroy]) + end end end From e14d5911bcbe9ff7ca026c36f17002ad24d62c4e Mon Sep 17 00:00:00 2001 From: Barnabas Jovanovics Date: Mon, 23 Sep 2024 19:09:31 +0200 Subject: [PATCH 29/48] format code --- lib/resource/subscription/actor.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/resource/subscription/actor.ex b/lib/resource/subscription/actor.ex index 779a1746..22d8fd14 100644 --- a/lib/resource/subscription/actor.ex +++ b/lib/resource/subscription/actor.ex @@ -1,5 +1,5 @@ defmodule AshGraphql.Resource.Subscription.Actor do - # I'd like to have the typesp say that actor can be anything + # I'd like to have the typespec say that actor can be anything # but that the input and output must be the same @callback actor(actor :: any, opts :: Keyword.t()) :: actor :: any end From d324eefcc8c50c8cec31f3d9fc1c7c532963e1e8 Mon Sep 17 00:00:00 2001 From: Barnabas Jovanovics Date: Mon, 23 Sep 2024 20:21:11 +0200 Subject: [PATCH 30/48] move all the subscription files into the same folder --- lib/resource/subscription.ex | 4 ++-- lib/{resource => }/subscription/actor.ex | 2 +- lib/{resource => }/subscription/actor_function.ex | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) rename lib/{resource => }/subscription/actor.ex (78%) rename lib/{resource => }/subscription/actor_function.ex (80%) diff --git a/lib/resource/subscription.ex b/lib/resource/subscription.ex index 36857da9..0c4dec82 100644 --- a/lib/resource/subscription.ex +++ b/lib/resource/subscription.ex @@ -14,8 +14,8 @@ defmodule AshGraphql.Resource.Subscription do ], actor: [ type: - {:spark_function_behaviour, AshGraphql.Resource.Subscription.Actor, - {AshGraphql.Resource.Subscription.ActorFunction, 1}}, + {:spark_function_behaviour, AshGraphql.Subscription.Actor, + {AshGraphql.Subscription.ActorFunction, 1}}, doc: "The actor to use for authorization." ], actions: [ diff --git a/lib/resource/subscription/actor.ex b/lib/subscription/actor.ex similarity index 78% rename from lib/resource/subscription/actor.ex rename to lib/subscription/actor.ex index 22d8fd14..1bf6c462 100644 --- a/lib/resource/subscription/actor.ex +++ b/lib/subscription/actor.ex @@ -1,4 +1,4 @@ -defmodule AshGraphql.Resource.Subscription.Actor do +defmodule AshGraphql.Subscription.Actor do # I'd like to have the typespec say that actor can be anything # but that the input and output must be the same @callback actor(actor :: any, opts :: Keyword.t()) :: actor :: any diff --git a/lib/resource/subscription/actor_function.ex b/lib/subscription/actor_function.ex similarity index 80% rename from lib/resource/subscription/actor_function.ex rename to lib/subscription/actor_function.ex index ed3ceaff..6cd3e42f 100644 --- a/lib/resource/subscription/actor_function.ex +++ b/lib/subscription/actor_function.ex @@ -1,4 +1,4 @@ -defmodule AshGraphql.Resource.Subscription.ActorFunction do +defmodule AshGraphql.Subscription.ActorFunction do @moduledoc false @behaviour AshGraphql.Resource.Subscription.Actor From 76d4c86a1cdc4087bac6442a0f8d248560dd4f6b Mon Sep 17 00:00:00 2001 From: Barnabas Jovanovics Date: Tue, 24 Sep 2024 17:42:14 +0200 Subject: [PATCH 31/48] add some policy tests for subscriptions --- lib/subscription/actor_function.ex | 2 +- test/subscription_test.exs | 194 ++++++++++++++++++++++--- test/support/resources/subscribable.ex | 27 ++++ 3 files changed, 198 insertions(+), 25 deletions(-) diff --git a/lib/subscription/actor_function.ex b/lib/subscription/actor_function.ex index 6cd3e42f..a24a6708 100644 --- a/lib/subscription/actor_function.ex +++ b/lib/subscription/actor_function.ex @@ -1,7 +1,7 @@ defmodule AshGraphql.Subscription.ActorFunction do @moduledoc false - @behaviour AshGraphql.Resource.Subscription.Actor + @behaviour AshGraphql.Subscription.Actor @impl true def actor(actor, [{:fun, {m, f, a}}]) do diff --git a/test/subscription_test.exs b/test/subscription_test.exs index 3e12b1f9..58a97c2d 100644 --- a/test/subscription_test.exs +++ b/test/subscription_test.exs @@ -3,38 +3,54 @@ defmodule AshGraphql.SubscriptionTest do alias AshGraphql.Test.PubSub alias AshGraphql.Test.Schema + alias AshGraphql.Test.Subscribable + + def assert_down(pid) do + ref = Process.monitor(pid) + + assert_receive {:DOWN, ^ref, _, _, _} + end setup do Application.put_env(PubSub, :notifier_test_pid, self()) - {:ok, _} = PubSub.start_link() - {:ok, _} = Absinthe.Subscription.start_link(PubSub) + {:ok, pubsub} = PubSub.start_link() + {:ok, absinthe_sub} = Absinthe.Subscription.start_link(PubSub) :ok + + on_exit(fn -> + Process.exit(pubsub, :normal) + Process.exit(absinthe_sub, :normal) + # block until the processes have exited + assert_down(pubsub) + assert_down(absinthe_sub) + end) end - @query """ - subscription { - subscribableEvents { - created { - id - text - } - updated { - id - text - } - destroyed - } + @admin %{ + id: 1, + role: :admin } - """ - @tag :wip - test "can subscribe to a resource" do - id = "1" + test "can subscribe to all action types resource" do assert {:ok, %{"subscribed" => topic}} = Absinthe.run( - @query, + """ + subscription { + subscribableEvents { + created { + id + text + } + updated { + id + text + } + destroyed + } + } + """, Schema, - context: %{actor: %{id: id}, pubsub: PubSub} + context: %{actor: @admin, pubsub: PubSub} ) create_mutation = """ @@ -52,7 +68,10 @@ defmodule AshGraphql.SubscriptionTest do """ assert {:ok, %{data: mutation_result}} = - Absinthe.run(create_mutation, Schema, variables: %{"input" => %{"text" => "foo"}}) + Absinthe.run(create_mutation, Schema, + variables: %{"input" => %{"text" => "foo"}}, + context: %{actor: @admin} + ) assert Enum.empty?(mutation_result["createSubscribable"]["errors"]) @@ -80,7 +99,8 @@ defmodule AshGraphql.SubscriptionTest do assert {:ok, %{data: mutation_result}} = Absinthe.run(update_mutation, Schema, - variables: %{"id" => subscribable_id, "input" => %{"text" => "bar"}} + variables: %{"id" => subscribable_id, "input" => %{"text" => "bar"}}, + context: %{actor: @admin} ) assert Enum.empty?(mutation_result["updateSubscribable"]["errors"]) @@ -103,7 +123,10 @@ defmodule AshGraphql.SubscriptionTest do """ assert {:ok, %{data: mutation_result}} = - Absinthe.run(destroy_mutation, Schema, variables: %{"id" => subscribable_id}) + Absinthe.run(destroy_mutation, Schema, + variables: %{"id" => subscribable_id}, + context: %{actor: @admin} + ) assert Enum.empty?(mutation_result["destroySubscribable"]["errors"]) @@ -111,4 +134,127 @@ defmodule AshGraphql.SubscriptionTest do assert subscription_data["subscribableEvents"]["destroyed"] == subscribable_id end + + test "policies are applied to subscriptions" do + actor1 = %{ + id: 1, + role: :user + } + + actor2 = %{ + id: 2, + role: :user + } + + assert {:ok, %{"subscribed" => topic1}} = + Absinthe.run( + """ + subscription { + subscribableEvents { + created { + id + text + } + updated { + id + text + } + destroyed + } + } + """, + Schema, + context: %{actor: actor1, pubsub: PubSub} + ) + + assert {:ok, %{"subscribed" => topic2}} = + Absinthe.run( + """ + subscription { + subscribableEvents { + created { + id + text + } + updated { + id + text + } + destroyed + } + } + """, + Schema, + context: %{actor: actor2, pubsub: PubSub} + ) + + assert topic1 != topic2 + + subscribable = + Subscribable + |> Ash.Changeset.for_create(:create, %{text: "foo", actor_id: 1}, actor: @admin) + |> Ash.create!() + + # actor1 will get data because it can see the resource + assert_receive {^topic1, %{data: subscription_data}} + # actor 2 will not get data because it cannot see the resource + refute_receive({^topic2, _}) + + assert subscribable.id == + subscription_data["subscribableEvents"]["created"]["id"] + end + + test "can dedup with actor fun" do + actor1 = %{ + id: 1, + role: :user + } + + actor2 = %{ + id: 2, + role: :user + } + + subscription = """ + subscription { + dedupedSubscribableEvents { + created { + id + text + } + updated { + id + text + } + destroyed + } + } + """ + + assert {:ok, %{"subscribed" => topic1}} = + Absinthe.run( + subscription, + Schema, + context: %{actor: actor1, pubsub: PubSub} + ) + + assert {:ok, %{"subscribed" => topic2}} = + Absinthe.run( + subscription, + Schema, + context: %{actor: actor2, pubsub: PubSub} + ) + + assert topic1 == topic2 + + subscribable = + Subscribable + |> Ash.Changeset.for_create(:create, %{text: "foo", actor_id: 1}, actor: @admin) + |> Ash.create!() + + assert_receive {^topic1, %{data: subscription_data}} + + assert subscribable.id == + subscription_data["dedupedSubscribableEvents"]["created"]["id"] + end end diff --git a/test/support/resources/subscribable.ex b/test/support/resources/subscribable.ex index 5940db9a..81159f7f 100644 --- a/test/support/resources/subscribable.ex +++ b/test/support/resources/subscribable.ex @@ -3,6 +3,7 @@ defmodule AshGraphql.Test.Subscribable do use Ash.Resource, domain: AshGraphql.Test.Domain, data_layer: Ash.DataLayer.Ets, + authorizers: [Ash.Policy.Authorizer], extensions: [AshGraphql.Resource] require Ash.Query @@ -26,18 +27,44 @@ defmodule AshGraphql.Test.Subscribable do subscribe(:subscribable_events) do actions([:create, :update, :destroy]) end + + subscribe(:deduped_subscribable_events) do + actions([:create, :update, :destroy]) + read_action(:open_read) + + actor(fn _ -> + %{id: -1, role: :deduped_actor} + end) + end + end + end + + policies do + bypass actor_attribute_equals(:role, :admin) do + authorize_if(always()) + end + + policy action(:read) do + authorize_if(expr(actor_id == ^actor(:id))) + end + + policy action(:open_read) do + authorize_if(always()) end end actions do default_accept(:*) defaults([:create, :read, :update, :destroy]) + + read(:open_read) end attributes do uuid_primary_key(:id) attribute(:text, :string, public?: true) + attribute(:actor_id, :integer, public?: true) create_timestamp(:created_at) update_timestamp(:updated_at) end From 16e8a437dafea40c11647e777a530716a2e2c4f0 Mon Sep 17 00:00:00 2001 From: Barnabas Jovanovics Date: Tue, 24 Sep 2024 17:46:09 +0200 Subject: [PATCH 32/48] filer out errors without a code --- lib/subscription/runner.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/subscription/runner.ex b/lib/subscription/runner.ex index fc39934a..889c9fbd 100644 --- a/lib/subscription/runner.ex +++ b/lib/subscription/runner.ex @@ -39,7 +39,7 @@ defmodule AshGraphql.Subscription.Runner do # and the user can not really do anything usefull with it not (errors |> List.wrap() - |> Enum.any?(fn error -> Map.get(error, :code) in ["forbidden", "not_found"] end)) + |> Enum.any?(fn error -> Map.get(error, :code) in ["forbidden", "not_found", nil] end)) end defp should_send?(_), do: true From 7dd9df0dd7b76f0fa09e7f14709e57e4580fa363 Mon Sep 17 00:00:00 2001 From: Barnabas Jovanovics Date: Wed, 25 Sep 2024 18:41:19 +0200 Subject: [PATCH 33/48] test data and only load necessary stuff --- lib/graphql/resolver.ex | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/lib/graphql/resolver.ex b/lib/graphql/resolver.ex index 75253a3c..f1e3c4b8 100644 --- a/lib/graphql/resolver.ex +++ b/lib/graphql/resolver.ex @@ -567,7 +567,28 @@ defmodule AshGraphql.Graphql.Resolver do [subcription_field_from_action_type(notification.action.type)] ) - case query |> Ash.read_one() do + result = + with {:ok, true, query} <- + Ash.can( + query, + opts[:actor], + tenant: opts[:tenant], + run_queries?: false, + alter_source?: true + ), + [] <- query.authorize_results, + {:ok, true} <- + Ash.Expr.eval(query.filter, + record: dbg(data), + unknown_on_unknown_refs?: true + ) do + Ash.load(data, query) + else + _ -> + query |> Ash.read_one() + end + + case result do # should only happen if a resource is created/updated and the subscribed user is not allowed to see it {:ok, nil} -> resolution From 31769879d20c6833e04d45e7b27e05b05c722b6b Mon Sep 17 00:00:00 2001 From: Barnabas Jovanovics Date: Wed, 25 Sep 2024 18:41:52 +0200 Subject: [PATCH 34/48] remove dbg --- lib/graphql/resolver.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/graphql/resolver.ex b/lib/graphql/resolver.ex index f1e3c4b8..9e7fcb05 100644 --- a/lib/graphql/resolver.ex +++ b/lib/graphql/resolver.ex @@ -579,7 +579,7 @@ defmodule AshGraphql.Graphql.Resolver do [] <- query.authorize_results, {:ok, true} <- Ash.Expr.eval(query.filter, - record: dbg(data), + record: data, unknown_on_unknown_refs?: true ) do Ash.load(data, query) From 9dd9c5822f8e8ca30f999d8ba63432470e6eec6d Mon Sep 17 00:00:00 2001 From: Barnabas Jovanovics Date: Wed, 25 Sep 2024 18:59:01 +0200 Subject: [PATCH 35/48] make subscriptions opt_in --- config/config.exs | 2 ++ lib/resource/resource.ex | 3 ++- .../verifiers/verify_subscription_opt_in.ex | 23 +++++++++++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 lib/resource/verifiers/verify_subscription_opt_in.ex diff --git a/config/config.exs b/config/config.exs index 0c6831b9..7c092045 100644 --- a/config/config.exs +++ b/config/config.exs @@ -9,6 +9,8 @@ config :logger, level: :warning config :ash, :pub_sub, debug?: true config :logger, level: :info +config :ash_graphql, :subscriptions, true + if Mix.env() == :dev do config :git_ops, mix_project: AshGraphql.MixProject, diff --git a/lib/resource/resource.ex b/lib/resource/resource.ex index 5a7361d4..be252f99 100644 --- a/lib/resource/resource.ex +++ b/lib/resource/resource.ex @@ -459,7 +459,8 @@ defmodule AshGraphql.Resource do @verifiers [ AshGraphql.Resource.Verifiers.VerifyQueryMetadata, AshGraphql.Resource.Verifiers.RequirePkeyDelimiter, - AshGraphql.Resource.Verifiers.VerifyPaginateRelationshipWith + AshGraphql.Resource.Verifiers.VerifyPaginateRelationshipWith, + AshGraphql.Resource.Verifiers.VerifySubscriptionOptIn ] @sections [@graphql] diff --git a/lib/resource/verifiers/verify_subscription_opt_in.ex b/lib/resource/verifiers/verify_subscription_opt_in.ex new file mode 100644 index 00000000..42a271e1 --- /dev/null +++ b/lib/resource/verifiers/verify_subscription_opt_in.ex @@ -0,0 +1,23 @@ +defmodule AshGraphql.Resource.Verifiers.VerifySubscriptionOptIn do + # Checks if the users has opted into using subscriptions + @moduledoc false + + use Spark.Dsl.Verifier + alias Spark.Dsl.Transformer + + def verify(dsl) do + has_subscriptions = + not (dsl + |> AshGraphql.Resource.Info.subscriptions() + |> Enum.empty?()) + + if has_subscriptions && not Application.get_env(:ash_graphql, :subscriptions, false) do + raise Spark.Error.DslError, + module: Transformer.get_persisted(dsl, :module), + message: "Subscriptions are in beta and must be enabled in the config", + path: [:graphql, :subscriptions] + end + + :ok + end +end From 4303af841a1455728caf5f8efcf07acba2ec055b Mon Sep 17 00:00:00 2001 From: Barnabas Jovanovics Date: Thu, 26 Sep 2024 20:59:40 +0200 Subject: [PATCH 36/48] support action_types and arguments in read actions --- lib/graphql/resolver.ex | 5 +-- lib/resource/resource.ex | 45 +++++++++++++++----------- lib/resource/subscription.ex | 7 +++- lib/subscription/notifier.ex | 4 +-- test/subscription_test.exs | 43 ++++++++++++++++++++++++ test/support/resources/subscribable.ex | 18 +++++++++-- 6 files changed, 96 insertions(+), 26 deletions(-) diff --git a/lib/graphql/resolver.ex b/lib/graphql/resolver.ex index 9e7fcb05..0a7b7d82 100644 --- a/lib/graphql/resolver.ex +++ b/lib/graphql/resolver.ex @@ -540,6 +540,7 @@ defmodule AshGraphql.Graphql.Resolver do cond do notification.action.type in [:create, :update] -> data = notification.data + {filter, args} = Map.pop(args, :filter) read_action = read_action || Ash.Resource.Info.primary_action!(resource, :read).name @@ -554,13 +555,13 @@ defmodule AshGraphql.Graphql.Resolver do query = Ash.Query.do_filter( query, - massage_filter(query.resource, Map.get(args, :filter)) + massage_filter(query.resource, filter) ) query = AshGraphql.Subscription.query_for_subscription( query - |> Ash.Query.for_read(read_action, %{}, opts), + |> Ash.Query.for_read(read_action, args, opts), domain, resolution, subscription_result_type(name), diff --git a/lib/resource/resource.ex b/lib/resource/resource.ex index be252f99..f1f8bfc9 100644 --- a/lib/resource/resource.ex +++ b/lib/resource/resource.ex @@ -1217,8 +1217,12 @@ defmodule AshGraphql.Resource do |> Enum.map(fn %Subscription{name: name} = subscription -> result_type = name |> to_string() |> then(&(&1 <> "_result")) |> String.to_atom() + action = + Ash.Resource.Info.action(resource, subscription.read_action) || + Ash.Resource.Info.primary_action(resource, :read) + %Absinthe.Blueprint.Schema.FieldDefinition{ - arguments: args(:subscription, resource, nil, schema, nil), + arguments: args(:subscription, resource, action, schema, nil), identifier: name, name: to_string(name), config: @@ -1760,26 +1764,29 @@ defmodule AshGraphql.Resource do read_args(resource, action, schema, hide_inputs) end - defp args(:subscription, resource, _action, schema, _identity, _hide_inputs, _query) do - if AshGraphql.Resource.Info.derive_filter?(resource) do - case resource_filter_fields(resource, schema) do - [] -> - [] + defp args(:subscription, resource, action, schema, _identity, hide_inputs, _query) do + args = + if AshGraphql.Resource.Info.derive_filter?(resource) do + case resource_filter_fields(resource, schema) do + [] -> + [] - _ -> - [ - %Absinthe.Blueprint.Schema.InputValueDefinition{ - name: "filter", - identifier: :filter, - type: resource_filter_type(resource), - description: "A filter to limit the results", - __reference__: ref(__ENV__) - } - ] + _ -> + [ + %Absinthe.Blueprint.Schema.InputValueDefinition{ + name: "filter", + identifier: :filter, + type: resource_filter_type(resource), + description: "A filter to limit the results", + __reference__: ref(__ENV__) + } + ] + end + else + [] end - else - [] - end + + args ++ read_args(resource, action, schema, hide_inputs) end defp related_list_args(resource, related_resource, relationship_name, action, schema) do diff --git a/lib/resource/subscription.ex b/lib/resource/subscription.ex index 0c4dec82..618af060 100644 --- a/lib/resource/subscription.ex +++ b/lib/resource/subscription.ex @@ -3,6 +3,7 @@ defmodule AshGraphql.Resource.Subscription do defstruct [ :name, :actions, + :action_types, :read_action, :actor ] @@ -20,7 +21,11 @@ defmodule AshGraphql.Resource.Subscription do ], actions: [ type: {:or, [{:list, :atom}, :atom]}, - doc: "The create/update/destroy actions the subsciption should listen to. Defaults to all." + doc: "The create/update/destroy actions the subsciption should listen to." + ], + action_types: [ + type: {:or, [{:list, :atom}, :atom]}, + doc: "The type of actions the subsciption should listen to." ], read_action: [ type: :atom, diff --git a/lib/subscription/notifier.ex b/lib/subscription/notifier.ex index 3c91da1e..eb6c74dd 100644 --- a/lib/subscription/notifier.ex +++ b/lib/subscription/notifier.ex @@ -7,8 +7,8 @@ defmodule AshGraphql.Subscription.Notifier do pub_sub = Info.subscription_pubsub(notification.resource) for subscription <- AshGraphql.Resource.Info.subscriptions(notification.resource) do - if is_nil(subscription.actions) or - notification.action.name in List.wrap(subscription.actions) do + if notification.action.name in List.wrap(subscription.actions) or + notification.action.type in List.wrap(subscription.action_types) do Absinthe.Subscription.publish(pub_sub, notification, [{subscription.name, "*"}]) end end diff --git a/test/subscription_test.exs b/test/subscription_test.exs index 58a97c2d..9de33152 100644 --- a/test/subscription_test.exs +++ b/test/subscription_test.exs @@ -257,4 +257,47 @@ defmodule AshGraphql.SubscriptionTest do assert subscribable.id == subscription_data["dedupedSubscribableEvents"]["created"]["id"] end + + test "can subscribe to read actions that take arguments" do + actor1 = %{ + id: 1, + role: :user + } + + subscription = """ + subscription WithArguments($topic: String!) { + subscribableEventsWithArguments(topic: $topic) { + created { + id + text + } + updated { + id + text + } + destroyed + } + } + """ + + assert {:ok, %{"subscribed" => topic1}} = + Absinthe.run( + subscription, + Schema, + variables: %{"topic" => "news"}, + context: %{actor: actor1, pubsub: PubSub} + ) + + subscribable = + Subscribable + |> Ash.Changeset.for_create(:create, %{text: "foo", topic: "news", actor_id: 1}, + actor: @admin + ) + |> Ash.create!() + + assert_receive {^topic1, %{data: subscription_data}} + + assert subscribable.id == + subscription_data["subscribableEventsWithArguments"]["created"]["id"] + end end diff --git a/test/support/resources/subscribable.ex b/test/support/resources/subscribable.ex index 81159f7f..c5344c26 100644 --- a/test/support/resources/subscribable.ex +++ b/test/support/resources/subscribable.ex @@ -25,7 +25,7 @@ defmodule AshGraphql.Test.Subscribable do pubsub(AshGraphql.Test.PubSub) subscribe(:subscribable_events) do - actions([:create, :update, :destroy]) + action_types([:create, :update, :destroy]) end subscribe(:deduped_subscribable_events) do @@ -36,6 +36,11 @@ defmodule AshGraphql.Test.Subscribable do %{id: -1, role: :deduped_actor} end) end + + subscribe(:subscribable_events_with_arguments) do + read_action(:read_with_arg) + actions([:create]) + end end end @@ -48,7 +53,7 @@ defmodule AshGraphql.Test.Subscribable do authorize_if(expr(actor_id == ^actor(:id))) end - policy action(:open_read) do + policy action([:open_read, :read_with_arg]) do authorize_if(always()) end end @@ -58,12 +63,21 @@ defmodule AshGraphql.Test.Subscribable do defaults([:create, :read, :update, :destroy]) read(:open_read) + + read :read_with_arg do + argument(:topic, :string) do + allow_nil? false + end + + filter(expr(topic == ^arg(:topic))) + end end attributes do uuid_primary_key(:id) attribute(:text, :string, public?: true) + attribute(:topic, :string, public?: true) attribute(:actor_id, :integer, public?: true) create_timestamp(:created_at) update_timestamp(:updated_at) From 995dd7bdfe2ed495e795c66b18aab399e825ed32 Mon Sep 17 00:00:00 2001 From: Barnabas Jovanovics Date: Thu, 26 Sep 2024 21:30:27 +0200 Subject: [PATCH 37/48] add support for adding subscriptions on the domain --- lib/ash_graphql.ex | 4 ++- lib/domain/domain.ex | 51 +++++++++++++++++++++++++++++++----- lib/domain/info.ex | 6 ++++- lib/resource/info.ex | 15 +++++++++-- lib/resource/resource.ex | 27 ++++++++++++------- lib/resource/subscription.ex | 9 ++++++- lib/subscription/notifier.ex | 3 ++- test/subscription_test.exs | 43 ++++++++++++++++++++++++++++++ test/support/domain.ex | 6 +++++ 9 files changed, 143 insertions(+), 21 deletions(-) diff --git a/lib/ash_graphql.ex b/lib/ash_graphql.ex index 2bb78513..41e8b12f 100644 --- a/lib/ash_graphql.ex +++ b/lib/ash_graphql.ex @@ -208,9 +208,11 @@ defmodule AshGraphql do blueprint_with_subscriptions = domain |> AshGraphql.Domain.subscriptions( + all_domains, unquote(resources), action_middleware, - __MODULE__ + unquote(schema), + unquote(relay_ids?) ) |> Enum.reduce(blueprint_with_mutations, fn subscription, blueprint -> Absinthe.Blueprint.add_field(blueprint, "RootSubscriptionType", subscription) diff --git a/lib/domain/domain.ex b/lib/domain/domain.ex index 17c0f409..b5d45d7a 100644 --- a/lib/domain/domain.ex +++ b/lib/domain/domain.ex @@ -36,9 +36,9 @@ defmodule AshGraphql.Domain do examples: [ """ mutations do - create :create_post, :create - update :update_post, :update - destroy :destroy_post, :destroy + create Post, :create_post, :create + update Post, :update_post, :update + destroy Post, :destroy_post, :destroy end """ ], @@ -57,6 +57,35 @@ defmodule AshGraphql.Domain do ) } + @subscriptions %Spark.Dsl.Section{ + name: :subscriptions, + describe: """ + Subscriptions to expose for the resource. + """, + examples: [ + """ + subscription do + subscribe Post, :post_created do + action_types(:create) + end + end + """ + ], + entities: + Enum.map( + AshGraphql.Resource.subscriptions(), + &%{ + &1 + | args: [:resource | &1.args], + schema: + Keyword.put(&1.schema, :resource, + type: {:spark, Ash.Resource}, + doc: "The resource that the action is defined on" + ) + } + ) + } + @graphql %Spark.Dsl.Section{ name: :graphql, describe: """ @@ -71,7 +100,8 @@ defmodule AshGraphql.Domain do ], sections: [ @queries, - @mutations + @mutations, + @subscriptions ], schema: [ authorize?: [ @@ -209,12 +239,21 @@ defmodule AshGraphql.Domain do ) end - def subscriptions(api, resources, action_middleware, schema) do + def subscriptions(domain, all_domains, resources, action_middleware, schema, relay_ids?) do resources |> Enum.filter(fn resource -> AshGraphql.Resource in Spark.extensions(resource) end) - |> Enum.flat_map(&AshGraphql.Resource.subscriptions(api, &1, action_middleware, schema)) + |> Enum.flat_map( + &AshGraphql.Resource.subscriptions( + domain, + all_domains, + &1, + action_middleware, + schema, + relay_ids? + ) + ) end @doc false diff --git a/lib/domain/info.ex b/lib/domain/info.ex index 0fbd6820..85c6055a 100644 --- a/lib/domain/info.ex +++ b/lib/domain/info.ex @@ -34,7 +34,7 @@ defmodule AshGraphql.Domain.Info do @doc "The queries exposed by the domain" def queries(resource) do - Extension.get_entities(resource, [:graphql, :queries]) + Extension.get_entities(resource, [:graphql, :queries]) || [] end @doc "The mutations exposed by the domain" @@ -42,6 +42,10 @@ defmodule AshGraphql.Domain.Info do Extension.get_entities(resource, [:graphql, :mutations]) || [] end + def subscriptions(resource) do + Extension.get_entities(resource, [:graphql, :subscriptions]) || [] + end + @doc "Whether or not to render raised errors in the GraphQL response" def show_raised_errors?(domain) do Extension.get_opt(domain, [:graphql], :show_raised_errors?, false, true) diff --git a/lib/resource/info.ex b/lib/resource/info.ex index af105627..11e2cbb3 100644 --- a/lib/resource/info.ex +++ b/lib/resource/info.ex @@ -36,8 +36,19 @@ defmodule AshGraphql.Resource.Info do end @doc "The subscriptions exposed for the resource" - def subscriptions(resource) do - Extension.get_entities(resource, [:graphql, :subscriptions]) || [] + def subscriptions(resource, domain_or_domains \\ []) do + module = + if is_atom(resource) do + resource + else + Spark.Dsl.Extension.get_persisted(resource, :module) + end + + domain_or_domains + |> List.wrap() + |> Enum.flat_map(&AshGraphql.Domain.Info.subscriptions/1) + |> Enum.filter(&(&1.resource == module)) + |> Enum.concat(Extension.get_entities(resource, [:graphql, :subscriptions]) || []) end @doc "The pubsub module used for subscriptions" diff --git a/lib/resource/resource.ex b/lib/resource/resource.ex index f1f8bfc9..7c147478 100644 --- a/lib/resource/resource.ex +++ b/lib/resource/resource.ex @@ -275,9 +275,13 @@ defmodule AshGraphql.Resource do @subscribe %Spark.Dsl.Entity{ name: :subscribe, args: [:name], - describe: "A query to fetch a record by primary key", + describe: "A subscription to listen for changes on the resource", examples: [ - "get :get_post, :read" + """ + subscribe :post_created do + action_types(:create) + end + """ ], schema: Subscription.schema(), target: Subscription @@ -310,6 +314,8 @@ defmodule AshGraphql.Resource do ] } + def subscriptions, do: [@subscribe] + @graphql %Spark.Dsl.Section{ name: :graphql, imports: [AshGraphql.Resource.Helpers], @@ -478,7 +484,7 @@ defmodule AshGraphql.Resource do defdelegate mutations(resource, domain \\ []), to: AshGraphql.Resource.Info @deprecated "See `AshGraphql.Resource.Info.mutations/1`" - defdelegate subscriptions(resource), to: AshGraphql.Resource.Info + defdelegate subscriptions(resource, domain \\ []), to: AshGraphql.Resource.Info @deprecated "See `AshGraphql.Resource.Info.managed_relationships/1`" defdelegate managed_relationships(resource), to: AshGraphql.Resource.Info @@ -1116,9 +1122,10 @@ defmodule AshGraphql.Resource do @doc false # sobelow_skip ["DOS.StringToAtom"] - def subscription_types(resource, _all_domains, schema) do + + def subscription_types(resource, all_domains, schema) do resource - |> subscriptions() + |> subscriptions(all_domains) |> Enum.map(fn %Subscription{name: name} -> resource_type = AshGraphql.Resource.Info.type(resource) @@ -1211,9 +1218,10 @@ defmodule AshGraphql.Resource do # sobelow_skip ["DOS.StringToAtom"] @doc false - def subscriptions(api, resource, action_middleware, schema) do + + def subscriptions(domain, all_domains, resource, action_middleware, schema, _relay_ids?) do resource - |> subscriptions() + |> subscriptions(all_domains) |> Enum.map(fn %Subscription{name: name} = subscription -> result_type = name |> to_string() |> then(&(&1 <> "_result")) |> String.to_atom() @@ -1228,14 +1236,15 @@ defmodule AshGraphql.Resource do config: AshGraphql.Subscription.Config.create_config( subscription, - api, + domain, resource ), module: schema, middleware: action_middleware ++ + domain_middleware(domain) ++ [ - {{AshGraphql.Graphql.Resolver, :resolve}, {api, resource, subscription, true}} + {{AshGraphql.Graphql.Resolver, :resolve}, {domain, resource, subscription, true}} ], type: result_type, __reference__: ref(__ENV__) diff --git a/lib/resource/subscription.ex b/lib/resource/subscription.ex index 618af060..e2e057ae 100644 --- a/lib/resource/subscription.ex +++ b/lib/resource/subscription.ex @@ -2,10 +2,12 @@ defmodule AshGraphql.Resource.Subscription do @moduledoc "Represents a configured query on a resource" defstruct [ :name, + :resource, :actions, :action_types, :read_action, - :actor + :actor, + :hide_input ] @subscription_schema [ @@ -30,6 +32,11 @@ defmodule AshGraphql.Resource.Subscription do read_action: [ type: :atom, doc: "The read action to use for reading data" + ], + hide_inputs: [ + type: {:list, :atom}, + doc: "A list of inputs to hide from the mutation.", + default: [] ] ] diff --git a/lib/subscription/notifier.ex b/lib/subscription/notifier.ex index eb6c74dd..ff47f642 100644 --- a/lib/subscription/notifier.ex +++ b/lib/subscription/notifier.ex @@ -6,7 +6,8 @@ defmodule AshGraphql.Subscription.Notifier do def notify(notification) do pub_sub = Info.subscription_pubsub(notification.resource) - for subscription <- AshGraphql.Resource.Info.subscriptions(notification.resource) do + for subscription <- + AshGraphql.Resource.Info.subscriptions(notification.resource, notification.domain) do if notification.action.name in List.wrap(subscription.actions) or notification.action.type in List.wrap(subscription.action_types) do Absinthe.Subscription.publish(pub_sub, notification, [{subscription.name, "*"}]) diff --git a/test/subscription_test.exs b/test/subscription_test.exs index 9de33152..461f1bf6 100644 --- a/test/subscription_test.exs +++ b/test/subscription_test.exs @@ -300,4 +300,47 @@ defmodule AshGraphql.SubscriptionTest do assert subscribable.id == subscription_data["subscribableEventsWithArguments"]["created"]["id"] end + + @tag :wip + test "can subscribe on the domain" do + actor1 = %{ + id: 1, + role: :user + } + + subscription = """ + subscription { + subscribedOnDomain { + created { + id + text + } + updated { + id + text + } + destroyed + } + } + """ + + assert {:ok, %{"subscribed" => topic1}} = + Absinthe.run( + subscription, + Schema, + context: %{actor: actor1, pubsub: PubSub} + ) + + subscribable = + Subscribable + |> Ash.Changeset.for_create(:create, %{text: "foo", topic: "news", actor_id: 1}, + actor: @admin + ) + |> Ash.create!() + + assert_receive {^topic1, %{data: subscription_data}} + + assert subscribable.id == + subscription_data["subscribedOnDomain"]["created"]["id"] + end end diff --git a/test/support/domain.ex b/test/support/domain.ex index 74c08ba8..65cf0f8f 100644 --- a/test/support/domain.ex +++ b/test/support/domain.ex @@ -12,6 +12,12 @@ defmodule AshGraphql.Test.Domain do get AshGraphql.Test.Comment, :get_comment, :read list AshGraphql.Test.Post, :post_score, :score end + + subscriptions do + subscribe AshGraphql.Test.Subscribable, :subscribed_on_domain do + action_types(:create) + end + end end resources do From 9067daa6f7a84c646b0fa6b7c507353c5957a704 Mon Sep 17 00:00:00 2001 From: Barnabas Jovanovics Date: Thu, 26 Sep 2024 21:38:17 +0200 Subject: [PATCH 38/48] only add the fields than will get data to the subcription result type --- lib/resource/resource.ex | 60 +++++++++++++++++++++++--------------- test/subscription_test.exs | 11 ------- 2 files changed, 36 insertions(+), 35 deletions(-) diff --git a/lib/resource/resource.ex b/lib/resource/resource.ex index 7c147478..508c1935 100644 --- a/lib/resource/resource.ex +++ b/lib/resource/resource.ex @@ -1126,9 +1126,16 @@ defmodule AshGraphql.Resource do def subscription_types(resource, all_domains, schema) do resource |> subscriptions(all_domains) - |> Enum.map(fn %Subscription{name: name} -> + |> Enum.map(fn %Subscription{name: name, actions: actions, action_types: action_types} -> resource_type = AshGraphql.Resource.Info.type(resource) + action_types = + Ash.Resource.Info.actions(resource) + |> Stream.filter(&(&1.name in List.wrap(actions))) + |> Stream.map(& &1.name) + |> Stream.concat(List.wrap(action_types)) + |> Enum.uniq() + result_type_name = name |> to_string() @@ -1142,29 +1149,34 @@ defmodule AshGraphql.Resource do module: schema, identifier: result_type, name: result_type_name, - fields: [ - %Absinthe.Blueprint.Schema.FieldDefinition{ - __reference__: ref(__ENV__), - identifier: :created, - module: schema, - name: "created", - type: resource_type - }, - %Absinthe.Blueprint.Schema.FieldDefinition{ - __reference__: ref(__ENV__), - identifier: :updated, - module: schema, - name: "updated", - type: resource_type - }, - %Absinthe.Blueprint.Schema.FieldDefinition{ - __reference__: ref(__ENV__), - identifier: :destroyed, - module: schema, - name: "destroyed", - type: :id - } - ], + fields: + [ + :create in action_types && + %Absinthe.Blueprint.Schema.FieldDefinition{ + __reference__: ref(__ENV__), + identifier: :created, + module: schema, + name: "created", + type: resource_type + }, + :update in action_types && + %Absinthe.Blueprint.Schema.FieldDefinition{ + __reference__: ref(__ENV__), + identifier: :updated, + module: schema, + name: "updated", + type: resource_type + }, + :destroy in action_types && + %Absinthe.Blueprint.Schema.FieldDefinition{ + __reference__: ref(__ENV__), + identifier: :destroyed, + module: schema, + name: "destroyed", + type: :id + } + ] + |> Enum.filter(&(&1 != false)), __reference__: ref(__ENV__) } end) diff --git a/test/subscription_test.exs b/test/subscription_test.exs index 461f1bf6..6ec52bec 100644 --- a/test/subscription_test.exs +++ b/test/subscription_test.exs @@ -271,11 +271,6 @@ defmodule AshGraphql.SubscriptionTest do id text } - updated { - id - text - } - destroyed } } """ @@ -301,7 +296,6 @@ defmodule AshGraphql.SubscriptionTest do subscription_data["subscribableEventsWithArguments"]["created"]["id"] end - @tag :wip test "can subscribe on the domain" do actor1 = %{ id: 1, @@ -315,11 +309,6 @@ defmodule AshGraphql.SubscriptionTest do id text } - updated { - id - text - } - destroyed } } """ From 6da3690e08066061224c2dda389fa930ca075645 Mon Sep 17 00:00:00 2001 From: Barnabas Jovanovics Date: Thu, 26 Sep 2024 21:47:50 +0200 Subject: [PATCH 39/48] remove unecessary number from variable --- test/subscription_test.exs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/subscription_test.exs b/test/subscription_test.exs index 6ec52bec..b4552b1c 100644 --- a/test/subscription_test.exs +++ b/test/subscription_test.exs @@ -275,7 +275,7 @@ defmodule AshGraphql.SubscriptionTest do } """ - assert {:ok, %{"subscribed" => topic1}} = + assert {:ok, %{"subscribed" => topic}} = Absinthe.run( subscription, Schema, @@ -290,7 +290,7 @@ defmodule AshGraphql.SubscriptionTest do ) |> Ash.create!() - assert_receive {^topic1, %{data: subscription_data}} + assert_receive {^topic, %{data: subscription_data}} assert subscribable.id == subscription_data["subscribableEventsWithArguments"]["created"]["id"] @@ -313,7 +313,7 @@ defmodule AshGraphql.SubscriptionTest do } """ - assert {:ok, %{"subscribed" => topic1}} = + assert {:ok, %{"subscribed" => topic}} = Absinthe.run( subscription, Schema, @@ -327,7 +327,7 @@ defmodule AshGraphql.SubscriptionTest do ) |> Ash.create!() - assert_receive {^topic1, %{data: subscription_data}} + assert_receive {^topic, %{data: subscription_data}} assert subscribable.id == subscription_data["subscribedOnDomain"]["created"]["id"] From 2dc91becfc518e5774711c9e9868e5160ec01919 Mon Sep 17 00:00:00 2001 From: Barnabas Jovanovics Date: Thu, 26 Sep 2024 21:59:51 +0200 Subject: [PATCH 40/48] test field policies in subscriptions --- test/subscription_test.exs | 38 ++++++++++++++++++++++++++ test/support/resources/subscribable.ex | 16 +++++++++++ 2 files changed, 54 insertions(+) diff --git a/test/subscription_test.exs b/test/subscription_test.exs index b4552b1c..f219e091 100644 --- a/test/subscription_test.exs +++ b/test/subscription_test.exs @@ -332,4 +332,42 @@ defmodule AshGraphql.SubscriptionTest do assert subscribable.id == subscription_data["subscribedOnDomain"]["created"]["id"] end + + test "can not see forbidden field" do + actor1 = %{ + id: 1, + role: :user + } + + subscription = """ + subscription { + subscribedOnDomain { + created { + id + text + hiddenField + } + } + } + """ + + assert {:ok, %{"subscribed" => topic}} = + Absinthe.run( + subscription, + Schema, + context: %{actor: actor1, pubsub: PubSub} + ) + + Subscribable + |> Ash.Changeset.for_create(:create, %{text: "foo", topic: "news", actor_id: 1}, + actor: @admin + ) + |> Ash.create!() + + assert_receive {^topic, %{data: subscription_data, errors: errors}} + + assert is_nil(subscription_data["subscribedOnDomain"]["created"]) + refute Enum.empty?(errors) + assert [%{code: "forbidden_field"}] = errors + end end diff --git a/test/support/resources/subscribable.ex b/test/support/resources/subscribable.ex index c5344c26..4979cefe 100644 --- a/test/support/resources/subscribable.ex +++ b/test/support/resources/subscribable.ex @@ -58,6 +58,16 @@ defmodule AshGraphql.Test.Subscribable do end end + field_policies do + field_policy :hidden_field do + authorize_if(actor_attribute_equals(:role, :admin)) + end + + field_policy :* do + authorize_if(always()) + end + end + actions do default_accept(:*) defaults([:create, :read, :update, :destroy]) @@ -76,6 +86,12 @@ defmodule AshGraphql.Test.Subscribable do attributes do uuid_primary_key(:id) + attribute(:hidden_field, :string) do + public?(true) + default("hidden") + allow_nil?(false) + end + attribute(:text, :string, public?: true) attribute(:topic, :string, public?: true) attribute(:actor_id, :integer, public?: true) From 0a287ecdeab936a8bcf5f07eef1f6ea7becb2700 Mon Sep 17 00:00:00 2001 From: Barnabas Jovanovics Date: Thu, 26 Sep 2024 22:13:13 +0200 Subject: [PATCH 41/48] remove unecessary param --- lib/ash_graphql.ex | 3 +-- lib/domain/domain.ex | 5 ++--- lib/resource/resource.ex | 2 +- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/lib/ash_graphql.ex b/lib/ash_graphql.ex index 41e8b12f..9f364780 100644 --- a/lib/ash_graphql.ex +++ b/lib/ash_graphql.ex @@ -211,8 +211,7 @@ defmodule AshGraphql do all_domains, unquote(resources), action_middleware, - unquote(schema), - unquote(relay_ids?) + unquote(schema) ) |> Enum.reduce(blueprint_with_mutations, fn subscription, blueprint -> Absinthe.Blueprint.add_field(blueprint, "RootSubscriptionType", subscription) diff --git a/lib/domain/domain.ex b/lib/domain/domain.ex index b5d45d7a..6ff7a4b2 100644 --- a/lib/domain/domain.ex +++ b/lib/domain/domain.ex @@ -239,7 +239,7 @@ defmodule AshGraphql.Domain do ) end - def subscriptions(domain, all_domains, resources, action_middleware, schema, relay_ids?) do + def subscriptions(domain, all_domains, resources, action_middleware, schema) do resources |> Enum.filter(fn resource -> AshGraphql.Resource in Spark.extensions(resource) @@ -250,8 +250,7 @@ defmodule AshGraphql.Domain do all_domains, &1, action_middleware, - schema, - relay_ids? + schema ) ) end diff --git a/lib/resource/resource.ex b/lib/resource/resource.ex index 508c1935..1acf4ec3 100644 --- a/lib/resource/resource.ex +++ b/lib/resource/resource.ex @@ -1231,7 +1231,7 @@ defmodule AshGraphql.Resource do # sobelow_skip ["DOS.StringToAtom"] @doc false - def subscriptions(domain, all_domains, resource, action_middleware, schema, _relay_ids?) do + def subscriptions(domain, all_domains, resource, action_middleware, schema) do resource |> subscriptions(all_domains) |> Enum.map(fn %Subscription{name: name} = subscription -> From f2d2d61292de881c41572db7190549e636b64abc Mon Sep 17 00:00:00 2001 From: Barnabas Jovanovics Date: Thu, 26 Sep 2024 22:56:40 +0200 Subject: [PATCH 42/48] check if all actions configured in subscriptions actually exist --- lib/resource/resource.ex | 1 + .../verifiers/verify_subscription_actions.ex | 56 +++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 lib/resource/verifiers/verify_subscription_actions.ex diff --git a/lib/resource/resource.ex b/lib/resource/resource.ex index 1acf4ec3..e1ff34cd 100644 --- a/lib/resource/resource.ex +++ b/lib/resource/resource.ex @@ -466,6 +466,7 @@ defmodule AshGraphql.Resource do AshGraphql.Resource.Verifiers.VerifyQueryMetadata, AshGraphql.Resource.Verifiers.RequirePkeyDelimiter, AshGraphql.Resource.Verifiers.VerifyPaginateRelationshipWith, + AshGraphql.Resource.Verifiers.VerifySubscriptionActions, AshGraphql.Resource.Verifiers.VerifySubscriptionOptIn ] diff --git a/lib/resource/verifiers/verify_subscription_actions.ex b/lib/resource/verifiers/verify_subscription_actions.ex new file mode 100644 index 00000000..1856ec83 --- /dev/null +++ b/lib/resource/verifiers/verify_subscription_actions.ex @@ -0,0 +1,56 @@ +defmodule AshGraphql.Resource.Verifiers.VerifySubscriptionActions do + # Validates the paginate_relationship_with option + @moduledoc false + + use Spark.Dsl.Verifier + + alias Spark.Dsl.Transformer + + def verify(dsl) do + dsl + |> AshGraphql.Resource.Info.subscriptions(Ash.Resource.Info.domain(dsl)) + |> Enum.each(&verify_actions(dsl, &1)) + + :ok + end + + defp verify_actions(dsl, subscription) do + unless MapSet.subset?( + MapSet.new(List.wrap(subscription.action_types)), + MapSet.new([:create, :update, :destroy]) + ) do + raise Spark.Error.DslError, + module: Transformer.get_persisted(dsl, :module), + message: "`action_types` values must be on of `[:create, :update, :destroy]`.", + path: [:graphql, :subscriptions, subscription.name, :action_types] + end + + missing_write_actions = + MapSet.difference( + MapSet.new(List.wrap(subscription.actions)), + MapSet.new( + Ash.Resource.Info.actions(dsl) + |> Stream.filter(&(&1.type in [:create, :update, :destroy])) + |> Enum.map(& &1.name) + ) + ) + + unless Enum.empty?(missing_write_actions) do + raise Spark.Error.DslError, + module: Transformer.get_persisted(dsl, :module), + message: + "The actions #{Enum.join(missing_write_actions, ", ")} do not exist on the resource.", + path: [:graphql, :subscriptions, subscription.name, :actions] + end + + unless is_nil(subscription.read_action) or + subscription.read_action in (Ash.Resource.Info.actions(dsl) + |> Stream.filter(&(&1.type == :read)) + |> Enum.map(& &1.name)) do + raise Spark.Error.DslError, + module: Transformer.get_persisted(dsl, :module), + message: "The read action #{subscription.read_action} does not exist on the resource.", + path: [:graphql, :subscriptions, subscription.name, :read_action] + end + end +end From 381cddf3b2d26b2bec42a0b8f42f5b186615531b Mon Sep 17 00:00:00 2001 From: Barnabas Jovanovics Date: Thu, 26 Sep 2024 23:02:16 +0200 Subject: [PATCH 43/48] generate cheat sheets --- documentation/dsls/DSL:-AshGraphql.Domain.md | 74 +++++++++++++++++- .../dsls/DSL:-AshGraphql.Resource.md | 76 +++++++++++++++++++ 2 files changed, 147 insertions(+), 3 deletions(-) diff --git a/documentation/dsls/DSL:-AshGraphql.Domain.md b/documentation/dsls/DSL:-AshGraphql.Domain.md index 2c44a32f..338dbcf6 100644 --- a/documentation/dsls/DSL:-AshGraphql.Domain.md +++ b/documentation/dsls/DSL:-AshGraphql.Domain.md @@ -21,6 +21,8 @@ Domain level configuration for GraphQL * update * destroy * action + * [subscriptions](#graphql-subscriptions) + * subscribe ### Examples @@ -269,9 +271,9 @@ Mutations (create/update/destroy actions) to expose for the resource. ### Examples ``` mutations do - create :create_post, :create - update :update_post, :update - destroy :destroy_post, :destroy + create Post, :create_post, :create + update Post, :update_post, :update + destroy Post, :destroy_post, :destroy end ``` @@ -445,6 +447,72 @@ action :check_status, :check_status Target: `AshGraphql.Resource.Action` +## graphql.subscriptions +Subscriptions to expose for the resource. + + +### Nested DSLs + * [subscribe](#graphql-subscriptions-subscribe) + + +### Examples +``` +subscription do + subscribe Post, :post_created do + action_types(:create) + end +end + +``` + + + + +## graphql.subscriptions.subscribe +```elixir +subscribe resource, name +``` + + +A subscription to listen for changes on the resource + + + +### Examples +``` +subscribe :post_created do + action_types(:create) +end + +``` + + + +### Arguments + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`resource`](#graphql-subscriptions-subscribe-resource){: #graphql-subscriptions-subscribe-resource } | `module` | | The resource that the action is defined on | +| [`name`](#graphql-subscriptions-subscribe-name){: #graphql-subscriptions-subscribe-name } | `atom` | | The name to use for the subscription. | +### Options + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`actor`](#graphql-subscriptions-subscribe-actor){: #graphql-subscriptions-subscribe-actor } | `(any -> any) \| module` | | The actor to use for authorization. | +| [`actions`](#graphql-subscriptions-subscribe-actions){: #graphql-subscriptions-subscribe-actions } | `list(atom) \| atom` | | The create/update/destroy actions the subsciption should listen to. | +| [`action_types`](#graphql-subscriptions-subscribe-action_types){: #graphql-subscriptions-subscribe-action_types } | `list(atom) \| atom` | | The type of actions the subsciption should listen to. | +| [`read_action`](#graphql-subscriptions-subscribe-read_action){: #graphql-subscriptions-subscribe-read_action } | `atom` | | The read action to use for reading data | +| [`hide_inputs`](#graphql-subscriptions-subscribe-hide_inputs){: #graphql-subscriptions-subscribe-hide_inputs } | `list(atom)` | `[]` | A list of inputs to hide from the mutation. | + + + + + +### Introspection + +Target: `AshGraphql.Resource.Subscription` + + diff --git a/documentation/dsls/DSL:-AshGraphql.Resource.md b/documentation/dsls/DSL:-AshGraphql.Resource.md index 60127fb3..98cadf17 100644 --- a/documentation/dsls/DSL:-AshGraphql.Resource.md +++ b/documentation/dsls/DSL:-AshGraphql.Resource.md @@ -21,6 +21,8 @@ Configuration for a given resource in graphql * update * destroy * action + * [subscriptions](#graphql-subscriptions) + * subscribe * [managed_relationships](#graphql-managed_relationships) * managed_relationship @@ -464,6 +466,80 @@ action :check_status, :check_status Target: `AshGraphql.Resource.Action` +## graphql.subscriptions +Subscriptions (notifications) to expose for the resource. + + +### Nested DSLs + * [subscribe](#graphql-subscriptions-subscribe) + + +### Examples +``` +subscriptions do + subscribe :bucket_created do + actions :create + read_action :read + end +end + +``` + + + + +### Options + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`pubsub`](#graphql-subscriptions-pubsub){: #graphql-subscriptions-pubsub .spark-required} | `module` | | The pubsub module to use for the subscription | + + + +## graphql.subscriptions.subscribe +```elixir +subscribe name +``` + + +A subscription to listen for changes on the resource + + + +### Examples +``` +subscribe :post_created do + action_types(:create) +end + +``` + + + +### Arguments + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`name`](#graphql-subscriptions-subscribe-name){: #graphql-subscriptions-subscribe-name } | `atom` | | The name to use for the subscription. | +### Options + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`actor`](#graphql-subscriptions-subscribe-actor){: #graphql-subscriptions-subscribe-actor } | `(any -> any) \| module` | | The actor to use for authorization. | +| [`actions`](#graphql-subscriptions-subscribe-actions){: #graphql-subscriptions-subscribe-actions } | `list(atom) \| atom` | | The create/update/destroy actions the subsciption should listen to. | +| [`action_types`](#graphql-subscriptions-subscribe-action_types){: #graphql-subscriptions-subscribe-action_types } | `list(atom) \| atom` | | The type of actions the subsciption should listen to. | +| [`read_action`](#graphql-subscriptions-subscribe-read_action){: #graphql-subscriptions-subscribe-read_action } | `atom` | | The read action to use for reading data | +| [`hide_inputs`](#graphql-subscriptions-subscribe-hide_inputs){: #graphql-subscriptions-subscribe-hide_inputs } | `list(atom)` | `[]` | A list of inputs to hide from the mutation. | + + + + + +### Introspection + +Target: `AshGraphql.Resource.Subscription` + + ## graphql.managed_relationships Generates input objects for `manage_relationship` arguments on resource actions. From fde8217f440ea33f7c55e776dcce0e359ddd874d Mon Sep 17 00:00:00 2001 From: Barnabas Jovanovics Date: Thu, 26 Sep 2024 23:03:12 +0200 Subject: [PATCH 44/48] update formatter --- .formatter.exs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.formatter.exs b/.formatter.exs index 21951119..a0a49475 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -2,6 +2,9 @@ spark_locals_without_parens = [ action: 2, action: 3, action: 4, + action_types: 1, + actions: 1, + actor: 1, allow_nil?: 1, argument_input_types: 1, argument_names: 1, @@ -47,6 +50,7 @@ spark_locals_without_parens = [ paginate_relationship_with: 1, paginate_with: 1, primary_key_delimiter: 1, + pubsub: 1, read_action: 1, read_one: 2, read_one: 3, @@ -58,6 +62,9 @@ spark_locals_without_parens = [ show_fields: 1, show_metadata: 1, show_raised_errors?: 1, + subscribe: 1, + subscribe: 2, + subscribe: 3, tracer: 1, type: 1, type_name: 1, From 8a6337d081cd56718bd8aaf408b0a91cf413437c Mon Sep 17 00:00:00 2001 From: Barnabas Jovanovics Date: Thu, 26 Sep 2024 23:12:12 +0200 Subject: [PATCH 45/48] fix: credo --- lib/subscription/actor.ex | 5 +++++ lib/subscription/config.ex | 5 +++++ lib/subscription/notifier.ex | 3 +++ lib/subscription/runner.ex | 6 ++++++ test/support/pub_sub.ex | 7 +++++-- 5 files changed, 24 insertions(+), 2 deletions(-) diff --git a/lib/subscription/actor.ex b/lib/subscription/actor.ex index 1bf6c462..21d08582 100644 --- a/lib/subscription/actor.ex +++ b/lib/subscription/actor.ex @@ -1,4 +1,9 @@ defmodule AshGraphql.Subscription.Actor do + @moduledoc """ + Allows the user to substitue an actor for another more generic actor, + this can be used to deduplicate subscription execution + """ + # I'd like to have the typespec say that actor can be anything # but that the input and output must be the same @callback actor(actor :: any, opts :: Keyword.t()) :: actor :: any diff --git a/lib/subscription/config.ex b/lib/subscription/config.ex index d45b1743..12b520cf 100644 --- a/lib/subscription/config.ex +++ b/lib/subscription/config.ex @@ -1,4 +1,9 @@ defmodule AshGraphql.Subscription.Config do + @moduledoc """ + Creates a config function used for the absinthe subscription definition + + See https://github.com/absinthe-graphql/absinthe/blob/3d0823bd71c2ebb94357a5588c723e053de8c66a/lib/absinthe/schema/notation.ex#L58 + """ alias AshGraphql.Resource.Subscription def create_config(%Subscription{} = subscription, _domain, resource) do diff --git a/lib/subscription/notifier.ex b/lib/subscription/notifier.ex index ff47f642..a8ed7030 100644 --- a/lib/subscription/notifier.ex +++ b/lib/subscription/notifier.ex @@ -1,4 +1,7 @@ defmodule AshGraphql.Subscription.Notifier do + @moduledoc """ + AshNotifier that triggers absinthe if subscriptions are listening + """ alias AshGraphql.Resource.Info use Ash.Notifier diff --git a/lib/subscription/runner.ex b/lib/subscription/runner.ex index 889c9fbd..fbbf3e18 100644 --- a/lib/subscription/runner.ex +++ b/lib/subscription/runner.ex @@ -1,4 +1,10 @@ defmodule AshGraphql.Subscription.Runner do + @moduledoc """ + Custom implementation if the run_docset function for the PubSub module used for Subscriptions + + Mostly a copy of https://github.com/absinthe-graphql/absinthe/blob/3d0823bd71c2ebb94357a5588c723e053de8c66a/lib/absinthe/subscription/local.ex#L40 + but this lets us decide if we want to send the data to the client or not in certain error cases + """ alias Absinthe.Pipeline.BatchResolver require Logger diff --git a/test/support/pub_sub.ex b/test/support/pub_sub.ex index ce02ef1b..4edec1ac 100644 --- a/test/support/pub_sub.ex +++ b/test/support/pub_sub.ex @@ -1,11 +1,14 @@ defmodule AshGraphql.Test.PubSub do + @moduledoc """ + PubSub mock implementation for subscription tests + """ @behaviour Absinthe.Subscription.Pubsub - def start_link() do + def start_link do Registry.start_link(keys: :duplicate, name: __MODULE__) end - def node_name() do + def node_name do Atom.to_string(node()) end From 3b3b8955246ef9f28f23edd94fb77673d4d71709 Mon Sep 17 00:00:00 2001 From: Barnabas Jovanovics Date: Fri, 27 Sep 2024 11:56:35 +0200 Subject: [PATCH 46/48] add docs --- documentation/dsls/DSL:-AshGraphql.Domain.md | 2 +- .../dsls/DSL:-AshGraphql.Resource.md | 2 +- .../topics/use-subscriptions-with-graphql.md | 76 ++++++++++++++++++- lib/resource/resource.ex | 5 +- lib/resource/subscription.ex | 5 +- mix.exs | 1 + mix.lock | 7 ++ 7 files changed, 90 insertions(+), 8 deletions(-) diff --git a/documentation/dsls/DSL:-AshGraphql.Domain.md b/documentation/dsls/DSL:-AshGraphql.Domain.md index 338dbcf6..bbce22f8 100644 --- a/documentation/dsls/DSL:-AshGraphql.Domain.md +++ b/documentation/dsls/DSL:-AshGraphql.Domain.md @@ -502,7 +502,7 @@ end | [`actions`](#graphql-subscriptions-subscribe-actions){: #graphql-subscriptions-subscribe-actions } | `list(atom) \| atom` | | The create/update/destroy actions the subsciption should listen to. | | [`action_types`](#graphql-subscriptions-subscribe-action_types){: #graphql-subscriptions-subscribe-action_types } | `list(atom) \| atom` | | The type of actions the subsciption should listen to. | | [`read_action`](#graphql-subscriptions-subscribe-read_action){: #graphql-subscriptions-subscribe-read_action } | `atom` | | The read action to use for reading data | -| [`hide_inputs`](#graphql-subscriptions-subscribe-hide_inputs){: #graphql-subscriptions-subscribe-hide_inputs } | `list(atom)` | `[]` | A list of inputs to hide from the mutation. | +| [`hide_inputs`](#graphql-subscriptions-subscribe-hide_inputs){: #graphql-subscriptions-subscribe-hide_inputs } | `list(atom)` | `[]` | A list of inputs to hide from the subscription, usable if the read action has arguments. | diff --git a/documentation/dsls/DSL:-AshGraphql.Resource.md b/documentation/dsls/DSL:-AshGraphql.Resource.md index 98cadf17..4ef7b381 100644 --- a/documentation/dsls/DSL:-AshGraphql.Resource.md +++ b/documentation/dsls/DSL:-AshGraphql.Resource.md @@ -529,7 +529,7 @@ end | [`actions`](#graphql-subscriptions-subscribe-actions){: #graphql-subscriptions-subscribe-actions } | `list(atom) \| atom` | | The create/update/destroy actions the subsciption should listen to. | | [`action_types`](#graphql-subscriptions-subscribe-action_types){: #graphql-subscriptions-subscribe-action_types } | `list(atom) \| atom` | | The type of actions the subsciption should listen to. | | [`read_action`](#graphql-subscriptions-subscribe-read_action){: #graphql-subscriptions-subscribe-read_action } | `atom` | | The read action to use for reading data | -| [`hide_inputs`](#graphql-subscriptions-subscribe-hide_inputs){: #graphql-subscriptions-subscribe-hide_inputs } | `list(atom)` | `[]` | A list of inputs to hide from the mutation. | +| [`hide_inputs`](#graphql-subscriptions-subscribe-hide_inputs){: #graphql-subscriptions-subscribe-hide_inputs } | `list(atom)` | `[]` | A list of inputs to hide from the subscription, usable if the read action has arguments. | diff --git a/documentation/topics/use-subscriptions-with-graphql.md b/documentation/topics/use-subscriptions-with-graphql.md index eb08a39e..40ec6b98 100644 --- a/documentation/topics/use-subscriptions-with-graphql.md +++ b/documentation/topics/use-subscriptions-with-graphql.md @@ -1,6 +1,6 @@ # Using Subscriptions -The AshGraphql DSL does not currently support subscriptions. However, you can do this with Absinthe direclty, and use `AshGraphql.Subscription.query_for_subscription/3`. Here is an example of how you could do this for a subscription for a single record. This example could be extended to support lists of records as well. +You can do this with Absinthe direclty, and use `AshGraphql.Subscription.query_for_subscription/3`. Here is an example of how you could do this for a subscription for a single record. This example could be extended to support lists of records as well. ```elixir # in your absinthe schema file @@ -27,3 +27,77 @@ subscription do end end ``` + +## Subscription DSL (beta) + +The subscription DSL is currently in beta and before using it you have to enable them in your config. + +```elixir +config :ash_graphql, :policies, show_policy_breakdowns?: true +``` + +First you'll need to do some setup, follow the the [setup guide](https://hexdocs.pm/absinthe/subscriptions.html#absinthe-phoenix-setup) +in the absinthe docs, but instead of using `Absinthe.Pheonix.Endpoint` use `AshGraphql.Subscription.Endpoint`. + +Afterwards add an empty subscription block to your schema module. + +```elixir +defmodule MyAppWeb.Schema do + ... + + subscription do + end +end +``` + +Now you can define subscriptions on your resource or domain + +```elixir +defmodule MyApp.Resource do + use Ash.Resource, + data_layer: Ash.DataLayer.Ets, + extensions: [AshGraphql.Resource] + + graphql do + subscriptions do + subscribe :resource_created do + action_types :create + end + end + end +end +``` + +For further Details checkout the DSL docs for [resource](/documentation/dsls/DSL:-AshGraphql.Resource.md#graphql-subscriptions) and [domain](/documentation/dsls/DSL:-AshGraphql.Domain.md#graphql-subscriptions) + +### Deduplication + +By default, AshGraphql will deduplicate subscriptions based on the `context_id`. +We use the some of the context like actor and tenant to create a `context_id` for you. + +If you want to customize the deduplication you can do so by adding a actor function to your subscription. +This function will be called with the actor that subscribes and you can return a more generic actor, this +way you can have one actor for multiple users, which will lead to less resolver executions. + +```elixir +defmodule MyApp.Resource do + use Ash.Resource, + data_layer: Ash.DataLayer.Ets, + extensions: [AshGraphql.Resource] + + graphql do + subscriptions do + subscribe :resource_created do + action_types :create + actor fn actor -> + if check_actor(actor) do + %{id: "your generic actor", ...} + else + actor + end + end + end + end + end +end +``` diff --git a/lib/resource/resource.ex b/lib/resource/resource.ex index e1ff34cd..a0f4e828 100644 --- a/lib/resource/resource.ex +++ b/lib/resource/resource.ex @@ -466,7 +466,6 @@ defmodule AshGraphql.Resource do AshGraphql.Resource.Verifiers.VerifyQueryMetadata, AshGraphql.Resource.Verifiers.RequirePkeyDelimiter, AshGraphql.Resource.Verifiers.VerifyPaginateRelationshipWith, - AshGraphql.Resource.Verifiers.VerifySubscriptionActions, AshGraphql.Resource.Verifiers.VerifySubscriptionOptIn ] @@ -1235,7 +1234,7 @@ defmodule AshGraphql.Resource do def subscriptions(domain, all_domains, resource, action_middleware, schema) do resource |> subscriptions(all_domains) - |> Enum.map(fn %Subscription{name: name} = subscription -> + |> Enum.map(fn %Subscription{name: name, hide_inputs: hide_inputs} = subscription -> result_type = name |> to_string() |> then(&(&1 <> "_result")) |> String.to_atom() action = @@ -1243,7 +1242,7 @@ defmodule AshGraphql.Resource do Ash.Resource.Info.primary_action(resource, :read) %Absinthe.Blueprint.Schema.FieldDefinition{ - arguments: args(:subscription, resource, action, schema, nil), + arguments: args(:subscription, resource, action, schema, nil, hide_inputs), identifier: name, name: to_string(name), config: diff --git a/lib/resource/subscription.ex b/lib/resource/subscription.ex index e2e057ae..764b090d 100644 --- a/lib/resource/subscription.ex +++ b/lib/resource/subscription.ex @@ -7,7 +7,7 @@ defmodule AshGraphql.Resource.Subscription do :action_types, :read_action, :actor, - :hide_input + :hide_inputs ] @subscription_schema [ @@ -35,7 +35,8 @@ defmodule AshGraphql.Resource.Subscription do ], hide_inputs: [ type: {:list, :atom}, - doc: "A list of inputs to hide from the mutation.", + doc: + "A list of inputs to hide from the subscription, usable if the read action has arguments.", default: [] ] ] diff --git a/mix.exs b/mix.exs index 6e2e7132..6b8f8762 100644 --- a/mix.exs +++ b/mix.exs @@ -142,6 +142,7 @@ defmodule AshGraphql.MixProject do {:ash, ash_version("~> 3.0 and >= 3.2.3")}, {:absinthe_plug, "~> 1.4"}, {:absinthe, "~> 1.7"}, + {:absinthe_phoenix, "~> 2.0.0", optional: true}, {:jason, "~> 1.2"}, {:igniter, "~> 0.3 and >= 0.3.34"}, {:spark, "~> 2.2 and >= 2.2.10"}, diff --git a/mix.lock b/mix.lock index 56c32f83..b997d682 100644 --- a/mix.lock +++ b/mix.lock @@ -1,8 +1,10 @@ %{ "absinthe": {:hex, :absinthe, "1.7.8", "43443d12ad2b4fcce60e257ac71caf3081f3d5c4ddd5eac63a02628bcaf5b556", [:mix], [{:dataloader, "~> 1.0.0 or ~> 2.0", [hex: :dataloader, repo: "hexpm", optional: true]}, {:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:opentelemetry_process_propagator, "~> 0.2.1 or ~> 0.3", [hex: :opentelemetry_process_propagator, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c4085df201892a498384f997649aedb37a4ce8a726c170d5b5617ed3bf45d40b"}, + "absinthe_phoenix": {:hex, :absinthe_phoenix, "2.0.3", "74e0862f280424b7bc290f6f69e133268bce0b4e7db0218c7e129c5c2b1d3fd4", [:mix], [{:absinthe, "~> 1.5", [hex: :absinthe, repo: "hexpm", optional: false]}, {:absinthe_plug, "~> 1.5", [hex: :absinthe_plug, repo: "hexpm", optional: false]}, {:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.5", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.13 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}], "hexpm", "caffaea03c17ea7419fe07e4bc04c2399c47f0d8736900623dbf4749a826fd2c"}, "absinthe_plug": {:hex, :absinthe_plug, "1.5.8", "38d230641ba9dca8f72f1fed2dfc8abd53b3907d1996363da32434ab6ee5d6ab", [:mix], [{:absinthe, "~> 1.5", [hex: :absinthe, repo: "hexpm", optional: false]}, {:plug, "~> 1.4", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "bbb04176647b735828861e7b2705465e53e2cf54ccf5a73ddd1ebd855f996e5a"}, "ash": {:hex, :ash, "3.4.16", "2ef1c3c1c901ba97fa5e3a4e02783bffda4e4f41dfa65935ff7a3c995ae9fa22", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.3.36 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: false]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 0.9", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5ae23ae5833c6fae27ea164dc0a9a86bd5e3a88bda6093f94f001df49488c920"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, + "castore": {:hex, :castore, "1.0.9", "5cc77474afadf02c7c017823f460a17daa7908e991b0cc917febc90e466a375c", [:mix], [], "hexpm", "5ea956504f1ba6f2b4eb707061d8e17870de2bee95fb59d512872c2ef06925e7"}, "credo": {:hex, :credo, "1.7.7", "771445037228f763f9b2afd612b6aa2fd8e28432a95dbbc60d8e03ce71ba4446", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8bc87496c9aaacdc3f90f01b7b0582467b69b4bd2441fe8aae3109d843cc2f2e"}, "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, "dialyxir": {:hex, :dialyxir, "1.4.3", "edd0124f358f0b9e95bfe53a9fcf806d615d8f838e2202a9f430d59566b6b53b", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "bf2cfb75cd5c5006bec30141b131663299c661a864ec7fbbc72dfa557487a986"}, @@ -28,6 +30,9 @@ "mix_test_watch": {:hex, :mix_test_watch, "1.2.0", "1f9acd9e1104f62f280e30fc2243ae5e6d8ddc2f7f4dc9bceb454b9a41c82b42", [:mix], [{:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "278dc955c20b3fb9a3168b5c2493c2e5cffad133548d307e0a50c7f2cfbf34f6"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, "owl": {:hex, :owl, "0.11.0", "2cd46185d330aa2400f1c8c3cddf8d2ff6320baeff23321d1810e58127082cae", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "73f5783f0e963cc04a061be717a0dbb3e49ae0c4bfd55fb4b78ece8d33a65efe"}, + "phoenix": {:hex, :phoenix, "1.7.14", "a7d0b3f1bc95987044ddada111e77bd7f75646a08518942c72a8440278ae7825", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "c7859bc56cc5dfef19ecfc240775dae358cbaa530231118a9e014df392ace61a"}, + "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, + "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, "plug": {:hex, :plug, "1.16.1", "40c74619c12f82736d2214557dedec2e9762029b2438d6d175c5074c933edc9d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a13ff6b9006b03d7e33874945b2755253841b238c34071ed85b0e86057f8cddc"}, "plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"}, "reactor": {:hex, :reactor, "0.10.0", "1206113c21ba69b889e072b2c189c05a7aced523b9c3cb8dbe2dab7062cb699a", [:mix], [{:igniter, "~> 0.2", [hex: :igniter, repo: "hexpm", optional: false]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4003c33e4c8b10b38897badea395e404d74d59a31beb30469a220f2b1ffe6457"}, @@ -40,6 +45,8 @@ "splode": {:hex, :splode, "0.2.4", "71046334c39605095ca4bed5d008372e56454060997da14f9868534c17b84b53", [:mix], [], "hexpm", "ca3b95f0d8d4b482b5357954fec857abd0fa3ea509d623334c1328e7382044c2"}, "stream_data": {:hex, :stream_data, "1.1.1", "fd515ca95619cca83ba08b20f5e814aaf1e5ebff114659dc9731f966c9226246", [:mix], [], "hexpm", "45d0cd46bd06738463fd53f22b70042dbb58c384bb99ef4e7576e7bb7d3b8c8c"}, "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, + "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, + "websock_adapter": {:hex, :websock_adapter, "0.5.7", "65fa74042530064ef0570b75b43f5c49bb8b235d6515671b3d250022cb8a1f9e", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "d0f478ee64deddfec64b800673fd6e0c8888b079d9f3444dd96d2a98383bdbd1"}, "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, "yaml_elixir": {:hex, :yaml_elixir, "2.11.0", "9e9ccd134e861c66b84825a3542a1c22ba33f338d82c07282f4f1f52d847bd50", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "53cc28357ee7eb952344995787f4bb8cc3cecbf189652236e9b163e8ce1bc242"}, } From 450ee5677e16ad6a9a045af8cd723736f7ae811e Mon Sep 17 00:00:00 2001 From: Barnabas Jovanovics Date: Fri, 27 Sep 2024 11:56:58 +0200 Subject: [PATCH 47/48] fix docs --- documentation/topics/use-subscriptions-with-graphql.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/documentation/topics/use-subscriptions-with-graphql.md b/documentation/topics/use-subscriptions-with-graphql.md index 40ec6b98..8ab20986 100644 --- a/documentation/topics/use-subscriptions-with-graphql.md +++ b/documentation/topics/use-subscriptions-with-graphql.md @@ -72,7 +72,7 @@ For further Details checkout the DSL docs for [resource](/documentation/dsls/DSL ### Deduplication -By default, AshGraphql will deduplicate subscriptions based on the `context_id`. +By default, Absinthe will deduplicate subscriptions based on the `context_id`. We use the some of the context like actor and tenant to create a `context_id` for you. If you want to customize the deduplication you can do so by adding a actor function to your subscription. From 36c9f6f635e472e9164129b16df09ec7fd950acb Mon Sep 17 00:00:00 2001 From: Barnabas Jovanovics Date: Fri, 27 Sep 2024 14:22:31 +0200 Subject: [PATCH 48/48] Update documentation/topics/use-subscriptions-with-graphql.md Co-authored-by: Zach Daniel --- documentation/topics/use-subscriptions-with-graphql.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/documentation/topics/use-subscriptions-with-graphql.md b/documentation/topics/use-subscriptions-with-graphql.md index 8ab20986..b172f791 100644 --- a/documentation/topics/use-subscriptions-with-graphql.md +++ b/documentation/topics/use-subscriptions-with-graphql.md @@ -1,6 +1,6 @@ # Using Subscriptions -You can do this with Absinthe direclty, and use `AshGraphql.Subscription.query_for_subscription/3`. Here is an example of how you could do this for a subscription for a single record. This example could be extended to support lists of records as well. +You can do this with Absinthe directly, and use `AshGraphql.Subscription.query_for_subscription/3`. Here is an example of how you could do this for a subscription for a single record. This example could be extended to support lists of records as well. ```elixir # in your absinthe schema file