├── .formatter.exs ├── .github ├── dependabot.yaml └── workflows │ └── ci.yml ├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── lib └── shopify │ ├── graphql.ex │ └── graphql │ ├── config.ex │ ├── helpers │ ├── limiter.ex │ ├── queue.ex │ └── url.ex │ ├── http.ex │ ├── http │ └── hackney.ex │ ├── limiter.ex │ ├── limiter │ ├── consumer.ex │ ├── consumer_supervisor.ex │ ├── partition.ex │ ├── partition_monitor.ex │ ├── producer.ex │ └── throttle_state.ex │ ├── operation.ex │ ├── request.ex │ ├── response.ex │ ├── retry.ex │ └── retry │ └── linear.ex ├── mix.exs ├── mix.lock └── test ├── shopify ├── graphql │ └── limiter_test.exs └── graphql_test.exs ├── support └── shopify │ └── graphql │ └── http │ └── mock.ex └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 3 | ] 4 | -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: github-actions 5 | 6 | directory: "/" 7 | 8 | schedule: 9 | interval: daily 10 | 11 | target-branch: master 12 | 13 | - package-ecosystem: mix 14 | 15 | directory: "/" 16 | 17 | schedule: 18 | interval: daily 19 | 20 | open-pull-requests-limit: 10 21 | 22 | target-branch: master 23 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: push 4 | 5 | jobs: 6 | dialyzer: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v4 11 | 12 | - uses: actions/cache@v4 13 | 14 | with: 15 | key: ${{ github.job }}-${{ matrix.elixir }}-${{ matrix.otp }}-${{ hashFiles('mix.lock') }}-1 16 | 17 | path: _build 18 | 19 | - uses: erlef/setup-beam@v1 20 | 21 | with: 22 | elixir-version: ${{ matrix.elixir }} 23 | 24 | otp-version: ${{ matrix.otp }} 25 | 26 | - run: mix deps.get 27 | 28 | - run: mix dialyzer 29 | 30 | strategy: 31 | matrix: 32 | elixir: 33 | - 1.12.x 34 | - 1.13.x 35 | - 1.14.x 36 | 37 | exclude: 38 | - elixir: 1.12.x 39 | 40 | otp: 25.x 41 | 42 | - elixir: 1.13.x 43 | 44 | otp: 25.x 45 | 46 | otp: 47 | - 24.x 48 | - 25.x 49 | 50 | test: 51 | runs-on: ubuntu-latest 52 | 53 | steps: 54 | - uses: actions/checkout@v4 55 | 56 | - uses: actions/cache@v4 57 | 58 | with: 59 | key: ${{ github.job }}-${{ matrix.elixir }}-${{ matrix.otp }}-${{ hashFiles('mix.lock') }}-1 60 | 61 | path: _build 62 | 63 | - uses: erlef/setup-beam@v1 64 | 65 | with: 66 | elixir-version: ${{ matrix.elixir }} 67 | 68 | otp-version: ${{ matrix.otp }} 69 | 70 | - run: mix deps.get 71 | 72 | - run: mix test 73 | 74 | strategy: 75 | matrix: 76 | elixir: 77 | - 1.12.x 78 | - 1.13.x 79 | - 1.14.x 80 | 81 | exclude: 82 | - elixir: 1.12.x 83 | 84 | otp: 25.x 85 | 86 | - elixir: 1.13.x 87 | 88 | otp: 25.x 89 | 90 | otp: 91 | - 24.x 92 | - 25.x 93 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | shopify_graphql-*.tar 24 | 25 | Ignore macOS directory config. 26 | .DS_Store 27 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": false 3 | } 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 malomohq 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Shopify.GraphQL 2 | 3 | [![Actions Status](https://github.com/malomohq/shopify-graphql-elixir/workflows/ci/badge.svg)](https://github.com/malomohq/shopify-graphql-elixir/actions) 4 | 5 | ## Installation 6 | 7 | `shopify_graphql` is published on [Hex](https://hex.pm/packages/shopify_graphql). 8 | Add it to your list of dependencies in `mix.exs`: 9 | 10 | ```elixir 11 | def deps do 12 | [ 13 | {:shopify_graphql, "~> 2.1.0"} 14 | ] 15 | end 16 | ``` 17 | 18 | You are also required to specify an HTTP client and JSON codec as dependencies. 19 | `shopify_graphql` supports `hackney` and `jason` out of the box. 20 | 21 | ## Usage 22 | 23 | You can make a request to the Shopify GraphQL admin API by passing a query to 24 | the `Shopify.GraphQL.send/2` function. 25 | 26 | ```elixir 27 | query = 28 | """ 29 | { 30 | shop { 31 | name 32 | } 33 | } 34 | """ 35 | 36 | Shopify.GraphQL.send(query, access_token: "...", shop: "myshop")) 37 | ``` 38 | 39 | You can manage variables using the `Shopify.GraphQL.put_variable/3` and 40 | `Shopify.GraphQL.put_variables/2` functions. 41 | 42 | ```elixir 43 | query = 44 | """ 45 | { 46 | query GetCustomer($customerId: ID!) { 47 | customer(id:$customerId) 48 | } 49 | } 50 | """ 51 | 52 | query 53 | |> Shopify.GraphQL.put_variable(:customerId, "gid://shopify/Customer/12195007594552") 54 | |> Shopify.GraphQL.send(access_token: "...", shop: "myshop") 55 | 56 | query 57 | |> Shopify.GraphQL.put_variables(%{customerId: "gid://shopify/Customer/12195007594552"}) 58 | |> Shopify.GraphQL.send(access_token: "...", shop: "myshop") 59 | ``` 60 | 61 | ## Configuration 62 | 63 | All configuration must be provided on a per-request basis as a keyword list to 64 | the second argument of `Shopify.GraphQL.send/2`. 65 | 66 | * `:access_token` - Shopify access token for making authenticated requests 67 | * `:endpoint` - endpoint for making GraphQL requests. Defaults to 68 | `graphql.json`. 69 | * `:http_client` - the HTTP client used for making requests. Defaults to 70 | `Shopify.GraphQL.Client.Hackney`. 71 | * `:http_client_opts` - additional options passed to `:http_client`. Defaults to 72 | `[]`. 73 | * `:http_headers` - a list of additional headers to send when making a request. 74 | Example: `[{"x-graphql-cost-include-fields", "true"}]`. Defaults 75 | to `[]`. 76 | * `:http_host` - HTTP host to make requests to. Defaults to `myshopify.com`. Note 77 | that using `:host` rather than a combination of `:host` and `:shop` 78 | may be more convenient when working with public apps. 79 | * `:http_path` - path to the admin API. Defaults to `admin/api`. 80 | * `:http_port` - the HTTP port used when making requests 81 | * `:http_protocol` - the HTTP protocol when making requests. Defaults to `https`. 82 | * `:json_codec` - codec for encoding and decoding JSON payloads 83 | * `:limiter` - whether to use the limiter to manage Shopify rate limiting. May 84 | be `true`, `false` or an atom. If `false` the limiter will not 85 | be used. If `true` the limiter will be used and the default 86 | name `Shopify.GraphQL.Limiter` will be used to interact with the 87 | limiter process. If an atom is used the limiter will be used and 88 | the atom will be used to interact with the limiter process. 89 | Defaults to `false`. 90 | * `:limiter_opts` - additional options used with `:limiter`. Defaults to `[]`. 91 | * `:max_requests` - the maximum number of concurrent requests per shop. 92 | Defaults to 3. 93 | * `:monitor` - whether to monitor a limiter. When set to `true` the limiter 94 | process will be stopped after a certain period of time of inactivity 95 | in order to keep limiter process size to a minimum. When set 96 | to `false` the limiter process will not stop and will stay 97 | alive indefinitely. Default `true`. 98 | * `:monitor_timeout` - number of miliseconds to check for inactivity before 99 | stopping a partition 100 | * `:restore_to` - the minimum cost to begin making requests again after 101 | being throttled. Possible values are `:half`, `:max` or an 102 | integer. Defaults to `:half`. 103 | * `:retry` - module implementing a strategy for retrying requests. Disabled when 104 | set to `false`. Defaults to `false` 105 | * `:retry_opts` - options for configuring retry behavior. Defaults to `[]`. 106 | * `:max_attempts` - the maximum number of retries. Defaults to `3`. 107 | * `:shop` - name of the shop that a request is being made to 108 | * `:version` - version of the API to use. Defaults to `nil`. According to 109 | Shopify, when not specifying a version Shopify will use the oldest stable 110 | version of its API. 111 | 112 | ## Rate Limiting 113 | 114 | `shopify_graphql` provides the ability to automatically manage the rate limiting 115 | of Shopify's GraphQL admin API. We do this using what's called a limiter. The 116 | limiter will automatically detect when queries are being rate limited and begin 117 | managing the traffic sent to Shopify to ensure queries get executed. 118 | 119 | The limiter is an optional feature of `shopify_graphql`. To use it you will 120 | need to add `gen_stage` as a dependency to your application. 121 | 122 | You will then need to add `Shopify.GraphQL.Limiter` to your supervision tree. 123 | When starting the limiter you may optionally pass a `:name` argument. If the 124 | `:name` argument is used the process will use that value as it's name. 125 | 126 | To send queries through the limiter you will need to pass the `limiter: true` 127 | config value to `Shopify.GraphQL.send/2`. 128 | 129 | ### Example 130 | 131 | ```elixir 132 | Shopify.GraphQL.send(query, access_token: "...", limiter: true, shop: "myshop") 133 | ``` 134 | 135 | If you named your process something other than `Shopify.GraphQL.Limiter` you 136 | will need to pass the name of the process to the `:limiter` config option 137 | instead of `true`. 138 | 139 | ## Retries 140 | 141 | `shopify_graphql` has a built-in mechanism for retrying requests that either 142 | return an HTTP status code of 500 or a client error. You can enabled retries 143 | by providing a module that implements the `Shopify.GraphQL.Retry` behaviour to the 144 | `:retry` option when calling `Shopify.GraphQL.send/2`. 145 | 146 | Currently, `shopify_graphql` provides a `Shopify.GraphQL.Retry.Linear` strategy for 147 | retrying requests. This strategy will automatically retry a request on a set 148 | interval. You can configure the interval by adding `:retry_in` with the number 149 | of milliseconds to wait before sending another request to the `:retry_opts` 150 | option. 151 | 152 | **Example** 153 | 154 | ```elixir 155 | Shopify.GraphQL.send("{ shop { name } }", access_token: "...", retry: Shopify.GraphQL.Retry.Linear, retry_opts: [retry_in: 250], shop: "myshop") 156 | ``` 157 | 158 | The example above would retry a failed request after 250 milliseconds. By 159 | default `Shopify.GraphQL.Retry.Linear` will retry a request immediately if 160 | `:retry_in` has no value 161 | -------------------------------------------------------------------------------- /lib/shopify/graphql.ex: -------------------------------------------------------------------------------- 1 | defmodule Shopify.GraphQL do 2 | alias Shopify.GraphQL.{ Config, Limiter, Operation, Request, Response } 3 | 4 | @type http_headers_t :: 5 | [{ String.t(), String.t() }] 6 | 7 | @type http_method_t :: 8 | :delete | :get | :head | :patch | :post | :put 9 | 10 | @type http_response_t :: 11 | { :ok, Response.t() } | { :error, Response.t() | any } 12 | 13 | @type http_status_code_t :: 14 | pos_integer 15 | 16 | @doc """ 17 | Add a variable to the operation. 18 | 19 | It's possible to pass either a `Shopify.GraphQL.Operation` struct or, as a 20 | convenience, a binary query. 21 | """ 22 | @spec put_variable(binary | Operation.t(), binary | atom, any) :: Operation.t() 23 | defdelegate put_variable(operation_or_query, name, value), 24 | to: Operation 25 | 26 | @doc """ 27 | Add variables to the operation. 28 | 29 | It's possible to pass either a `Shopify.GraphQL.Operation` struct or, as a 30 | convenience, a binary query. 31 | """ 32 | @spec put_variables(binary | Operation.t(), map) :: Operation.t() 33 | defdelegate put_variables(operation_or_query, map), 34 | to: Operation 35 | 36 | @doc """ 37 | Send a GraphQL operation to Shopify. 38 | 39 | It's possible to send either a `Shopify.GraphQL.Operation` struct or, as a 40 | convenience, a binary query. 41 | 42 | query = 43 | \"\"\" 44 | { 45 | shop { 46 | name 47 | } 48 | } 49 | \"\"\" 50 | 51 | operation = %Shopify.GraphQL.Operation{ query: query } 52 | 53 | Shopify.GraphQL.send(operation) 54 | 55 | or 56 | 57 | query = 58 | \"\"\" 59 | { 60 | shop { 61 | name 62 | } 63 | } 64 | \"\"\" 65 | 66 | Shopify.GraphQL.send(query) 67 | 68 | You must also pass configuration as a keyword list to the second argument. 69 | This allows you to use different config values on a per-request basis. 70 | """ 71 | @spec send(String.t() | Operation.t(), Keyword.t()) :: http_response_t 72 | def send(query, config) when is_binary(query) do 73 | __MODULE__.send(%Operation{ query: query }, config) 74 | end 75 | 76 | def send(operation, config) do 77 | config = Config.new(config) 78 | 79 | request = Request.new(operation, config) 80 | 81 | cond do 82 | config.limiter == false -> 83 | Request.send(request, config) 84 | config.limiter == true -> 85 | Limiter.send(Shopify.GraphQL.Limiter, request, config) 86 | true -> 87 | Limiter.send(config.limiter, request, config) 88 | end 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /lib/shopify/graphql/config.ex: -------------------------------------------------------------------------------- 1 | defmodule Shopify.GraphQL.Config do 2 | defstruct access_token: nil, 3 | endpoint: "/graphql.json", 4 | http_client: Shopify.GraphQL.Http.Hackney, 5 | http_client_opts: [], 6 | http_headers: [], 7 | http_host: "myshopify.com", 8 | http_path: "/admin/api", 9 | http_port: nil, 10 | http_protocol: "https", 11 | json_codec: Jason, 12 | limiter: false, 13 | limiter_opts: [], 14 | retry: false, 15 | retry_opts: [], 16 | shop: nil, 17 | version: nil 18 | 19 | @type t :: 20 | %__MODULE__{ 21 | access_token: String.t(), 22 | endpoint: String.t(), 23 | http_client: module, 24 | http_client_opts: any, 25 | http_headers: Shopify.GraphQL.http_headers_t(), 26 | http_host: String.t(), 27 | http_path: String.t(), 28 | http_port: pos_integer, 29 | http_protocol: String.t(), 30 | json_codec: module, 31 | limiter: Shopify.GraphQL.Limiter.name_t(), 32 | limiter_opts: Keyword.t(), 33 | retry: boolean, 34 | retry_opts: Keyword.t(), 35 | shop: String.t(), 36 | version: String.t() 37 | } 38 | 39 | @spec new(Keyword.t()) :: t 40 | def new(overrides) do 41 | Map.merge(%__MODULE__{}, Enum.into(overrides, %{})) 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/shopify/graphql/helpers/limiter.ex: -------------------------------------------------------------------------------- 1 | defmodule Shopify.GraphQL.Helpers.Limiter do 2 | @moduledoc false 3 | 4 | alias Shopify.GraphQL.{ Limiter } 5 | 6 | @spec pid(Limiter.name_t()) :: pid 7 | def pid({ :via, registry_mod, { registry, key } }) do 8 | [{ pid, _ }] = registry_mod.lookup(registry, key) 9 | 10 | pid 11 | end 12 | 13 | def pid({ :global, name }) do 14 | :global.whereis_name(name) 15 | end 16 | 17 | def pid(name) do 18 | Process.whereis(name) 19 | end 20 | 21 | @spec process_name(Limiter.name_t(), atom) :: Limiter.name_t() 22 | def process_name({ :via, registry_mod, { registry, key } }, name) do 23 | { :via, registry_mod, { registry, process_name(key, name) } } 24 | end 25 | 26 | def process_name({ :global, key }, name) do 27 | { :global, process_name(key, name) } 28 | end 29 | 30 | def process_name(key, name) do 31 | Module.concat([key, name]) 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/shopify/graphql/helpers/queue.ex: -------------------------------------------------------------------------------- 1 | defmodule Shopify.GraphQL.Helpers.Queue do 2 | @spec take(:queue.queue, non_neg_integer) :: { { list, non_neg_integer }, :queue.queue } 3 | def take(queue, n) do 4 | do_take(queue, :queue.new(), 0, n) 5 | end 6 | 7 | defp do_take(queue, acc, acc_len, 0) do 8 | { { :queue.to_list(acc), acc_len }, queue } 9 | end 10 | 11 | defp do_take(queue, acc, acc_len, n) do 12 | case :queue.out(queue) do 13 | { { :value, item }, queue } -> 14 | do_take(queue, :queue.in(item, acc), acc_len + 1, n - 1) 15 | { :empty, queue } -> 16 | { { :queue.to_list(acc), acc_len }, queue } 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/shopify/graphql/helpers/url.ex: -------------------------------------------------------------------------------- 1 | defmodule Shopify.GraphQL.Helpers.Url do 2 | @moduledoc false 3 | 4 | alias Shopify.GraphQL.{ Config } 5 | 6 | @spec to_string(Config.t()) :: String.t() 7 | def to_string(config) do 8 | config 9 | |> to_uri() 10 | |> URI.to_string() 11 | end 12 | 13 | @spec to_uri(Config.t()) :: URI.t() 14 | def to_uri(config) do 15 | %URI{} 16 | |> Map.put(:port, config.http_port) 17 | |> Map.put(:scheme, config.http_protocol) 18 | |> Map.put(:path, "#{config.http_path}/#{config.version}#{config.endpoint}") 19 | |> put_host(config) 20 | end 21 | 22 | defp put_host(uri, %_{ shop: shop } = config) when not is_nil(shop) do 23 | Map.put(uri, :host, "#{shop}.#{config.http_host}") 24 | end 25 | 26 | defp put_host(uri, config) do 27 | Map.put(uri, :host, config.http_host) 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/shopify/graphql/http.ex: -------------------------------------------------------------------------------- 1 | defmodule Shopify.GraphQL.Http do 2 | alias Shopify.GraphQL.{ Request } 3 | 4 | @type response_t :: 5 | %{ 6 | body: String.t(), 7 | headers: Shopify.GraphQL.http_headers_t(), 8 | status_code: Shopify.GraphQL.http_status_code_t() 9 | } 10 | 11 | @callback send( 12 | request :: Request.t(), 13 | opts :: any 14 | ) :: { :ok, response_t } | { :error, response_t | any } 15 | end 16 | -------------------------------------------------------------------------------- /lib/shopify/graphql/http/hackney.ex: -------------------------------------------------------------------------------- 1 | defmodule Shopify.GraphQL.Http.Hackney do 2 | @behaviour Shopify.GraphQL.Http 3 | 4 | @impl true 5 | def send(request, opts) do 6 | opts = opts ++ [:with_body] 7 | 8 | response = 9 | :hackney.request( 10 | request.method, 11 | request.url, 12 | request.headers, 13 | request.body, 14 | opts 15 | ) 16 | 17 | case response do 18 | { :ok, status_code, headers } -> 19 | { :ok, %{ body: "", headers: headers, status_code: status_code } } 20 | { :ok, status_code, headers, body } -> 21 | { :ok, %{ body: body, headers: headers, status_code: status_code } } 22 | otherwise -> 23 | otherwise 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/shopify/graphql/limiter.ex: -------------------------------------------------------------------------------- 1 | defmodule Shopify.GraphQL.Limiter do 2 | use DynamicSupervisor 3 | 4 | alias Shopify.GraphQL.{ Config, Limiter, Request } 5 | 6 | @type name_t :: 7 | atom | { :global, any } | { :via, module, any } 8 | 9 | # 10 | # client 11 | # 12 | 13 | @spec send(Limiter.name_t(), Request.t(), Config.t()) :: Shopify.GraphQL.http_response_t() 14 | def send(limiter, request, config) do 15 | ensure_gen_stage_loaded!() 16 | 17 | partition = Limiter.Partition.name(limiter, config) 18 | 19 | start_partition(limiter, config) 20 | 21 | Limiter.Producer.send(partition, request, config) 22 | end 23 | 24 | @spec start_link(Keyword.t()) :: Supervisor.on_start() 25 | def start_link(opts) do 26 | ensure_gen_stage_loaded!() 27 | 28 | name = Keyword.get(opts, :name, __MODULE__) 29 | 30 | DynamicSupervisor.start_link(__MODULE__, :ok, name: name) 31 | end 32 | 33 | @spec start_partition(Limiter.name_t(), Config.t()) :: { :ok, pid } | { :error, term } 34 | def start_partition(limiter, config) do 35 | partition = Limiter.Partition.name(limiter, config) 36 | 37 | opts = Keyword.new() 38 | opts = Keyword.put(opts, :limiter_opts, config.limiter_opts) 39 | opts = Keyword.put(opts, :name, partition) 40 | 41 | spec = { Limiter.Partition, opts } 42 | 43 | case DynamicSupervisor.start_child(limiter, spec) do 44 | { :error, { :already_started, _pid } } -> 45 | :ignore 46 | otherwise -> 47 | otherwise 48 | end 49 | end 50 | 51 | @doc """ 52 | Returns `true` if requests are currently being throttled due to rate limiting. 53 | Otherwise, returns `false`. 54 | """ 55 | @spec throttled?(Limiter.name_t(), Keyword.t()) :: boolean 56 | def throttled?(limiter, config) do 57 | config = Config.new(config) 58 | 59 | limiter 60 | |> Limiter.Partition.name(config) 61 | |> Limiter.Producer.waiting?() 62 | end 63 | 64 | defp ensure_gen_stage_loaded! do 65 | unless Code.ensure_loaded?(GenStage) do 66 | raise """ 67 | You are trying to use Shopify.GraphQL.Limiter but GenStage is not loaded. 68 | Make sure you have defined gen_stage as a dependency. 69 | """ 70 | end 71 | end 72 | 73 | # 74 | # callbacks 75 | # 76 | 77 | @impl true 78 | def init(:ok) do 79 | DynamicSupervisor.init(strategy: :one_for_one) 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /lib/shopify/graphql/limiter/consumer.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(GenStage) do 2 | defmodule Shopify.GraphQL.Limiter.Consumer do 3 | use Task 4 | 5 | alias Shopify.GraphQL.{ Limiter, Request } 6 | 7 | @spec start_link(map) :: { :ok, pid } 8 | def start_link(event) do 9 | Task.start_link(__MODULE__, :send, [event]) 10 | end 11 | 12 | @spec send(map) :: :ok 13 | def send(event) do 14 | config = Map.get(event, :config) 15 | 16 | limiter_opts = Map.get(event, :limiter_opts) 17 | 18 | request = Map.get(event, :request) 19 | 20 | case Request.send(request, config) do 21 | { :ok, %{ body: %{ "errors" => [%{ "message" => "Throttled" }] } } = response } -> 22 | restore_to = Keyword.get(limiter_opts, :restore_to, :half) 23 | 24 | throttle_state = Limiter.ThrottleState.new(response) 25 | 26 | retry_in = Limiter.ThrottleState.throttle_for(throttle_state, restore_to) 27 | 28 | Limiter.Producer.wait_and_retry(event[:partition], event, retry_in) 29 | otherwise -> 30 | GenStage.reply(event[:owner], otherwise) 31 | end 32 | 33 | :ok 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/shopify/graphql/limiter/consumer_supervisor.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(GenStage) do 2 | defmodule Shopify.GraphQL.Limiter.ConsumerSupervisor do 3 | use ConsumerSupervisor 4 | 5 | alias Shopify.GraphQL.{ Helpers, Limiter } 6 | 7 | # 8 | # client 9 | # 10 | 11 | @spec idle?(Supervisor.name()) :: boolean 12 | def idle?(partition) do 13 | partition 14 | |> name() 15 | |> ConsumerSupervisor.count_children() 16 | |> Map.get(:active) == 0 17 | end 18 | 19 | @spec name(Supervisor.name()) :: Supervisor.name() 20 | def name(partition) do 21 | Helpers.Limiter.process_name(partition, ConsumerSupervisor) 22 | end 23 | 24 | @spec start_link(Keyword.t()) :: GenServer.on_start() 25 | def start_link(opts) do 26 | ConsumerSupervisor.start_link(__MODULE__, opts, name: name(opts[:partition])) 27 | end 28 | 29 | # 30 | # callbacks 31 | # 32 | 33 | @impl true 34 | def init(opts) do 35 | limiter_opts = Keyword.get(opts, :limiter_opts) 36 | 37 | partition = Keyword.get(opts, :partition) 38 | 39 | max_demand = Keyword.get(limiter_opts, :max_requests, 3) 40 | 41 | min_demand = 1 42 | 43 | producer = Limiter.Producer.name(partition) 44 | 45 | ConsumerSupervisor.init(children(), strategy: :one_for_one, subscribe_to: [{ producer, min_demand: min_demand, max_demand: max_demand }]) 46 | end 47 | 48 | defp children do 49 | spec = Map.new() 50 | spec = Map.put(spec, :id, Limiter.Consumer) 51 | spec = Map.put(spec, :restart, :transient) 52 | spec = Map.put(spec, :start, { Limiter.Consumer, :start_link, [] }) 53 | 54 | [spec] 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/shopify/graphql/limiter/partition.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(GenStage) do 2 | defmodule Shopify.GraphQL.Limiter.Partition do 3 | use Supervisor, restart: :temporary 4 | 5 | alias Shopify.GraphQL.{ Config, Helpers, Limiter } 6 | 7 | # 8 | # client 9 | # 10 | 11 | @spec idle?(Supervisor.name()) :: Supervisor.name() 12 | def idle?(partition) do 13 | Limiter.Producer.idle?(partition) && 14 | Limiter.ConsumerSupervisor.idle?(partition) 15 | end 16 | 17 | @spec name(Supervisor.name(), Config.t()) :: Supervisor.name() 18 | def name(limiter, config) do 19 | id = config |> Helpers.Url.to_uri() |> Map.get(:host) 20 | 21 | Helpers.Limiter.process_name(limiter, :"Partition:#{id}") 22 | end 23 | 24 | @spec start_link(Keyword.t()) :: Supervisor.on_start() 25 | def start_link(opts) do 26 | partition_opts = Keyword.new() 27 | partition_opts = Keyword.put(partition_opts, :partition, opts[:name]) 28 | partition_opts = Keyword.put(partition_opts, :limiter_opts, opts[:limiter_opts]) 29 | 30 | Supervisor.start_link(__MODULE__, partition_opts, name: opts[:name]) 31 | end 32 | 33 | # 34 | # callbacks 35 | # 36 | 37 | @impl true 38 | def init(opts) do 39 | Supervisor.init(children(opts), strategy: :one_for_one) 40 | end 41 | 42 | defp children(opts) do 43 | [ 44 | { Limiter.PartitionMonitor, opts }, 45 | { Limiter.Producer, opts }, 46 | { Limiter.ConsumerSupervisor, opts } 47 | ] 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/shopify/graphql/limiter/partition_monitor.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(GenStage) do 2 | defmodule Shopify.GraphQL.Limiter.PartitionMonitor do 3 | use GenServer 4 | 5 | alias Shopify.GraphQL.{ Helpers, Limiter } 6 | 7 | # 8 | # client 9 | # 10 | 11 | @spec monitor?(Keyword.t()) :: boolean 12 | def monitor?(limiter_opts) do 13 | Keyword.get(limiter_opts, :monitor, true) 14 | end 15 | 16 | @spec name(Supervisor.name()) :: Supervisor.name() 17 | def name(partition) do 18 | Helpers.Limiter.process_name(partition, PartitionMonitor) 19 | end 20 | 21 | @spec pid(Supervisor.name()) :: pid 22 | def pid(partition) do 23 | partition 24 | |> name() 25 | |> Helpers.Limiter.pid() 26 | end 27 | 28 | @spec start(Supervisor.name(), Keyword.t()) :: reference 29 | def start(partition, limiter_opts) do 30 | timeout = Keyword.get(limiter_opts, :monitor_timeout, 3_500) 31 | 32 | Process.send_after(pid(partition), :check, timeout) 33 | end 34 | 35 | @spec start_link(Keyword.t()) :: GenServer.on_start() 36 | def start_link(opts) do 37 | if monitor?(opts[:limiter_opts]) do 38 | GenServer.start_link(__MODULE__, opts, name: name(opts[:partition])) 39 | else 40 | :ignore 41 | end 42 | end 43 | 44 | # 45 | # callbacks 46 | # 47 | 48 | @impl true 49 | def init(opts) do 50 | limiter_opts = Keyword.get(opts, :limiter_opts, []) 51 | 52 | partition = Keyword.get(opts, :partition) 53 | 54 | state = Map.new() 55 | state = Map.put(state, :limiter_opts, limiter_opts) 56 | state = Map.put(state, :partition, partition) 57 | state = Map.put(state, :timer, start(partition, limiter_opts)) 58 | 59 | { :ok, state } 60 | end 61 | 62 | @impl true 63 | def handle_info(:check, state) do 64 | partition = Map.get(state, :partition) 65 | 66 | if Limiter.Partition.idle?(partition) do 67 | Supervisor.stop(partition) 68 | else 69 | { :noreply, %{ state | timer: start(partition, state[:limiter_opts]) } } 70 | end 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /lib/shopify/graphql/limiter/producer.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(GenStage) do 2 | defmodule Shopify.GraphQL.Limiter.Producer do 3 | use GenStage 4 | 5 | alias Shopify.GraphQL.{ Config, Helpers, Limiter, Request } 6 | 7 | # 8 | # client 9 | # 10 | 11 | @spec idle?(Limiter.name_t()) :: boolean 12 | def idle?(partition) do 13 | GenStage.call(name(partition), :idle?) 14 | end 15 | 16 | @spec name(Limiter.name_t()) :: Limiter.name_t() 17 | def name(partition) do 18 | Helpers.Limiter.process_name(partition, Producer) 19 | end 20 | 21 | @spec send(Limiter.name_t(), Request.t(), Config.t()) :: Shopify.GraphQL.http_response_t() 22 | def send(partition, request, config) do 23 | GenStage.call(name(partition), { :send, node(), request, config }, :infinity) 24 | end 25 | 26 | @spec start_link(Keyword.t()) :: GenServer.on_start() 27 | def start_link(opts) do 28 | GenStage.start_link(__MODULE__, opts, hibernate_after: 3_500, name: name(opts[:partition])) 29 | end 30 | 31 | @spec waiting?(Limiter.name_t()) :: boolean 32 | def waiting?(partition) do 33 | GenStage.call(name(partition), :waiting?) 34 | end 35 | 36 | @spec wait_and_retry(Limiter.name_t(), map, non_neg_integer) :: :ok 37 | def wait_and_retry(partition, event, wait_for) do 38 | GenStage.call(name(partition), { :wait_and_retry, event, wait_for }) 39 | end 40 | 41 | # 42 | # callbacks 43 | # 44 | 45 | @impl true 46 | def init(opts) do 47 | state = Map.new() 48 | state = Map.put(state, :dead_owners, []) 49 | state = Map.put(state, :demand, 0) 50 | state = Map.put(state, :limiter_opts, opts[:limiter_opts]) 51 | state = Map.put(state, :partition, opts[:partition]) 52 | state = Map.put(state, :queue, :queue.new()) 53 | state = Map.put(state, :waiting, false) 54 | 55 | Process.send_after(self(), :sweep_dead_owners, 5_000) 56 | 57 | { :producer, state } 58 | end 59 | 60 | @impl true 61 | def handle_call(:idle?, _from, state) do 62 | { :reply, :queue.is_empty(state[:queue]), [], state } 63 | end 64 | 65 | @impl true 66 | def handle_call({ :send, node, request, config }, { pid, _ref } = from, state) do 67 | Process.monitor(pid) 68 | 69 | event = Map.new() 70 | event = Map.put(event, :config, config) 71 | event = Map.put(event, :limiter_opts, state[:limiter_opts]) 72 | event = Map.put(event, :partition, state[:partition]) 73 | event = Map.put(event, :request, request) 74 | event = Map.put(event, :node, node) 75 | event = Map.put(event, :owner, from) 76 | 77 | queue = :queue.in(event, state[:queue]) 78 | 79 | if state[:waiting] do 80 | { :noreply, [], %{ state | queue: queue } } 81 | else 82 | demand = Map.get(state, :demand) 83 | 84 | { { events, event_len }, queue } = Helpers.Queue.take(queue, demand) 85 | 86 | demand = demand - event_len 87 | 88 | { :noreply, events, %{ state | demand: demand, queue: queue } } 89 | end 90 | end 91 | 92 | @impl true 93 | def handle_call(:waiting?, _from, state) do 94 | { :reply, state[:waiting], [], state } 95 | end 96 | 97 | @impl true 98 | def handle_call({ :wait_and_retry, event, wait_for }, _from, state) do 99 | state[:timer] && Process.cancel_timer(state[:timer]) 100 | 101 | dead_owners = Map.get(state, :dead_owners) 102 | 103 | { pid, _ } = event[:owner] 104 | 105 | alive? = !Enum.any?(dead_owners, fn({ dead_pid, _ }) -> dead_pid == pid end) 106 | 107 | queue = (alive? && :queue.in_r(event, state[:queue])) || state[:queue] 108 | 109 | state = Map.put(state, :queue, queue) 110 | state = Map.put(state, :timer, Process.send_after(self(), :start, wait_for)) 111 | state = Map.put(state, :waiting, true) 112 | 113 | { :reply, :ok, [], state } 114 | end 115 | 116 | @impl true 117 | def handle_demand(demand, state) do 118 | demand = demand + Map.get(state, :demand, 3) 119 | 120 | if state[:waiting] do 121 | { :noreply, [], %{ state | demand: demand } } 122 | else 123 | { { events, event_len }, queue } = Helpers.Queue.take(state[:queue], demand) 124 | 125 | demand = demand - event_len 126 | 127 | { :noreply, events, %{ state | demand: demand, queue: queue } } 128 | end 129 | end 130 | 131 | @impl true 132 | def handle_info({ :DOWN, _ref, :process, pid, _reason }, state) do 133 | dead_owners = Map.get(state, :dead_owners) 134 | dead_owners = dead_owners ++ [{ pid, DateTime.utc_now() }] 135 | 136 | queue = Map.get(state, :queue) 137 | queue = :queue.delete_with(fn(%{ owner: { owner, _ } }) -> owner == pid end, queue) 138 | 139 | { :noreply, [], %{ state | dead_owners: dead_owners, queue: queue } } 140 | end 141 | 142 | @impl true 143 | def handle_info(:start, state) do 144 | demand = Map.get(state, :demand) 145 | 146 | { { events, event_len }, queue } = Helpers.Queue.take(state[:queue], demand) 147 | 148 | demand = demand - event_len 149 | 150 | state = Map.put(state, :demand, demand) 151 | state = Map.put(state, :queue, queue) 152 | state = Map.put(state, :timer, nil) 153 | state = Map.put(state, :waiting, false) 154 | 155 | { :noreply, events, state } 156 | end 157 | 158 | def handle_info(:sweep_dead_owners, state) do 159 | now = DateTime.utc_now() 160 | 161 | dead_owners = Map.get(state, :dead_owners) 162 | dead_owners = Enum.drop_while(dead_owners, fn({ _, time_of_death }) -> DateTime.diff(now, time_of_death) >= 45 end) 163 | 164 | Process.send_after(self(), :sweep_dead_owners, 5_000) 165 | 166 | { :noreply, [], %{ state | dead_owners: dead_owners } } 167 | end 168 | end 169 | end 170 | -------------------------------------------------------------------------------- /lib/shopify/graphql/limiter/throttle_state.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(GenStage) do 2 | defmodule Shopify.GraphQL.Limiter.ThrottleState do 3 | alias Shopify.GraphQL.{ Response } 4 | 5 | @type t :: 6 | %__MODULE__{ 7 | currently_available: integer, 8 | maximum_available: integer, 9 | restore_rate: integer 10 | } 11 | 12 | defstruct [:currently_available, :maximum_available, :restore_rate] 13 | 14 | @doc """ 15 | Returns a `Shopify.GraphQL.Limiter.ThrottleStatus` struct from a 16 | `Shopify.GraphQL.Response` struct. 17 | """ 18 | @spec new(Response.t()) :: t 19 | def new(response) do 20 | throttle_status = 21 | response 22 | |> Map.get(:body) 23 | |> Map.get("extensions") 24 | |> Map.get("cost") 25 | |> Map.get("throttleStatus") 26 | 27 | currently_available = Map.get(throttle_status, "currentlyAvailable") 28 | 29 | maximum_available = Map.get(throttle_status, "maximumAvailable") 30 | 31 | restore_rate = Map.get(throttle_status, "restoreRate") 32 | 33 | %__MODULE__{} 34 | |> Map.put(:currently_available, currently_available) 35 | |> Map.put(:maximum_available, maximum_available) 36 | |> Map.put(:restore_rate, restore_rate) 37 | end 38 | 39 | @spec throttle_for(t, :half | :max | non_neg_integer) :: non_neg_integer 40 | def throttle_for(throttle_state, :half) do 41 | throttle_for(throttle_state, round(throttle_state.maximum_available / 2)) 42 | end 43 | 44 | def throttle_for(throttle_state, :max) do 45 | throttle_for(throttle_state, throttle_state.maximum_available) 46 | end 47 | 48 | def throttle_for(throttle_state, to) do 49 | available = throttle_state.currently_available 50 | 51 | if available > to do 52 | 0 53 | else 54 | ceil((to - available) / throttle_state.restore_rate) * 1_000 55 | end 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/shopify/graphql/operation.ex: -------------------------------------------------------------------------------- 1 | defmodule Shopify.GraphQL.Operation do 2 | @type t :: 3 | %__MODULE__{ query: String.t(), variables: map } 4 | 5 | defstruct [query: nil, variables: %{}] 6 | 7 | @spec put_variable(binary | t, atom | binary, any) :: t 8 | def put_variable(query, name, value) when is_binary(query) do 9 | put_variable(%__MODULE__{ query: query }, name, value) 10 | end 11 | 12 | def put_variable(operation, name, value) do 13 | variables = 14 | operation 15 | |> Map.get(:variables) 16 | |> Map.put(name, value) 17 | 18 | %{ operation | variables: variables } 19 | end 20 | 21 | @spec put_variables(binary | t, map) :: t 22 | def put_variables(query, variables) when is_binary(query) do 23 | put_variables(%__MODULE__{query: query}, variables) 24 | end 25 | 26 | def put_variables(operation, variables) do 27 | variables = Map.merge(operation.variables, variables) 28 | 29 | %{operation | variables: variables} 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/shopify/graphql/request.ex: -------------------------------------------------------------------------------- 1 | defmodule Shopify.GraphQL.Request do 2 | alias Shopify.GraphQL.{ Config, Helpers, Operation, Response } 3 | 4 | defstruct [ 5 | body: nil, 6 | config: nil, 7 | headers: [], 8 | method: :post, 9 | private: %{}, 10 | url: nil 11 | ] 12 | 13 | @type t :: 14 | %__MODULE__{ 15 | body: String.t(), 16 | config: Config.t(), 17 | headers: Shopify.GraphQL.http_headers_t(), 18 | method: Shopify.GraphQL.http_method_t(), 19 | private: map, 20 | url: String.t() 21 | } 22 | 23 | @spec new(Operation.t(), Config.t()) :: t 24 | def new(operation, config) do 25 | url = Helpers.Url.to_string(config) 26 | 27 | headers = [] 28 | headers = headers ++ [{ "content-type", "application/json" }] 29 | headers = headers ++ [{ "x-shopify-access-token", config.access_token }] 30 | headers = headers ++ config.http_headers 31 | 32 | body = Map.new() 33 | body = Map.put(body, :query, operation.query) 34 | body = Map.put(body, :variables, operation.variables) 35 | body = config.json_codec.encode!(body) 36 | 37 | %__MODULE__{} 38 | |> Map.put(:body, body) 39 | |> Map.put(:config, config) 40 | |> Map.put(:headers, headers) 41 | |> Map.put(:url, url) 42 | end 43 | 44 | @spec send(t, Config.t()) :: Shopify.GraphQL.http_response_t() 45 | def send(request, config) do 46 | attempt = Map.get(request.private, :attempt, 0) 47 | 48 | attempt = attempt + 1 49 | 50 | private = Map.put(request.private, :attempt, attempt) 51 | 52 | request = Map.put(request, :private, private) 53 | 54 | request 55 | |> config.http_client.send(config.http_client_opts) 56 | |> retry(request, config) 57 | |> finish(config) 58 | end 59 | 60 | defp retry(response, _request, %_{ retry: retry }) when is_nil(retry) or retry == false do 61 | response 62 | end 63 | 64 | defp retry({ :ok, %{ status_code: status_code } } = response, request, config) when status_code >= 500 do 65 | do_retry(response, request, config) 66 | end 67 | 68 | defp retry({ :error, _ } = response, request, config) do 69 | do_retry(response, request, config) 70 | end 71 | 72 | defp retry(response, _request, _config) do 73 | response 74 | end 75 | 76 | defp do_retry(response, request, config) do 77 | attempt = Map.get(request.private, :attempt) 78 | 79 | max_attempts = Keyword.get(config.retry_opts, :max_attempts, 3) 80 | 81 | if max_attempts > attempt do 82 | seconds_to_wait = config.retry.wait_for(request, config) 83 | 84 | :timer.sleep(seconds_to_wait) 85 | 86 | request 87 | |> config.http_client.send(config.http_client_opts) 88 | |> retry(request, config) 89 | else 90 | response 91 | end 92 | end 93 | 94 | defp finish(response, config) do 95 | case response do 96 | { :ok, %{ status_code: status_code } = response } when status_code >= 400 -> 97 | { :error, Response.new(response, config) } 98 | { :ok, %{ status_code: status_code } = response } when status_code >= 200 -> 99 | { :ok, Response.new(response, config) } 100 | otherwise -> 101 | otherwise 102 | end 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /lib/shopify/graphql/response.ex: -------------------------------------------------------------------------------- 1 | defmodule Shopify.GraphQL.Response do 2 | alias Shopify.GraphQL.{ Config, Http } 3 | 4 | defstruct [:body, :headers, :status_code] 5 | 6 | @type t :: 7 | %__MODULE__{ 8 | body: term, 9 | headers: Shopify.GraphQL.http_headers_t(), 10 | status_code: Shopify.GraphQL.http_status_code_t() 11 | } 12 | 13 | @spec new(Http.response_t(), Config.t()) :: t 14 | def new(response, config) do 15 | body = 16 | response 17 | |> Map.get(:body) 18 | |> config.json_codec.decode!() 19 | 20 | %__MODULE__{} 21 | |> Map.put(:body, body) 22 | |> Map.put(:headers, Map.get(response, :headers)) 23 | |> Map.put(:status_code, Map.get(response, :status_code)) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/shopify/graphql/retry.ex: -------------------------------------------------------------------------------- 1 | defmodule Shopify.GraphQL.Retry do 2 | alias Shopify.GraphQL.{ Config, Request } 3 | 4 | @callback wait_for(request :: Request.t(), config :: Config.t()) :: non_neg_integer 5 | end 6 | -------------------------------------------------------------------------------- /lib/shopify/graphql/retry/linear.ex: -------------------------------------------------------------------------------- 1 | defmodule Shopify.GraphQL.Retry.Linear do 2 | @behaviour Shopify.GraphQL.Retry 3 | 4 | @impl true 5 | def wait_for(_request, config) do 6 | Keyword.get(config.retry_opts, :retry_in, 0) 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Shopify.GraphQL.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :shopify_graphql, 7 | version: "2.1.0", 8 | elixir: "~> 1.9", 9 | start_permanent: Mix.env() == :prod, 10 | deps: deps(), 11 | dialyzer: [plt_add_apps: [:gen_stage, :hackney]], 12 | elixirc_paths: elixirc_paths(Mix.env()), 13 | package: package(), 14 | xref: [ 15 | exclude: [ 16 | Shopify.GraphQL.Limiter.Partition, 17 | Shopify.GraphQL.Limiter.PartitionMonitor, 18 | Shopify.GraphQL.Limiter.Producer 19 | ] 20 | ] 21 | ] 22 | end 23 | 24 | def application do 25 | [ 26 | extra_applications: [:logger] 27 | ] 28 | end 29 | 30 | defp deps do 31 | [ 32 | {:gen_stage, ">= 0.14.0 and < 2.0.0", optional: true}, 33 | {:hackney, "~> 1.15", optional: true}, 34 | {:jason, "~> 1.1", optional: true}, 35 | 36 | # 37 | # dev 38 | # 39 | 40 | {:dialyxir, "~> 1.0", only: :dev, runtime: false}, 41 | {:ex_doc, "> 0.0.0", only: :dev, runtime: false} 42 | ] 43 | end 44 | 45 | defp package do 46 | %{ 47 | description: "Elixir client for the Shopify GraphQL admin API", 48 | maintainers: ["Anthony Smith"], 49 | licenses: ["MIT"], 50 | links: %{ 51 | GitHub: "https://github.com/malomohq/shopify-graphql-elixir", 52 | "Made by Malomo - Post-purchase experiences that customers love": "https://gomalomo.com" 53 | } 54 | } 55 | end 56 | 57 | defp elixirc_paths(:test) do 58 | ["lib", "test"] 59 | end 60 | 61 | defp elixirc_paths(_env) do 62 | ["lib"] 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"}, 3 | "dialyxir": {:hex, :dialyxir, "1.4.3", "edd0124f358f0b9e95bfe53a9fcf806d615d8f838e2202a9f430d59566b6b53b", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "bf2cfb75cd5c5006bec30141b131663299c661a864ec7fbbc72dfa557487a986"}, 4 | "earmark_parser": {:hex, :earmark_parser, "1.4.43", "34b2f401fe473080e39ff2b90feb8ddfeef7639f8ee0bbf71bb41911831d77c5", [:mix], [], "hexpm", "970a3cd19503f5e8e527a190662be2cee5d98eed1ff72ed9b3d1a3d466692de8"}, 5 | "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, 6 | "ex_doc": {:hex, :ex_doc, "0.37.2", "2a3aa7014094f0e4e286a82aa5194a34dd17057160988b8509b15aa6c292720c", [:mix], [{:earmark_parser, "~> 1.4.42", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "4dfa56075ce4887e4e8b1dcc121cd5fcb0f02b00391fd367ff5336d98fa49049"}, 7 | "gen_stage": {:hex, :gen_stage, "1.2.1", "19d8b5e9a5996d813b8245338a28246307fd8b9c99d1237de199d21efc4c76a1", [:mix], [], "hexpm", "83e8be657fa05b992ffa6ac1e3af6d57aa50aace8f691fcf696ff02f8335b001"}, 8 | "hackney": {:hex, :hackney, "1.20.1", "8d97aec62ddddd757d128bfd1df6c5861093419f8f7a4223823537bad5d064e2", [:rebar3], [{:certifi, "~>2.12.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "fe9094e5f1a2a2c0a7d10918fee36bfec0ec2a979994cff8cfe8058cd9af38e3"}, 9 | "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, 10 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 11 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 12 | "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, 13 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, 14 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 15 | "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, 16 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, 17 | "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, 18 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, 19 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, 20 | } 21 | -------------------------------------------------------------------------------- /test/shopify/graphql/limiter_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Shopify.GraphQL.LimiterTest do 2 | use ExUnit.Case, async: true 3 | 4 | describe "start_link/1" do 5 | test "without name" do 6 | { :ok, pid } = Shopify.GraphQL.Limiter.start_link([]) 7 | 8 | assert pid == Process.whereis(Shopify.GraphQL.Limiter) 9 | end 10 | 11 | test "with name" do 12 | { :ok, pid } = Shopify.GraphQL.Limiter.start_link(name: MyLimiter) 13 | 14 | assert pid == Process.whereis(MyLimiter) 15 | end 16 | 17 | test "as global" do 18 | { :ok, pid } = Shopify.GraphQL.Limiter.start_link(name: { :global, MyLimiter }) 19 | 20 | assert pid == :global.whereis_name(MyLimiter) 21 | end 22 | 23 | test "with registry" do 24 | { :ok, _ } = Registry.start_link(keys: :unique, name: MyRegistry) 25 | 26 | { :ok, pid } = Shopify.GraphQL.Limiter.start_link(name: { :via, Registry, { MyRegistry, MyLimiter } }) 27 | 28 | assert [{ ^pid, _ }] = Registry.lookup(MyRegistry, MyLimiter) 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /test/shopify/graphql_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Shopify.GraphQLTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Shopify.GraphQL.{ Http, Response } 5 | 6 | @ok_resp %{ body: "{\"ok\":true}", headers: [], status_code: 200 } 7 | 8 | @not_ok_resp %{ body: "{\"ok\":false}", headers: [], status_code: 400 } 9 | 10 | describe "put_variables/2" do 11 | test "puts variables when query is provided" do 12 | query = """ 13 | { 14 | shop { 15 | name 16 | } 17 | } 18 | """ 19 | 20 | variables = %{test: "test"} 21 | 22 | assert %Shopify.GraphQL.Operation{query: query, variables: variables} == 23 | Shopify.GraphQL.put_variables(query, variables) 24 | end 25 | 26 | test "puts variables when operation is provided" do 27 | query = """ 28 | { 29 | shop { 30 | name 31 | } 32 | } 33 | """ 34 | 35 | operation = %Shopify.GraphQL.Operation{query: query} 36 | 37 | variables = %{test: "test"} 38 | 39 | assert %Shopify.GraphQL.Operation{query: query, variables: variables} == 40 | Shopify.GraphQL.put_variables(operation, variables) 41 | end 42 | 43 | test "merges variables" do 44 | query = """ 45 | { 46 | shop { 47 | name 48 | } 49 | } 50 | """ 51 | 52 | operation = %Shopify.GraphQL.Operation{query: query, variables: %{one: "one"}} 53 | 54 | variables = %{two: "two"} 55 | 56 | assert %Shopify.GraphQL.Operation{query: query, variables: %{one: "one", two: "two"}} == 57 | Shopify.GraphQL.put_variables(operation, variables) 58 | end 59 | end 60 | 61 | 62 | test "sends a POST request" do 63 | Http.Mock.start_link() 64 | 65 | response = { :ok, @ok_resp } 66 | 67 | Http.Mock.put_response(response) 68 | 69 | Shopify.GraphQL.send("{ shop { name } }", http_client: Http.Mock) 70 | 71 | assert :post == Http.Mock.get_request_method() 72 | end 73 | 74 | test "sends the proper HTTP headers" do 75 | Http.Mock.start_link() 76 | 77 | response = { :ok, @ok_resp } 78 | 79 | Http.Mock.put_response(response) 80 | 81 | Shopify.GraphQL.send("{ shop { name } }", access_token: "thisisfake", http_client: Http.Mock, http_headers: [{ "x-custom-header", "true" }]) 82 | 83 | assert { "content-type", "application/json" } in Http.Mock.get_request_headers() 84 | assert { "x-shopify-access-token", "thisisfake" } in Http.Mock.get_request_headers() 85 | assert { "x-custom-header", "true" } in Http.Mock.get_request_headers() 86 | end 87 | 88 | test "returns :ok when the request is successful" do 89 | Http.Mock.start_link() 90 | 91 | response = { :ok, @ok_resp } 92 | 93 | Http.Mock.put_response(response) 94 | 95 | result = Shopify.GraphQL.send("{ shop { name } }", http_client: Http.Mock) 96 | 97 | assert { :ok, %Response{} } = result 98 | end 99 | 100 | test "returns :error when the request is not successful" do 101 | Http.Mock.start_link() 102 | 103 | response = { :ok, @not_ok_resp } 104 | 105 | Http.Mock.put_response(response) 106 | 107 | result = Shopify.GraphQL.send("{ shop { name } }", http_client: Http.Mock) 108 | 109 | assert { :error, %Response{} } = result 110 | end 111 | 112 | test "passes the response through when unrecognized" do 113 | Http.Mock.start_link() 114 | 115 | response = { :error, :timeout } 116 | 117 | Http.Mock.put_response(response) 118 | 119 | result = Shopify.GraphQL.send("{ shop { name } }", http_client: Http.Mock) 120 | 121 | assert ^response = result 122 | end 123 | 124 | test "properly handles retries" do 125 | Http.Mock.start_link() 126 | 127 | Http.Mock.put_response({ :error, :timeout }) 128 | Http.Mock.put_response({ :ok, @ok_resp }) 129 | 130 | result = Shopify.GraphQL.send("{ shop { name } }", http_client: Http.Mock, retry: Shopify.GraphQL.Retry.Linear) 131 | 132 | assert { :ok, %Response{} } = result 133 | end 134 | end 135 | -------------------------------------------------------------------------------- /test/support/shopify/graphql/http/mock.ex: -------------------------------------------------------------------------------- 1 | defmodule Shopify.GraphQL.Http.Mock do 2 | @behaviour Shopify.GraphQL.Http 3 | 4 | use GenServer 5 | 6 | @proc_key :__shopify_graphql_http_mock__ 7 | 8 | # 9 | # client 10 | # 11 | 12 | def start_link do 13 | { :ok, pid } = GenServer.start_link(__MODULE__, :ok) 14 | 15 | Process.put(@proc_key, pid) 16 | 17 | { :ok, pid } 18 | end 19 | 20 | def get_request_body do 21 | pid = Process.get(@proc_key) 22 | 23 | GenServer.call(pid, :get_request_body) 24 | end 25 | 26 | def get_request_headers do 27 | pid = Process.get(@proc_key) 28 | 29 | GenServer.call(pid, :get_request_headers) 30 | end 31 | 32 | def get_request_method do 33 | pid = Process.get(@proc_key) 34 | 35 | GenServer.call(pid, :get_request_method) 36 | end 37 | 38 | def get_request_url do 39 | pid = Process.get(@proc_key) 40 | 41 | GenServer.call(pid, :get_request_url) 42 | end 43 | 44 | def put_response(response) do 45 | pid = Process.get(@proc_key) 46 | 47 | GenServer.call(pid, { :put_response, response }) 48 | end 49 | 50 | @impl true 51 | def send(request, _opts) do 52 | pid = Process.get(@proc_key) 53 | 54 | :ok = GenServer.call(pid, { :put_request_method, request.method }) 55 | :ok = GenServer.call(pid, { :put_request_url, request.url }) 56 | :ok = GenServer.call(pid, { :put_request_headers, request.headers }) 57 | :ok = GenServer.call(pid, { :put_request_body, request.body }) 58 | 59 | GenServer.call(pid, :get_response) 60 | end 61 | 62 | # 63 | # callbacks 64 | # 65 | 66 | @impl true 67 | def init(:ok) do 68 | { :ok, %{} } 69 | end 70 | 71 | @impl true 72 | def handle_call(:get_request_body, _from, state) do 73 | { :reply, Map.fetch!(state, :request_body), state } 74 | end 75 | 76 | @impl true 77 | def handle_call(:get_request_headers, _from, state) do 78 | { :reply, Map.fetch!(state, :request_headers), state } 79 | end 80 | 81 | @impl true 82 | def handle_call(:get_request_method, _from, state) do 83 | { :reply, Map.fetch!(state, :request_method), state } 84 | end 85 | 86 | @impl true 87 | def handle_call(:get_request_url, _from, state) do 88 | { :reply, Map.fetch!(state, :request_url), state } 89 | end 90 | 91 | @impl true 92 | def handle_call(:get_response, _from, state) do 93 | [h | t] = Map.get(state, :responses, []) 94 | 95 | { :reply, h, Map.put(state, :responses, t) } 96 | end 97 | 98 | @impl true 99 | def handle_call({ :put_request_body, body }, _from, state) do 100 | { :reply, :ok, Map.put(state, :request_body, body) } 101 | end 102 | 103 | @impl true 104 | def handle_call({ :put_request_headers, headers }, _from, state) do 105 | { :reply, :ok, Map.put(state, :request_headers, headers) } 106 | end 107 | 108 | @impl true 109 | def handle_call({ :put_request_method, method }, _from, state) do 110 | { :reply, :ok, Map.put(state, :request_method, method) } 111 | end 112 | 113 | @impl true 114 | def handle_call({ :put_request_url, url }, _from, state) do 115 | { :reply, :ok, Map.put(state, :request_url, url) } 116 | end 117 | 118 | @impl true 119 | def handle_call({ :put_response, response }, _from, state) do 120 | responses = Map.get(state, :responses, []) 121 | responses = responses ++ [response] 122 | 123 | { :reply, :ok, Map.put(state, :responses, responses) } 124 | end 125 | end 126 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | 3 | Application.ensure_all_started(:bypass) 4 | 5 | Application.ensure_all_started(:hackney) 6 | --------------------------------------------------------------------------------