Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
157 changes: 157 additions & 0 deletions guides/extending-requests-with-prepare-req.md
Original file line number Diff line number Diff line change
@@ -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`
43 changes: 33 additions & 10 deletions lib/grephql.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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}
Expand All @@ -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)
Expand All @@ -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}
Expand Down Expand Up @@ -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
Expand Down
60 changes: 57 additions & 3 deletions lib/grephql/result.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 6 additions & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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",
Expand Down
Loading
Loading