diff --git a/lib/graphql/resolver.ex b/lib/graphql/resolver.ex index 7f09df7..4528e4c 100644 --- a/lib/graphql/resolver.ex +++ b/lib/graphql/resolver.ex @@ -95,7 +95,14 @@ defmodule AshGraphql.Graphql.Resolver do {result, _} -> resolution - |> Absinthe.Resolution.put_result(to_resolution(result, context, api)) + |> Absinthe.Resolution.put_result( + to_resolution( + result + |> add_read_metadata(gql_query, Ash.Resource.Info.action(query.resource, action)), + context, + api + ) + ) |> add_root_errors(api, result) |> modify_resolution(modify, modify_args) end @@ -113,7 +120,8 @@ defmodule AshGraphql.Graphql.Resolver do def resolve( %{arguments: args, context: context} = resolution, {api, resource, - %{name: query_name, type: :read_one, action: action, modify_resolution: modify}} + %{name: query_name, type: :read_one, action: action, modify_resolution: modify} = + gql_query} ) do metadata = %{ api: api, @@ -171,6 +179,9 @@ defmodule AshGraphql.Graphql.Resolver do {{:error, error}, [query, {:error, error}]} end + result = + add_read_metadata(result, gql_query, Ash.Resource.Info.action(query.resource, action)) + resolution |> Absinthe.Resolution.put_result(to_resolution(result, context, api)) |> add_root_errors(api, result) @@ -195,7 +206,7 @@ defmodule AshGraphql.Graphql.Resolver do relay?: relay?, action: action, modify_resolution: modify - }} + } = gql_query} ) do metadata = %{ api: api, @@ -247,6 +258,9 @@ defmodule AshGraphql.Graphql.Resolver do {{:error, error}, [query, {:error, error}]} end + result = + add_read_metadata(result, gql_query, Ash.Resource.Info.action(query.resource, action)) + resolution |> Absinthe.Resolution.put_result(to_resolution(result, context, api)) |> add_root_errors(api, modify_args) @@ -1187,6 +1201,32 @@ defmodule AshGraphql.Graphql.Resolver do end end + defp add_read_metadata({:ok, result}, query, action) do + {:ok, add_read_metadata(result, query, action)} + end + + defp add_read_metadata(nil, _, _), do: nil + + defp add_read_metadata(result, query, action) when is_list(result) do + show_metadata = query.show_metadata || Enum.map(Map.get(action, :metadata, []), & &1.name) + + Enum.map(result, fn record -> + do_add_read_metadata(record, show_metadata) + end) + end + + defp add_read_metadata(result, query, action) do + show_metadata = query.show_metadata || Enum.map(Map.get(action, :metadata, []), & &1.name) + + do_add_read_metadata(result, show_metadata) + end + + defp do_add_read_metadata(record, show_metadata) do + Enum.reduce(show_metadata, record, fn key, record -> + Map.put(record, key, Map.get(record.__metadata__ || %{}, key)) + end) + end + defp add_metadata(result, action_result, action) do metadata = Map.get(action, :metadata, []) diff --git a/lib/resource/query.ex b/lib/resource/query.ex index 30c3ce4..8ebcfdf 100644 --- a/lib/resource/query.ex +++ b/lib/resource/query.ex @@ -8,10 +8,14 @@ defmodule AshGraphql.Resource.Query do :allow_nil?, :modify_resolution, as_mutation?: false, + metadata_names: [], + metadata_types: [], + show_metadata: nil, + type_name: nil, relay?: false ] - @get_schema [ + @query_schema [ name: [ type: :atom, doc: "The name to use for the query.", @@ -22,27 +26,30 @@ defmodule AshGraphql.Resource.Query do doc: "The action to use for the query.", required: true ], - identity: [ + type_name: [ type: :atom, - doc: "The identity to use for looking up the record. Pass `false` to not use an identity.", - required: false - ], - allow_nil?: [ - type: :boolean, - default: true, - doc: "Whether or not the action can return nil." - ], - modify_resolution: [ - type: :mfa, doc: """ - An MFA that will be called with the resolution, the query, and the result of the action as the first three arguments (followed by the arguments in the mfa). - Must return a new absinthe resolution. This can be used to implement things like setting cookies based on resource actions. A method of using resolution context - for that is documented here: https://hexdocs.pm/absinthe_plug/Absinthe.Plug.html#module-before-send + Override the type name returned by this query. Must be set if the read action has `metadata`. - *Important* if you are modifying the context, then you should also set `as_mutation?` to true and represent - this in your graphql as a mutation. See `as_mutation?` for more. + To ignore any action metadata, set this to the same type the resource uses, or set `show_metadata` to `[]`. + To show metadata in the response, choose a new name here, like `:user_with_token` to get a response type that + includes the additional fields. """ ], + metadata_names: [ + type: :keyword_list, + default: [], + doc: "Name overrides for metadata fields on the read action." + ], + metadata_types: [ + type: :keyword_list, + default: [], + doc: "Type overrides for metadata fields on the read action." + ], + show_metadata: [ + type: {:list, :atom}, + doc: "The metadata attributes to show. Defaults to all." + ], as_mutation?: [ type: :boolean, default: false, @@ -58,48 +65,56 @@ defmodule AshGraphql.Resource.Query do ] ] + @get_schema [ + identity: [ + type: :atom, + doc: + "The identity to use for looking up the record. Pass `false` to not use an identity.", + required: false + ], + allow_nil?: [ + type: :boolean, + default: true, + doc: "Whether or not the action can return nil." + ], + modify_resolution: [ + type: :mfa, + doc: """ + An MFA that will be called with the resolution, the query, and the result of the action as the first three arguments (followed by the arguments in the mfa). + Must return a new absinthe resolution. This can be used to implement things like setting cookies based on resource actions. A method of using resolution context + for that is documented here: https://hexdocs.pm/absinthe_plug/Absinthe.Plug.html#module-before-send + + *Important* if you are modifying the context, then you should also set `as_mutation?` to true and represent + this in your graphql as a mutation. See `as_mutation?` for more. + """ + ] + ] + |> Spark.OptionsHelpers.merge_schemas(@query_schema, "Shared Query Options") + @read_one_schema [ - name: [ - type: :atom, - doc: "The name to use for the query.", - default: :read_one - ], - action: [ - type: :atom, - doc: "The action to use for the query.", - required: true - ], - allow_nil?: [ - type: :boolean, - default: true, - doc: "Whether or not the action can return nil." - ] - ] + allow_nil?: [ + type: :boolean, + default: true, + doc: "Whether or not the action can return nil." + ] + ] + |> Spark.OptionsHelpers.merge_schemas(@query_schema, "Shared Query Options") @list_schema [ - name: [ - type: :atom, - doc: "The name to use for the query.", - default: :list - ], - action: [ - type: :atom, - doc: "The action to use for the query.", - required: true - ], - relay?: [ - type: :boolean, - default: false, - doc: """ - If true, the graphql queries/resolvers for this resource will be built to honor the [relay specification](https://relay.dev/graphql/connections.htm). + relay?: [ + type: :boolean, + default: false, + doc: """ + If true, the graphql queries/resolvers for this resource will be built to honor the [relay specification](https://relay.dev/graphql/connections.htm). - The two changes that are made currently are: + The two changes that are made currently are: - * the type for the resource will implement the `Node` interface - * pagination over that resource will behave as a Connection. - """ - ] - ] + * the type for the resource will implement the `Node` interface + * pagination over that resource will behave as a Connection. + """ + ] + ] + |> Spark.OptionsHelpers.merge_schemas(@query_schema, "Shared Query Options") def get_schema, do: @get_schema def read_one_schema, do: @read_one_schema diff --git a/lib/resource/resource.ex b/lib/resource/resource.ex index 7283ddf..249b52f 100644 --- a/lib/resource/resource.ex +++ b/lib/resource/resource.ex @@ -294,6 +294,10 @@ defmodule AshGraphql.Resource do AshGraphql.Resource.Transformers.ValidateCompatibleNames ] + @verifiers [ + AshGraphql.Resource.Verifiers.VerifyQueryMetadata + ] + @sections [@graphql] @moduledoc """ @@ -313,7 +317,7 @@ defmodule AshGraphql.Resource do """ - use Spark.Dsl.Extension, sections: @sections, transformers: @transformers + use Spark.Dsl.Extension, sections: @sections, transformers: @transformers, verifiers: @verifiers @deprecated "See `AshGraphql.Resource.Info.queries/1`" defdelegate queries(resource), to: AshGraphql.Resource.Info @@ -866,7 +870,9 @@ defmodule AshGraphql.Resource do end # sobelow_skip ["DOS.StringToAtom"] - defp query_type(%{type: :list, relay?: relay?}, _resource, action, type) do + defp query_type(%{type: :list, relay?: relay?} = query, _resource, action, type) do + type = query.type_name || type + if action.pagination do cond do relay? -> @@ -890,6 +896,8 @@ defmodule AshGraphql.Resource do end defp query_type(query, _resource, _action, type) do + type = query.type_name || type + maybe_wrap_non_null(type, not query.allow_nil?) end @@ -1197,6 +1205,7 @@ defmodule AshGraphql.Resource do def type_definitions(resource, api, schema) do List.wrap(calculation_input(resource, schema)) ++ List.wrap(type_definition(resource, api, schema)) ++ + List.wrap(query_type_definitions(resource, api, schema)) ++ List.wrap(sort_input(resource, schema)) ++ List.wrap(filter_input(resource, schema)) ++ filter_field_types(resource, schema) ++ @@ -2474,6 +2483,42 @@ defmodule AshGraphql.Resource do type.identifier == :node end + def query_type_definitions(resource, api, schema) do + resource_type = AshGraphql.Resource.Info.type(resource) + + resource + |> AshGraphql.Resource.Info.queries() + |> Enum.filter(&(&1.type_name && &1.type_name != resource_type)) + |> Enum.map(fn query -> + relay? = Map.get(query, :relay?) + + interfaces = + if relay? do + [:node] + else + [] + end + + is_type_of = + if relay? do + &AshGraphql.Resource.is_node_type/1 + else + nil + end + + %Absinthe.Blueprint.Schema.ObjectTypeDefinition{ + description: Ash.Resource.Info.description(resource), + interfaces: interfaces, + fields: fields(resource, api, schema, query), + identifier: query.type_name, + module: schema, + name: Macro.camelize(to_string(query.type_name)), + __reference__: ref(__ENV__), + is_type_of: is_type_of + } + end) + end + def type_definition(resource, api, schema) do if generate_object?(resource) do type = AshGraphql.Resource.Info.type(resource) @@ -2510,14 +2555,48 @@ defmodule AshGraphql.Resource do end end - defp fields(resource, api, schema) do + defp fields(resource, api, schema, query \\ nil) do attributes(resource, schema) ++ + metadata(query, resource, schema) ++ relationships(resource, api, schema) ++ aggregates(resource, schema) ++ calculations(resource, api, schema) ++ keyset(resource, schema) end + defp metadata(nil, _resource, _schema) do + [] + end + + defp metadata(query, resource, schema) do + action = Ash.Resource.Info.action(resource, query.action) + show_metadata = query.show_metadata || Enum.map(Map.get(action, :metadata, []), & &1.name) + + action.metadata + |> Enum.filter(&(&1.name in show_metadata)) + |> Enum.map(fn metadata -> + field_type = + case query.metadata_types[metadata.name] do + nil -> + metadata.type + |> field_type(metadata, resource) + |> maybe_wrap_non_null(not metadata.allow_nil?) + + type -> + unwrap_literal_type(type) + end + + %Absinthe.Blueprint.Schema.FieldDefinition{ + description: metadata.description, + identifier: metadata.name, + module: schema, + name: to_string(query.metadata_names[metadata.name] || metadata.name), + type: field_type, + __reference__: ref(__ENV__) + } + end) + end + defp keyset(resource, schema) do case AshGraphql.Resource.Info.keyset_field(resource) do nil -> diff --git a/lib/resource/verifiers/verify_query_metadata.ex b/lib/resource/verifiers/verify_query_metadata.ex new file mode 100644 index 0000000..05ad465 --- /dev/null +++ b/lib/resource/verifiers/verify_query_metadata.ex @@ -0,0 +1,41 @@ +defmodule AshGraphql.Resource.Verifiers.VerifyQueryMetadata do + @moduledoc "Ensures that queries for actions with metadata have a type set" + use Spark.Dsl.Verifier + + alias Spark.Dsl.Transformer + + def verify(dsl) do + dsl + |> AshGraphql.Resource.Info.queries() + |> Enum.each(fn query -> + action = Ash.Resource.Info.action(dsl, query.action) + show_metadata = query.show_metadata || Enum.map(Map.get(action, :metadata, []), & &1.name) + + metadata = + action + |> Map.get(:metadata, []) + |> Enum.filter(&(&1.name in show_metadata)) + + if action && !Enum.empty?(metadata) && is_nil(query.type_name) do + resource = Transformer.get_persisted(dsl, :module) + + raise Spark.Error.DslError, + module: resource, + message: """ + Queries for actions with metadata must have a type configured on the query. + + The #{query.action} action on #{inspect(resource)} has the following metadata fields: + + #{Enum.map_join(action.metadata, "\n", &"* #{&1.name}")} + + To generate a new type and include the metadata in that type, provide a new type + name, for example `type :user_with_token`. + + To ignore the generated metadata, use the same type as the default. + """ + end + end) + + :ok + end +end diff --git a/mix.exs b/mix.exs index 92ced6f..5a234e4 100644 --- a/mix.exs +++ b/mix.exs @@ -134,7 +134,7 @@ defmodule AshGraphql.MixProject do # Run "mix help deps" to learn about dependencies. defp deps do [ - {:ash, ash_version("~> 2.4")}, + {:ash, ash_version("~> 2.5.11")}, {:absinthe_plug, "~> 1.4"}, {:absinthe, "~> 1.7"}, {:dataloader, "~> 1.0"}, diff --git a/mix.lock b/mix.lock index cf34c1e..0230d68 100644 --- a/mix.lock +++ b/mix.lock @@ -1,7 +1,7 @@ %{ "absinthe": {:hex, :absinthe, "1.7.0", "36819e7b1fd5046c9c734f27fe7e564aed3bda59f0354c37cd2df88fd32dd014", [:mix], [{:dataloader, "~> 1.0.0", [hex: :dataloader, repo: "hexpm", optional: true]}, {:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0 or ~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "566a5b5519afc9b29c4d367f0c6768162de3ec03e9bf9916f9dc2bcbe7c09643"}, "absinthe_plug": {:hex, :absinthe_plug, "1.5.5", "be913e77df1947ffb654a1cf1a90e28d84dc23241f6404053750bae513ccd52b", [:mix], [{:absinthe, "~> 1.5", [hex: :absinthe, repo: "hexpm", optional: false]}, {:plug, "~> 1.4", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "6c366615d9422444774206aff3448bb9cfb4e849e0c9a94a275085097bc67509"}, - "ash": {:hex, :ash, "2.5.4", "fc93280ee500ec1d06df9c18a47dba963c90cacc9f3b43a6de2d04489f5408a1", [:mix], [{:comparable, "~> 1.0", [hex: :comparable, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: true]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8.0", [hex: :ets, repo: "hexpm", optional: false]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: false]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:spark, ">= 0.3.0", [hex: :spark, repo: "hexpm", optional: false]}, {:stream_data, "~> 0.5.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a0196dc73d1b9dcd4fc4e59ba1359e42cfa0252b06fc4a23a043f82c5b5f3d19"}, + "ash": {:hex, :ash, "2.5.11", "eeadeba560681d53be5438a15434c121f0dc8b108cdf05e975a40fe809865df4", [:mix], [{:comparable, "~> 1.0", [hex: :comparable, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: true]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8.0", [hex: :ets, repo: "hexpm", optional: false]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: false]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:spark, ">= 0.3.0", [hex: :spark, repo: "hexpm", optional: false]}, {:stream_data, "~> 0.5.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "bf2b5e337650b7bdc805af2a6038f5126a344c0439320606f5f8db9e11962071"}, "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, "certifi": {:hex, :certifi, "2.9.0", "6f2a475689dd47f19fb74334859d460a2dc4e3252a3324bd2111b8f0429e7e21", [:rebar3], [], "hexpm", "266da46bdb06d6c6d35fde799bcb28d36d985d424ad7c08b5bb48f5b5cdd4641"}, "comparable": {:hex, :comparable, "1.0.0", "bb669e91cedd14ae9937053e5bcbc3c52bb2f22422611f43b6e38367d94a495f", [:mix], [{:typable, "~> 0.1", [hex: :typable, repo: "hexpm", optional: false]}], "hexpm", "277c11eeb1cd726e7cd41c6c199e7e52fa16ee6830b45ad4cdc62e51f62eb60c"}, @@ -36,7 +36,7 @@ "plug_crypto": {:hex, :plug_crypto, "1.2.3", "8f77d13aeb32bfd9e654cb68f0af517b371fb34c56c9f2b58fe3df1235c1251a", [:mix], [], "hexpm", "b5672099c6ad5c202c45f5a403f21a3411247f164e4a8fab056e5cd8a290f4a2"}, "sobelow": {:hex, :sobelow, "0.11.0", "cdc17e3a9f1ea78dc55dbe0a03121cb6767fef737c6d9f1e62ee7e78730abccc", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "c57807bfe6f231338b657781f89ef0320b66a0dbe779aa911d6ed27cfa14ae6e"}, "sourceror": {:hex, :sourceror, "0.11.2", "549ce48be666421ac60cfb7f59c8752e0d393baa0b14d06271d3f6a8c1b027ab", [:mix], [], "hexpm", "9ab659118896a36be6eec68ff7b0674cba372fc8e210b1e9dc8cf2b55bb70dfb"}, - "spark": {:hex, :spark, "0.3.5", "99905e681156050a713218e2b57956870b88b660dff57e7ee061b0245fc5dd50", [:mix], [{:nimble_options, "~> 0.5", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:sourceror, "~> 0.1", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "f272fe581f37123bf90568974937dbc18ce575fc43580a5a658965b35b993520"}, + "spark": {:hex, :spark, "0.3.8", "002b2855be1bc37c4c31be49cb409f08626606c71c97615e6ce57c3ab3da654a", [:mix], [{:nimble_options, "~> 0.5", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:sourceror, "~> 0.1", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "78e7491bf18ef2c2ac43684c69f0e8c4b4447ac5af3ad66e6448a9d9e0f5c411"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, "stream_data": {:hex, :stream_data, "0.5.0", "b27641e58941685c75b353577dc602c9d2c12292dd84babf506c2033cd97893e", [:mix], [], "hexpm", "012bd2eec069ada4db3411f9115ccafa38540a3c78c4c0349f151fc761b9e271"}, "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, diff --git a/test/read_test.exs b/test/read_test.exs index cc2987c..df805b3 100644 --- a/test/read_test.exs +++ b/test/read_test.exs @@ -38,6 +38,29 @@ defmodule AshGraphql.ReadTest do assert %{data: %{"postScore" => [%{"text" => "foo"}]}} = result end + test "metadata fields are rendered" do + AshGraphql.Test.User + |> Ash.Changeset.for_create(:create, + name: "My Name" + ) + |> AshGraphql.Test.Api.create!() + + resp = + """ + query CurrentUserWithMetadata { + currentUserWithMetadata { + bar + } + } + """ + |> Absinthe.run(AshGraphql.Test.Schema) + + assert {:ok, result} = resp + + refute Map.has_key?(result, :errors) + assert %{data: %{"currentUserWithMetadata" => %{"bar" => "bar"}}} = result + end + test "a read with arguments works" do AshGraphql.Test.Post |> Ash.Changeset.for_create(:create, text: "foo", published: true) diff --git a/test/support/resources/user.ex b/test/support/resources/user.ex index 58c5332..e0b742f 100644 --- a/test/support/resources/user.ex +++ b/test/support/resources/user.ex @@ -10,7 +10,12 @@ defmodule AshGraphql.Test.User do type :user queries do - read_one :current_user, :current_user + read_one(:current_user, :current_user) + + read_one :current_user_with_metadata, :current_user_with_metadata do + type_name :user_with_bar + metadata_names(foo: :bar) + end end mutations do @@ -27,6 +32,19 @@ defmodule AshGraphql.Test.User do read :current_user do filter(id: actor(:id)) end + + read :current_user_with_metadata do + metadata(:foo, :string) + + prepare(fn query, _ -> + Ash.Query.after_action(query, fn _query, results -> + {:ok, + Enum.map(results, fn result -> + Ash.Resource.put_metadata(result, :foo, "bar") + end)} + end) + end) + end end attributes do