Skip to content

About

A Shopify GraphQL Admin API client for Elixir, built on Req: per-shop cost budgets, streaming pagination, and bulk operations.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

5 Commits

Folders and files

Repository files navigation

ShopifyClient

A client for Shopify's GraphQL Admin API, for Elixir. HTTP is handled by Req under the hood: you never have to touch it, but its options are there when you need them.

GraphQL-only, by design: Shopify's REST Admin API is legacy. There's no OAuth, sessions or webhook plumbing either. This library talks to the API, and your app owns everything else.

What it does, beyond sending a query:

  • Throttling that doesn't block needlessly. Every response's throttleStatus feeds a node-wide, per-shop cost budget, checked before each request. Choose, per call, whether to wait for budget or fail fast.
  • Errors you can act on. THROTTLED, ACCESS_DENIED, mutation userErrors, shops that are uninstalled or frozen, and transport failures all become one ShopifyClient.Error with a :reason.
  • Pagination as a lazy Stream. Pages are fetched only as they're consumed.
  • Bulk operations the current way. Tracked by id (not the deprecated currentBulkOperation), with results streamed line by line, never loaded into memory.
  • Safe by default. The access token never shows up in inspect/1, is only ever sent to *.myshopify.com, and is never sent to bulk-result storage. Throttled requests are retried; a mutation that may have run is not.

Status: pre-release (0.1). Not published to Hex yet.

Installation

def deps do
  [
    {:shopify_client, github: "fluke/shopify_client_elixir"}
  ]
end

Usage

client =
  ShopifyClient.new(
    shop: "example.myshopify.com",
    access_token: token,
    api_version: "2026-04"
  )

{:ok, %ShopifyClient.Response{data: data, cost: cost}} =
  ShopifyClient.query(client, """
  query Product($id: ID!) {
    product(id: $id) { title status }
  }
  """, %{"id" => "gid://shopify/Product/1"})

There is deliberately no default API version: pin one, and bump it on purpose.

Throttling

# Default: wait (at most :max_wait ms at a time) for budget, and retry
# THROTTLED answers up to :max_throttle_retries times.
ShopifyClient.query(client, query, vars)

# Never sleep: return {:error, %ShopifyClient.Error{reason: :throttled}} at
# once. For callers with a better fallback than waiting.
ShopifyClient.query(client, query, vars, throttle: :fail_fast)

A throttled request never ran, so ShopifyClient.Error.retry_safe?/1 is true for it. Transport errors and 5xx responses are not retried by default, because the operation may already have run.

ShopifyClient.new(
  shop: ..., access_token: ..., api_version: "2026-04",
  # One deadline for connecting, pool checkout and the response.
  timeout: 2_000,
  # Leave 500 points of the shop's budget to others sharing it (another app or
  # service using the same shop).
  reserve: 500,
  # Retry *queries* (never mutations) after a network error or 5xx.
  query_retries: 2
)

Errors

case ShopifyClient.query(client, mutation, vars) do
  {:ok, response} -> ...
  {:error, %ShopifyClient.Error{reason: :user_errors, errors: errors}} -> ...
  {:error, %ShopifyClient.Error{} = error} ->
    if ShopifyClient.Error.shop_unavailable?(error), do: mark_uninstalled(shop)
end

Pass user_errors: :ignore to get mutation userErrors back in the data instead.

Pagination

query = """
query Products($cursor: String) {
  products(first: 250, after: $cursor) {
    nodes { id title }
    pageInfo { hasNextPage endCursor }
  }
}
"""

client
|> ShopifyClient.stream(query, %{}, path: ["products"])
|> Stream.filter(&(&1["title"] =~ "Sale"))
|> Enum.take(10)

Bulk operations

{:ok, operation} = ShopifyClient.Bulk.run_query(client, "{ products { edges { node { id } } } }")
{:ok, operation} = ShopifyClient.Bulk.await(client, operation)

client
|> ShopifyClient.Bulk.stream_results(operation)
|> Stream.each(&import_product/1)
|> Stream.run()

At scale, skip polling: subscribe to the bulk_operations/finish webhook, then use ShopifyClient.Bulk.webhook_operation_id/1 and ShopifyClient.Bulk.get/3.

Customization

Req options (timeouts, proxies, a test plug) go in :req_options. For anything more, such as a tracing or logging step, use update_req/2:

client =
  ShopifyClient.new(shop: ..., access_token: ..., api_version: "2026-04",
                    req_options: [receive_timeout: 5_000])

client = ShopifyClient.update_req(client, &Req.Request.append_request_steps(&1, trace: &MyApp.trace/1))

Testing

Point the client at a Req.Test stub with req_options: [plug: {Req.Test, MyApp.Shopify}]. ShopifyClient.Test builds Shopify-shaped bodies:

Req.Test.stub(MyApp.Shopify, fn conn ->
  Req.Test.json(conn, ShopifyClient.Test.data(%{"shop" => %{"name" => "Example"}}))
end)

Req.Test.stub(MyApp.Shopify, &Req.Test.json(&1, ShopifyClient.Test.throttled()))

Telemetry

There's a [:shopify_client, :query] span per query, plus [:shopify_client, :throttle] and [:shopify_client, :deprecated] events. See ShopifyClient.Telemetry.

Related packages

  • ex_shopify_schema: typed structs for Admin API types, per API version. It pairs well with this client for decoding responses.

License

MIT

About

A Shopify GraphQL Admin API client for Elixir, built on Req: per-shop cost budgets, streaming pagination, and bulk operations.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages