From a20b01e1b318c8109e625ccf2bbc33821a850675 Mon Sep 17 00:00:00 2001 From: Phil Chen <06fahchen@gmail.com> Date: Fri, 17 Apr 2026 10:30:54 +0900 Subject: [PATCH] feat: add Result.assigns and prepare_req/1 callback for response metadata Allow users to capture arbitrary response metadata (e.g. GraphQL extensions) by overriding prepare_req/1 in their client module and using Result.put_resp_assign/3 in Req response steps. Closes #64 Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 17 ++ guides/extending-requests-with-prepare-req.md | 157 ++++++++++++++++++ lib/grephql.ex | 43 +++-- lib/grephql/result.ex | 60 ++++++- mix.exs | 7 +- test/grephql/result_test.exs | 39 +++++ test/grephql_test.exs | 97 +++++++++++ 7 files changed, 406 insertions(+), 14 deletions(-) create mode 100644 guides/extending-requests-with-prepare-req.md diff --git a/README.md b/README.md index 611a310..05d7f84 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Compile-time GraphQL client for Elixir. Parses and validates queries during comp - **Typed variables** — Input validation via Ecto changesets with generated `params()` type - **Union / Interface support** — Automatic type dispatch, no extra fields needed - **Req integration** — Full access to Req's middleware/plugin system, test with `Req.Test` directly +- **Response metadata** — Capture arbitrary response metadata (e.g. GraphQL `extensions`) via `prepare_req/1` callback and `Result.assigns` - **Configurable JSON library** — Defaults to Elixir 1.18+ built-in `JSON`, falls back to `Jason`, or set your own via `:json_library` - **Auto-generated docs** — `defgql` functions include `@doc` with variables, types, and generated module listing @@ -364,6 +365,22 @@ get_in(result.data, [:user, :name]) This is powered by `TypedStructor.Plugins.Access`, which is registered in every generated `typed_embedded_schema` block. +## Customizing Requests (`prepare_req/1`) + +Each client module has an overridable `prepare_req/1` callback that receives the `%Req.Request{}` before it is sent — attach Req steps, add headers, or capture response metadata into `Result.assigns`: + +```elixir +def prepare_req(req) do + Req.Request.append_response_steps(req, + request_id: fn {req, resp} -> + {req, Grephql.Result.put_resp_assign(resp, :request_id, Req.Response.get_header(resp, "x-request-id"))} + end + ) +end +``` + +See the [Customizing Requests with `prepare_req`](guides/extending-requests-with-prepare-req.md) guide for more details. + ## Testing Use `Req.Test` to stub HTTP responses without any network calls: diff --git a/guides/extending-requests-with-prepare-req.md b/guides/extending-requests-with-prepare-req.md new file mode 100644 index 0000000..82a65ee --- /dev/null +++ b/guides/extending-requests-with-prepare-req.md @@ -0,0 +1,157 @@ +# Customizing Requests with `prepare_req` + +Every client module generated by `use Grephql` includes an overridable `prepare_req/1` callback. It receives the fully built `%Req.Request{}` just before it is sent, giving you a hook to attach [Req response steps](https://hexdocs.pm/req/Req.Request.html#module-response-steps), add headers, or apply any other request-level customization. + +## How It Works + +1. Override `prepare_req/1` in your client module +2. In a Req response step, use `Grephql.Result.put_resp_assign/3` to stash values from the response +3. After decoding, those values appear in `result.assigns` + +```elixir +defmodule MyApp.API do + use Grephql, + otp_app: :my_app, + source: "priv/schemas/api.json", + endpoint: "https://api.example.com/graphql" + + def prepare_req(req) do + Req.Request.append_response_steps(req, + capture_request_id: fn {req, resp} -> + request_id = Req.Response.get_header(resp, "x-request-id") + {req, Grephql.Result.put_resp_assign(resp, :request_id, request_id)} + end + ) + end + + defgql :get_user, ~GQL""" + query GetUser($id: ID!) { + user(id: $id) { name } + } + """ +end + +{:ok, result} = MyApp.API.get_user(%{id: "1"}) +result.assigns[:request_id] #=> ["req-abc-123"] +``` + +## Storing Multiple Values + +Call `put_resp_assign/3` multiple times to store different pieces of metadata: + +```elixir +def prepare_req(req) do + Req.Request.append_response_steps(req, + capture_metadata: fn {req, resp} -> + resp = + resp + |> Grephql.Result.put_resp_assign(:extensions, resp.body["extensions"]) + |> Grephql.Result.put_resp_assign(:request_id, Req.Response.get_header(resp, "x-request-id")) + + {req, resp} + end + ) +end +``` + +## Testing + +Use `Req.Test` as usual. Include the metadata you want to capture in the stubbed response: + +```elixir +test "captures request metadata" do + Req.Test.stub(MyApp.API, fn conn -> + conn + |> Plug.Conn.put_resp_header("x-request-id", "test-123") + |> Req.Test.json(%{ + "data" => %{"user" => %{"name" => "Alice"}} + }) + end) + + assert {:ok, result} = MyApp.API.get_user(%{id: "1"}) + assert result.assigns[:request_id] == ["test-123"] +end +``` + +## Example: Shopify Rate Limiting via Extensions + +Shopify's GraphQL API returns rate-limit and cost information in the `extensions` field: + +```json +{ + "data": { "products": { ... } }, + "extensions": { + "cost": { + "requestedQueryCost": 12, + "actualQueryCost": 10, + "throttleStatus": { + "currentlyAvailable": 980, + "maximumAvailable": 1000.0, + "restoreRate": 50.0 + } + } + } +} +``` + +Capture it with `prepare_req/1`: + +```elixir +defmodule MyApp.Shopify do + use Grephql, + otp_app: :my_app, + source: "priv/schemas/shopify.json", + endpoint: "https://myshop.myshopify.com/admin/api/graphql.json" + + def prepare_req(req) do + Req.Request.append_response_steps(req, + capture_extensions: fn {req, resp} -> + {req, Grephql.Result.put_resp_assign(resp, :extensions, resp.body["extensions"])} + end + ) + end + + defgql :get_products, ~GQL""" + query GetProducts($first: Int!) { + products(first: $first) { + edges { + node { + title + } + } + } + } + """ +end +``` + +Then use the captured cost data to throttle API calls: + +```elixir +{:ok, result} = MyApp.Shopify.get_products(%{first: 10}) + +throttle_status = result.assigns[:extensions]["cost"]["throttleStatus"] +throttle_status["currentlyAvailable"] #=> 980 +throttle_status["restoreRate"] #=> 50.0 +``` + +```elixir +defmodule MyApp.Shopify.RateLimiter do + def maybe_throttle(%Grephql.Result{assigns: assigns}) do + case assigns[:extensions] do + %{"cost" => %{"throttleStatus" => %{"currentlyAvailable" => available}}} + when available < 100 -> + Process.sleep(1_000) + + _other -> + :ok + end + end +end +``` + +## API Reference + +- `prepare_req/1` — overridable callback on client modules, receives the `%Req.Request{}` before it is sent +- `Grephql.Result.put_resp_assign/3` — stores a key-value pair on the Req response for later transfer to `Result.assigns` +- `Grephql.Result` — the `:assigns` field (default `%{}`) holds all values stored via `put_resp_assign/3` diff --git a/lib/grephql.ex b/lib/grephql.ex index cea9dfc..7a600d2 100644 --- a/lib/grephql.ex +++ b/lib/grephql.ex @@ -96,6 +96,27 @@ defmodule Grephql do @doc false @spec __grephql_config__() :: {atom(), keyword()} def __grephql_config__, do: {@grephql_otp_app, @grephql_use_config} + + @doc """ + Customizes the `Req.Request` before it is sent. + + Override this callback to attach Req response steps, add headers, + or apply any other request-level configuration. + + ## Example + + def prepare_req(req) do + Req.Request.append_response_steps(req, + my_step: fn {req, resp} -> + {req, Grephql.Result.put_resp_assign(resp, :extensions, resp.body["extensions"])} + end + ) + end + """ + @spec prepare_req(Req.Request.t()) :: Req.Request.t() + def prepare_req(req), do: req + + defoverridable prepare_req: 1 end end @@ -140,7 +161,7 @@ defmodule Grephql do |> build_request(opts, json: body) |> Req.post() do {:ok, %{status: status} = response} when status >= 200 and status <= 299 -> - decode_response(response.body, query.result_module) + decode_response(response, query.result_module) {:ok, response} -> {:error, response} @@ -156,7 +177,8 @@ defmodule Grephql do defp dump_variables(variables) when is_map(variables), do: variables - defp decode_response(body, result_module) when is_map(body) do + defp decode_response(%Req.Response{body: body} = response, result_module) + when is_map(body) do data = case Map.get(body, "data") do data when is_map(data) -> ResponseDecoder.decode!(result_module, data) @@ -168,13 +190,16 @@ defmodule Grephql do |> Map.get("errors", []) |> Enum.map(&Grephql.Error.from_json/1) - {:ok, %Result{data: data, errors: errors}} + assigns = Result.assigns_from_response(response) + + {:ok, %Result{data: data, errors: errors, assigns: assigns}} end - defp decode_response(body, result_module) when is_binary(body) do + defp decode_response(%Req.Response{body: body} = response, result_module) + when is_binary(body) do case Grephql.JSON.decode(body) do {:ok, decoded} -> - decode_response(decoded, result_module) + decode_response(%{response | body: decoded}, result_module) {:error, reason} when is_exception(reason) -> {:error, reason} @@ -203,11 +228,9 @@ defmodule Grephql do config[:endpoint] || raise ArgumentError, "Grephql: :endpoint is required but was not configured" - Enum.reduce( - [use_req_opts, runtime_req_opts, exec_req_opts], - Req.new([url: endpoint] ++ base_opts), - &Req.merge(&2, &1) - ) + [use_req_opts, runtime_req_opts, exec_req_opts] + |> Enum.reduce(Req.new([url: endpoint] ++ base_opts), &Req.merge(&2, &1)) + |> client_module.prepare_req() end defp resolve_source(source, caller_file) do diff --git a/lib/grephql/result.ex b/lib/grephql/result.ex index 1fd6774..a2252b5 100644 --- a/lib/grephql/result.ex +++ b/lib/grephql/result.ex @@ -2,26 +2,80 @@ defmodule Grephql.Result do @moduledoc """ Represents a GraphQL response. - Contains the decoded `data` (typed per-query) and any `errors` - returned by the server. The type parameter `data_type` allows - `defgql`-generated functions to specify the concrete result type. + Contains the decoded `data` (typed per-query), any `errors` + returned by the server, and an `assigns` map for user-defined + metadata populated via Req response steps. ## Examples {:ok, %Grephql.Result{data: %MyClient.GetUser.Result.User{name: "Alice"}, errors: []}} {:ok, %Grephql.Result{data: nil, errors: [%Grephql.Error{message: "Not found"}]}} + + ## Assigns + + The `assigns` field lets you capture arbitrary response metadata + (e.g. rate-limit info from a GraphQL `extensions` field) by using + a Req response step in your client's `prepare_req/1` callback: + + defmodule MyApp.Shopify do + use Grephql, + otp_app: :my_app, + source: "priv/shopify_schema.json" + + def prepare_req(req) do + Req.Request.append_response_steps(req, + shopify_extensions: fn {req, resp} -> + extensions = resp.body["extensions"] + {req, Grephql.Result.put_resp_assign(resp, :extensions, extensions)} + end + ) + end + + defgql :get_products, ~GQL\"\"\" + query { products(first: 10) { edges { node { title } } } } + \"\"\" + end + + {:ok, result} = MyApp.Shopify.get_products() + result.assigns[:extensions]["cost"]["throttleStatus"] """ use TypedStructor alias Grephql.Error + @grephql_private_key :grephql + typed_structor do parameter :data_type field :data, data_type field :errors, [Error.t()], default: [] + field :assigns, map(), default: %{} end @type t() :: t(struct()) + + @doc """ + Stores a key-value pair in the Grephql assigns area of a `Req.Response`. + + Intended for use inside Req response steps. The stored assigns are + automatically transferred to `%Grephql.Result{assigns: ...}` after + the response is decoded. + """ + @spec put_resp_assign(Req.Response.t(), atom(), term()) :: Req.Response.t() + def put_resp_assign(%Req.Response{} = resp, key, value) when is_atom(key) do + assigns = + resp.private + |> Map.get(@grephql_private_key, %{}) + |> Map.put(key, value) + + put_in(resp.private[@grephql_private_key], assigns) + end + + @doc false + @spec assigns_from_response(Req.Response.t()) :: map() + def assigns_from_response(%Req.Response{private: private}) do + Map.get(private, @grephql_private_key, %{}) + end end diff --git a/mix.exs b/mix.exs index 045c4b3..74d6aa6 100644 --- a/mix.exs +++ b/mix.exs @@ -67,7 +67,7 @@ defmodule Grephql.MixProject do "Hex" => "https://hex.pm/packages/grephql" }, files: - ~w(lib priv/graphql/introspection.graphql src/*.yrl .formatter.exs mix.exs README.md LICENSE NOTICE) + ~w(lib guides priv/graphql/introspection.graphql src/*.yrl .formatter.exs mix.exs README.md LICENSE NOTICE) ] end @@ -78,8 +78,13 @@ defmodule Grephql.MixProject do source_url: @source_url, extras: [ {"README.md", [title: "Introduction"]}, + {"guides/extending-requests-with-prepare-req.md", + [title: "Customizing Requests with prepare_req"]}, {"LICENSE", [title: "License"]} ], + groups_for_extras: [ + Guides: ~r/guides\/.*/ + ], skip_undefined_reference_warnings_on: [ "Grephql.TypeMapper", "Grephql.TypeGenerator", diff --git a/test/grephql/result_test.exs b/test/grephql/result_test.exs index 8511653..ac82628 100644 --- a/test/grephql/result_test.exs +++ b/test/grephql/result_test.exs @@ -19,6 +19,12 @@ defmodule Grephql.ResultTest do assert result.errors == [] end + test "defaults assigns to empty map" do + result = %Result{} + + assert result.assigns == %{} + end + test "data defaults to nil" do result = %Result{} @@ -33,4 +39,37 @@ defmodule Grephql.ResultTest do assert length(result.errors) == 1 end end + + describe "put_resp_assign/3" do + test "stores a key-value pair in response private" do + resp = %Req.Response{status: 200, body: ""} + resp = Result.put_resp_assign(resp, :extensions, %{"cost" => 10}) + + assert resp.private.grephql == %{extensions: %{"cost" => 10}} + end + + test "preserves existing assigns" do + resp = + %Req.Response{status: 200, body: ""} + |> Result.put_resp_assign(:foo, 1) + |> Result.put_resp_assign(:bar, 2) + + assert resp.private.grephql == %{foo: 1, bar: 2} + end + end + + describe "assigns_from_response/1" do + test "extracts assigns from response private" do + resp = + Result.put_resp_assign(%Req.Response{status: 200, body: ""}, :extensions, %{"cost" => 10}) + + assert Result.assigns_from_response(resp) == %{extensions: %{"cost" => 10}} + end + + test "returns empty map when no grephql key in private" do + resp = %Req.Response{status: 200, body: ""} + + assert Result.assigns_from_response(resp) == %{} + end + end end diff --git a/test/grephql_test.exs b/test/grephql_test.exs index d6ab393..6c08614 100644 --- a/test/grephql_test.exs +++ b/test/grephql_test.exs @@ -166,6 +166,103 @@ defmodule GrephqlTest do ) end + test "prepare_req callback populates assigns from response" do + defmodule PrepareReqClient do + use Grephql, + otp_app: :grephql, + source: "support/schemas/minimal.json", + endpoint: "https://api.example.com/graphql" + + def prepare_req(req) do + Req.Request.append_response_steps(req, + capture_extensions: fn {req, resp} -> + {req, Grephql.Result.put_resp_assign(resp, :extensions, resp.body["extensions"])} + end + ) + end + + defgql(:get_user, "query { user(id: \"1\") { name } }") + end + + Req.Test.stub(PrepareReqClient, fn conn -> + conn + |> Plug.Conn.put_resp_content_type("application/json") + |> Plug.Conn.send_resp( + 200, + Jason.encode!(%{ + "data" => %{"user" => %{"name" => "Alice"}}, + "extensions" => %{ + "cost" => %{ + "requestedQueryCost" => 12, + "throttleStatus" => %{"currentlyAvailable" => 980} + } + } + }) + ) + end) + + assert {:ok, %Result{} = result} = + PrepareReqClient.get_user(req_options: [plug: {Req.Test, PrepareReqClient}]) + + assert result.data.user.name == "Alice" + assert result.assigns.extensions["cost"]["requestedQueryCost"] == 12 + assert result.assigns.extensions["cost"]["throttleStatus"]["currentlyAvailable"] == 980 + end + + test "assigns default to empty map when prepare_req is not overridden" do + Req.Test.stub(ExecuteClient, fn conn -> + conn + |> Plug.Conn.put_resp_content_type("application/json") + |> Plug.Conn.send_resp( + 200, + Jason.encode!(%{ + "data" => %{"user" => %{"name" => "Alice", "email" => "alice@example.com"}} + }) + ) + end) + + assert {:ok, %Result{} = result} = + ExecuteClient.get_user(%{id: "1"}, req_options: [plug: {Req.Test, ExecuteClient}]) + + assert result.assigns == %{} + end + + test "assigns survive when response body is raw binary" do + defmodule RawBodyClient do + use Grephql, + otp_app: :grephql, + source: "support/schemas/minimal.json", + endpoint: "https://api.example.com/graphql" + + def prepare_req(req) do + req + |> Req.Request.append_response_steps( + capture_request_id: fn {req, resp} -> + {req, Grephql.Result.put_resp_assign(resp, :request_id, "test-123")} + end + ) + |> Req.merge(decode_body: false) + end + + defgql(:get_user, "query { user(id: \"1\") { name } }") + end + + Req.Test.stub(RawBodyClient, fn conn -> + conn + |> Plug.Conn.put_resp_content_type("application/json") + |> Plug.Conn.send_resp( + 200, + Jason.encode!(%{"data" => %{"user" => %{"name" => "Alice"}}}) + ) + end) + + assert {:ok, %Result{} = result} = + RawBodyClient.get_user(req_options: [plug: {Req.Test, RawBodyClient}]) + + assert result.data.user.name == "Alice" + assert result.assigns.request_id == "test-123" + end + test "per-call req_options merge with compile-time config, not replace" do defmodule MergeClient do use Grephql,