diff --git a/lib/absinthe/plug.ex b/lib/absinthe/plug.ex index f5a6a45..81f0907 100644 --- a/lib/absinthe/plug.ex +++ b/lib/absinthe/plug.ex @@ -155,7 +155,7 @@ defmodule Absinthe.Plug do - `:adapter` -- (Optional) Absinthe adapter to use (default: `Absinthe.Adapter.LanguageConventions`). - `:context` -- (Optional) Initial value for the Absinthe context, available to resolvers. (default: `%{}`). - `:no_query_message` -- (Optional) Message to return to the client if no query is provided (default: "No query document supplied"). - - `:json_codec` -- (Optional) A `module` or `{module, Keyword.t}` dictating which JSON codec should be used (default: `Jason`). The codec module should implement `encode!/2` (e.g., `module.encode!(body, opts)`). + - `:json_codec` -- (Optional) A `module` or `{module, Keyword.t}` dictating which JSON codec should be used (default: `Jason`). The codec module should implement `encode!/1` at minimum. Codecs that accept keyword options (like Jason) can also be configured as `{module, opts}`. Elixir 1.18+'s built-in `JSON` module is supported when passed as a bare module (i.e. `json_codec: JSON`). - `:pipeline` -- (Optional) `{module, atom}` reference to a 2-arity function that will be called to generate the processing pipeline. (default: `{Absinthe.Plug, :default_pipeline}`). - `:document_providers` -- (Optional) A `{module, atom}` reference to a 1-arity function that will be called to determine the document providers that will be used to process the request. (default: `{Absinthe.Plug, :default_document_providers}`, which configures `Absinthe.Plug.DocumentProvider.Default` as the lone document provider). A simple list of document providers can also be given. See `Absinthe.Plug.DocumentProvider` for more information about document providers, their role in processing requests, and how you can define and configure your own. - `:schema` -- (Required, if not handled by Mix.Config) The Absinthe schema to use. If a module name is not provided, `Application.get_env(:absinthe, :schema)` will be attempt to find one. @@ -602,12 +602,29 @@ defmodule Absinthe.Plug do }) do conn |> put_resp_content_type(content_type) - |> send_resp(status, mod.encode!(body, opts)) + |> send_resp(status, safe_encode!(mod, body, opts)) end @doc false def encode_json!(value, %{json_codec: json_codec}) do - json_codec.module.encode!(value, json_codec.opts) + safe_encode!(json_codec.module, value, json_codec.opts) + end + + # Safely encode a value using the given codec module and options. + # + # Elixir 1.18's built-in JSON module has a different `encode!/2` signature + # than Jason: `JSON.encode!/2` expects an encoder function as the second + # argument, not a keyword list of options. To stay compatible with both + # conventions, we call `encode!/1` when opts is empty, and only pass opts + # through when they are non-empty (which means the user chose a codec like + # Jason that actually supports keyword options). + @doc false + def safe_encode!(module, value, opts) when opts == [] do + module.encode!(value) + end + + def safe_encode!(module, value, opts) do + module.encode!(value, opts) end @doc false diff --git a/lib/absinthe/plug/graphiql.ex b/lib/absinthe/plug/graphiql.ex index 561e74d..28dbc7c 100644 --- a/lib/absinthe/plug/graphiql.ex +++ b/lib/absinthe/plug/graphiql.ex @@ -242,12 +242,12 @@ defmodule Absinthe.Plug.GraphiQL do var_string = variables - |> config.json_codec.module.encode!(pretty: true) + |> pretty_encode!(config.json_codec.module) |> js_escape result = result - |> config.json_codec.module.encode!(pretty: true) + |> pretty_encode!(config.json_codec.module) |> js_escape config = @@ -274,7 +274,7 @@ defmodule Absinthe.Plug.GraphiQL do var_string = variables - |> config.json_codec.module.encode!(pretty: true) + |> pretty_encode!(config.json_codec.module) |> js_escape config = @@ -408,7 +408,7 @@ defmodule Absinthe.Plug.GraphiQL do header_string = val |> Enum.map(fn {k, v} -> %{"name" => k, "value" => v} end) - |> config.json_codec.module.encode!(pretty: true) + |> pretty_encode!(config.json_codec.module) Map.put(config, :default_headers, header_string) @@ -473,4 +473,26 @@ defmodule Absinthe.Plug.GraphiQL do defp normalize_socket_url(%{socket_url: url} = config, _) do %{config | socket_url: "'#{url}'"} end + + # Encode a value with pretty-printing when the codec supports keyword options. + # Jason and Poison accept `encode!(value, pretty: true)`, but Elixir 1.18's + # built-in JSON module uses a different `encode!/2` signature where the second + # argument is an encoder function, not keyword options. + defp pretty_encode!(value, module) do + if supports_keyword_opts?(module) do + module.encode!(value, pretty: true) + else + module.encode!(value) + end + end + + @supported_keyword_codecs [Jason, Poison] + + defp supports_keyword_opts?(module) when module in @supported_keyword_codecs, do: true + + defp supports_keyword_opts?(module) do + # For unknown codecs, check if encode!/2 exists and is not the Elixir + # built-in JSON module (which uses encode!/2 with a function argument). + function_exported?(module, :encode!, 2) and module != JSON + end end diff --git a/test/lib/absinthe/plug_test.exs b/test/lib/absinthe/plug_test.exs index 47136bc..e0fb844 100644 --- a/test/lib/absinthe/plug_test.exs +++ b/test/lib/absinthe/plug_test.exs @@ -752,4 +752,61 @@ defmodule Absinthe.PlugTest do defp basic_opts(context) do Map.put(context, :opts, Absinthe.Plug.init(schema: TestSchema)) end + + describe "safe_encode!/3" do + test "calls encode!/1 when opts is empty" do + assert Absinthe.Plug.safe_encode!(Jason, %{a: 1}, []) == ~s({"a":1}) + end + + test "calls encode!/2 when opts is non-empty" do + result = Absinthe.Plug.safe_encode!(Jason, %{a: 1}, pretty: true) + assert result =~ "\"a\"" + assert result =~ "\n" + end + + if Code.ensure_loaded?(JSON) do + test "works with Elixir 1.18 built-in JSON module and empty opts" do + assert Absinthe.Plug.safe_encode!(JSON, %{a: 1}, []) == ~s({"a":1}) + end + end + end + + if Code.ensure_loaded?(JSON) do + describe "Elixir 1.18 JSON codec integration" do + @query """ + { + item(id: "foo") { + name + } + } + """ + + test "works as json_codec for basic queries" do + opts = Absinthe.Plug.init(schema: TestSchema, json_codec: JSON) + + assert %{status: 200, resp_body: resp_body} = + conn(:post, "/", @query) + |> put_req_header("content-type", "application/graphql") + |> plug_parser + |> Absinthe.Plug.call(opts) + + assert Jason.decode!(resp_body) == %{"data" => %{"item" => %{"name" => "Foo"}}} + end + + test "works as json_codec with JSON content type" do + opts = Absinthe.Plug.init(schema: TestSchema, json_codec: JSON) + + query_str = ~S|{ item(id: "foo") { name } }| + body = JSON.encode!(%{query: query_str}) + + assert %{status: 200, resp_body: resp_body} = + conn(:post, "/", body) + |> put_req_header("content-type", "application/json") + |> plug_parser + |> Absinthe.Plug.call(opts) + + assert Jason.decode!(resp_body) == %{"data" => %{"item" => %{"name" => "Foo"}}} + end + end + end end