Skip to content

Commit 4478db2

Browse files
authored
Merge pull request #623 from Baradoy/feat/next_graphql_request
Add GraphQLQuery and GraphQLResponse
2 parents 52420a1 + 02f699a commit 4478db2

15 files changed

Lines changed: 779 additions & 16 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
- New: Reworked webhook flow, [check readme](README.md#Webhooks) for details on how to use
99
- Deprecation: old Plugs.Webhook is being replaced and will be removed eventually
1010
- New: Add Scopes context and Scope protocol. Change GraphQL queries to expect scopes. AuthToken can be used as a scope as a fallback via the defimpl in the AuthToken file.
11+
- New: GraphQL requests through [Req](https://hexdocs.pm/req/Req.html) are now done with GraphQLQuery modules and return GraphQLResponses. Ideally we will deprecate the previoud GraphQL method once people have had a chance to move over from the old method.
1112

1213
## 0.16.2
1314

README.md

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -180,31 +180,40 @@ be found at [https://hexdocs.pm/shopify_api](https://hexdocs.pm/shopify_api).
180180

181181
## GraphQL
182182

183-
`GraphQL` implementation handles GraphQL Queries against Shopify API using HTTPoison library as client, this initial implementation consists of hitting Shopify GraphQL and returning the response in a tuple `{:ok, %Response{}} | {:error, %Response{}}` containing the response and metadata(actualQueryCost, throttleStatus).
183+
GraphQL requests against [Shopify's GraphQL API](https://shopify.dev/docs/api/admin-graphql) are done through modules that use `ShopifyAPI.GraphQL.GraphQLQuery`.
184184

185-
Configure the version to use in your config.exs, it will default to a stable version as ref'd in the [graphql module](lib/shopify_api/graphql.ex).
185+
### GraphQL Queries
186186

187+
GraphQL query modules are created with `use ShopifyAPI.GraphQL.GraphQLQuery` and implement the `ShopifyAPI.GraphQL.GraphQLQuery` behaviour. See the [GraphQLQuery](lib/shopify_api/graphql/graphql_query.ex) module for documentation.
187188

188-
```elixir
189-
config :shopify_api, ShopifyAPI.GraphQL, graphql_version: "2019-07"
190-
```
189+
### GraphQL Logging
191190

192-
### GraphQL Response
191+
Logging is handled through Telemetry (`[:shopify_api, :graphql_request, :start]`, `[:shopify_api, :graphql_request, :stop]`, `[:shopify_api, :graphql_request, :exception]`). A basic logger is proivided with [ShopifyAPI.GraphQL.TelemetryLogger](lib/shopify_api/graphql/telemetry_logger.ex) and can be used with `ShopifyAPI.GraphQL.TelemetryLogger.attach()` in `application.ex`.
193192

194-
Because `GraphQL responses` can be a little complex we are parsing/wraping responses `%HTTPoison.response` to `%GraphQL.Response`.
193+
### Handling GraphQL Errors
195194

196-
Successful response:
195+
The happy path response from `GraphQLQuery.execute/2` is `{:ok, %ShopifyAPI.GraphQL.GraphQLResponse{results: ..., errors?: false}`. In most cases you can pipe your response through `ShopifyAPI.GraphQL.GraphQLResponse.resolve/1` to get `{:ok, results} | {:error, ShopifyAPI.GraphQL.GraphQLResponse{errors?: true} | {:error, exception}`.
197196

198-
```elixir
199-
{:ok, %ShopifyAPI.GraphQL.Response{response: %{}, metadata: %{}, status_code: code}}
200-
```
197+
Unfortuneately, GraphQL is not always that simple. GraphQL makes no promises of a transactional api and you can have partial success and partial failures. In that case you will need to dig deeper into the `GraphQLResponse`. In that case, you may need to stich together the `:results` and `:user_errors` from `%GraphQLResponse{}`.
201198

202-
Failed response:
199+
There are four main types of errors returned form GraphQL
200+
- "errors" array in the response body. These can arrise from malformed queries or missing variables. This will return `{:ok, GraphQLResponse{errors?: false, errors: [_ | _]}}`
201+
- "userErrors" array at the root of the query response. This is specific to Shopify's implementation of GraphQL. This will return `{:ok, GraphQLResponse{errors?: false, user_errors: [_ | _]}}`
202+
- Non-200 responses - This will return `{:ok, GraphQLResponse{errors?: false, raw: %Req.Response{status: _}}}`
203+
- Network errors - These will return a `{:error, Exception.t()}` from the `Req` request.
204+
205+
### GraphQL version
206+
207+
Configure the version to use in your config.exs, it will default to a stable version as ref'd in the [graphql module](lib/shopify_api/graphql.ex).
203208

204209
```elixir
205-
{:error, %HTTPoison.Response{}}
210+
config :shopify_api, ShopifyAPI.GraphQL, graphql_version: "2024-10"
206211
```
207212

213+
### Previous GraphQL version
214+
215+
We are soft deprecating the old `ShopifyAPI.graphql_request/4`. It will not be marked as deprecated until people have had a chance to move over to the new method. The reasons for the move include 1) moving away from HTTPoison and towards Req. 2) Better handling of partial failures. 3) Overall cleaner implementations and more access to proper errors.
216+
208217
## Telemetry
209218

210219
The `shopify_api` library will emit events using the [`:telemetry`](https://github.com/beam-telemetry/telemetry) library. Consumers of `shopify_api` can then use these events for customized metrics aggregation and more.
@@ -213,8 +222,9 @@ The following telemetry events are generated:
213222
- `[:shopify_api, :rest_request, :failure]`
214223
- `[:shopify_api, :throttling, :over_limit]`
215224
- `[:shopify_api, :throttling, :within_limit]`
216-
- `[:shopify_api, :graphql_request, :success]`
217-
- `[:shopify_api, :graphql_request, :failure]`
225+
- `[:shopify_api, :graphql_request, :start]`
226+
- `[:shopify_api, :graphql_request, :stop]`
227+
- `[:shopify_api, :graphql_request, :exception]`
218228
- `[:shopify_api, :bulk_operation, :success]`
219229
- `[:shopify_api, :bulk_operation, :failure]`
220230

lib/shopify_api.ex

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
defmodule ShopifyAPI do
2+
alias ShopifyAPI.GraphQL.GraphQLQuery
3+
alias ShopifyAPI.GraphQL.GraphQLResponse
24
alias ShopifyAPI.RateLimiting
35
alias ShopifyAPI.Throttled
46

@@ -10,6 +12,8 @@ defmodule ShopifyAPI do
1012
@doc """
1113
A helper function for making throttled GraphQL requests.
1214
15+
Soft deprecated. Please use execute_graphql/3 or [GraphQLQuery](lib/shopify_api/graphql/graphql_query.ex) instead.
16+
1317
## Example:
1418
1519
iex> query = "mutation metafieldDelete($input: MetafieldDeleteInput!){ metafieldDelete(input: $input) {deletedId userErrors {field message }}}",
@@ -26,6 +30,16 @@ defmodule ShopifyAPI do
2630
Throttled.graphql_request(func, scope, estimated_cost)
2731
end
2832

33+
@doc """
34+
Executes the given GrahpQLQuery for the given scope.
35+
36+
See [GraphQLQuery](lib/shopify_api/graphql/graphql_query.ex) for details.
37+
"""
38+
@spec execute_graphql(GraphQLQuery.t(), ShopifyAPI.Scope.t(), keyword()) ::
39+
{:ok, GraphQLResponse.t()} | {:error, Exception.t()}
40+
def execute_graphql(%GraphQLQuery{} = query, scope, opts \\ []),
41+
do: ShopifyAPI.GraphQL.execute(query, scope, opts)
42+
2943
def request(token, func), do: Throttled.request(func, token, RateLimiting.RESTTracker)
3044

3145
@doc false

lib/shopify_api/graphql.ex

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,18 @@ defmodule ShopifyAPI.GraphQL do
55

66
require Logger
77

8-
alias ShopifyAPI.GraphQL.{JSONParseError, Response, Telemetry}
8+
alias ShopifyAPI.GraphQL.GraphQLQuery
9+
alias ShopifyAPI.GraphQL.GraphQLResponse
10+
alias ShopifyAPI.GraphQL.JSONParseError
11+
alias ShopifyAPI.GraphQL.Response
12+
alias ShopifyAPI.GraphQL.Telemetry
913
alias ShopifyAPI.JSONSerializer
1014

1115
@default_graphql_version "2020-10"
1216

1317
@log_module __MODULE__ |> to_string() |> String.trim_leading("Elixir.")
1418

19+
@type opts :: keyword()
1520
@type query_response ::
1621
{:ok, Response.t()}
1722
| {:error, JSONParseError.t() | HTTPoison.Response.t() | HTTPoison.Error.t()}
@@ -44,6 +49,42 @@ defmodule ShopifyAPI.GraphQL do
4449
logged_request(scope, url, body, headers, opts)
4550
end
4651

52+
@doc """
53+
Executes the given GrahpQLQuery for the given scope
54+
55+
Telemetry events are sent to
56+
- [:shopify_api, :graphql_request, :start],
57+
- [:shopify_api, :graphql_request, :stop],
58+
- [:shopify_api, :graphql_request, :exception]
59+
60+
ShopifyAPI.GraphQL.TelemetryLogger is provided for basic logging.
61+
"""
62+
@spec execute(GraphQLQuery.t(), ShopifyAPI.Scope.t(), opts()) ::
63+
{:ok, GraphQLResponse.success_t()}
64+
| {:ok, GraphQLResponse.failure_t()}
65+
| {:error, Exception.t()}
66+
def execute(%GraphQLQuery{} = query, scope, opts \\ []) do
67+
url = build_url(ShopifyAPI.Scopes.myshopify_domain(scope), opts)
68+
headers = build_headers(ShopifyAPI.Scopes.access_token(scope), opts)
69+
body = JSONSerializer.encode!(%{query: query.query_string, variables: query.variables})
70+
metadata = %{scope: scope, query: query}
71+
72+
:telemetry.span(
73+
[:shopify_api, :graphql_request],
74+
metadata,
75+
fn ->
76+
case Req.post(url, body: body, headers: headers) do
77+
{:ok, raw_response} ->
78+
response = GraphQLResponse.parse(raw_response, query)
79+
{{:ok, response}, Map.put(metadata, :response, response)}
80+
81+
{:error, exception} ->
82+
{{:error, exception}, Map.put(metadata, :error, exception)}
83+
end
84+
end
85+
)
86+
end
87+
4788
defp build_body(query_string), do: %{query: query_string}
4889

4990
defp insert_variables(body, variables) do
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
defmodule ShopifyAPI.GraphQL.GraphQLQuery do
2+
@moduledoc """
3+
A quiery builder for Shopify GraphQL
4+
5+
The query is made with a scope that implements `ShopifyAPI.Scope`
6+
7+
In your query file `use ShopifyAPI.GraphQL.GraphQLQuery` and implement
8+
- query_string/1
9+
- name/1 - matches the root name of the query
10+
- path/1 - a list of access functions for the returning data.
11+
12+
```elixir
13+
defmodule MyApp.Shopify.Query.ThemeList do
14+
use ShopifyAPI.GraphQL.GraphQLQuery
15+
16+
@theme_list ~S[
17+
query {
18+
themes(first: 20) {
19+
edges {
20+
node {
21+
name
22+
id
23+
role
24+
}
25+
}
26+
}
27+
}
28+
]
29+
30+
def query_string, do: @theme_list
31+
def name, do: "themes"
32+
def path, do: ["edges", Access.all(), "node"]
33+
end
34+
```
35+
36+
```elixir
37+
def list_themes(%Model.Scope{} = scope, variables) do
38+
Query.ThemeList.query()
39+
|> Query.ThemeList.assigns(variables)
40+
|> Query.ThemeList.execute(scope)
41+
|> GraphQLResponse.resolve()
42+
end
43+
```
44+
"""
45+
46+
defstruct [:name, :query_string, :variables, :path]
47+
48+
alias ShopifyAPI.GraphQL.GraphQLResponse
49+
50+
@type t :: %__MODULE__{
51+
name: String.t(),
52+
query_string: String.t(),
53+
variables: map(),
54+
path: [term()]
55+
}
56+
57+
@callback query_string() :: String.t()
58+
@callback name() :: String.t()
59+
@callback path() :: list()
60+
61+
defmacro __using__(_opts) do
62+
quote do
63+
@behaviour unquote(__MODULE__)
64+
65+
@type t :: unquote(__MODULE__).t()
66+
67+
@spec query :: t()
68+
def query do
69+
query_string()
70+
|> unquote(__MODULE__).build(name())
71+
|> append_path(path())
72+
end
73+
74+
defdelegate assign(query, key, value), to: unquote(__MODULE__)
75+
defdelegate assigns(query, map), to: unquote(__MODULE__)
76+
defdelegate append_path(query, access), to: unquote(__MODULE__)
77+
defdelegate execute(query, scope), to: unquote(__MODULE__)
78+
end
79+
end
80+
81+
def build(query_string, name) do
82+
%__MODULE__{
83+
name: name,
84+
query_string: query_string,
85+
variables: %{},
86+
path: [name]
87+
}
88+
end
89+
90+
@spec append_path(t(), any()) :: t()
91+
def append_path(%__MODULE__{} = query, access),
92+
do: %{query | path: query.path ++ List.wrap(access)}
93+
94+
@spec assign(t(), any(), any()) :: t()
95+
def assign(%__MODULE__{} = query, key, value), do: assigns(query, %{key => value})
96+
97+
@spec assigns(t(), map()) :: t()
98+
def assigns(%__MODULE__{} = query, map) when is_map(map),
99+
do: %{query | variables: Map.merge(query.variables, map)}
100+
101+
@spec execute(t(), ShopifyAPI.Scope.t()) ::
102+
{:ok, GraphQLResponse.success_t()}
103+
| {:ok, GraphQLResponse.failure_t()}
104+
| {:error, Exception.t()}
105+
def execute(query, scope), do: ShopifyAPI.GraphQL.execute(query, scope)
106+
107+
@doc """
108+
Returns a function that accesses the key/value paths as a map.
109+
110+
## Examples
111+
iex> get_in(
112+
...> %{"nodes" => [
113+
...> %{"filename" => "file1", "body" => %{"content" => "file1 content"}},
114+
...> %{"filename" => "file2", "body" => %{"content" => "file2 content"}}
115+
...> ]},
116+
...> ["nodes", GraphQLQuery.access_map(["filename"], ["body", "content"])]
117+
...> )
118+
%{"file1" => "file1 content", "file2" => "file2 content"}
119+
"""
120+
@spec access_map(term, term) :: Access.access_fun(data :: map, current_value :: term)
121+
def access_map(key, value) do
122+
fn
123+
:get, data, next when is_list(data) ->
124+
next.(Map.new(data, &{get_in(&1, key), get_in(&1, value)}))
125+
126+
:get, data, next ->
127+
next.(%{get_in(data, key) => get_in(data, value)})
128+
129+
:get_and_update, _data, _next ->
130+
raise "access_map not implemented for get_and_update"
131+
end
132+
end
133+
end
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
defmodule ShopifyAPI.GraphQL.GraphQLResponse do
2+
@doc """
3+
Results of a GraphQLQuery
4+
"""
5+
alias ShopifyAPI.GraphQL.GraphQLQuery
6+
7+
defstruct query: nil,
8+
results: nil,
9+
raw: nil,
10+
errors: [],
11+
user_errors: [],
12+
metadata: nil,
13+
errors?: false
14+
15+
@type t() :: t(any())
16+
17+
@type t(results) :: success_t(results) | failure_t(results)
18+
19+
@type success_t() :: success_t(any())
20+
@type success_t(results) :: %__MODULE__{
21+
results: results,
22+
query: GraphQLQuery.t(),
23+
raw: Req.Response.t(),
24+
errors?: true
25+
}
26+
27+
@type failure_t() :: failure_t(any())
28+
@type failure_t(results) :: %__MODULE__{
29+
results: results | nil,
30+
query: GraphQLQuery.t(),
31+
raw: Req.Response.t(),
32+
errors?: false
33+
}
34+
35+
@spec parse(Req.Response.t(), GraphQLQuery.t()) :: t()
36+
def parse(%Req.Response{} = raw, %GraphQLQuery{} = query) do
37+
%__MODULE__{query: query, raw: raw}
38+
|> set_results()
39+
|> set_errors()
40+
|> set_user_errors()
41+
end
42+
43+
@spec resolve({:ok, success_t(type)}) :: {:ok, type} when type: any()
44+
@spec resolve({:ok, failure_t(type)}) :: {:error, failure_t(type)} when type: any()
45+
@spec resolve({:error, Exception.t()}) :: {:error, Exception.t()}
46+
def resolve({:ok, %__MODULE__{errors?: false, results: results}}), do: {:ok, results}
47+
def resolve({:ok, %__MODULE__{errors?: true} = response}), do: {:error, response}
48+
def resolve({:error, error}), do: {:error, error}
49+
50+
defp set_results(
51+
%__MODULE__{raw: %Req.Response{body: %{"data" => data}, status: 200}} = graphql_response
52+
) do
53+
if is_map(data) and Map.has_key?(data, graphql_response.query.name),
54+
do: %{graphql_response | results: get_in(data, graphql_response.query.path)},
55+
else: %{graphql_response | errors?: true}
56+
end
57+
58+
defp set_results(%__MODULE__{raw: %Req.Response{body: _body}} = graphql_response),
59+
do: %{graphql_response | errors?: true}
60+
61+
defp set_errors(
62+
%__MODULE__{raw: %Req.Response{body: %{"errors" => [_ | _] = errors}}} = graphql_response
63+
),
64+
do: %{graphql_response | errors: errors, errors?: true}
65+
66+
defp set_errors(%__MODULE__{raw: %Req.Response{body: _body}} = graphql_response),
67+
do: graphql_response
68+
69+
defp set_user_errors(
70+
%__MODULE__{
71+
query: %{name: name},
72+
raw: %Req.Response{body: %{"data" => data}}
73+
} = graphql_response
74+
)
75+
when is_map(data) do
76+
case get_in(data, [name, "userErrors"]) do
77+
[_ | _] = user_errors -> %{graphql_response | user_errors: user_errors, errors?: true}
78+
_ -> graphql_response
79+
end
80+
end
81+
82+
defp set_user_errors(%__MODULE__{raw: %Req.Response{body: _body}} = graphql_response),
83+
do: %{graphql_response | errors?: true}
84+
end

0 commit comments

Comments
 (0)