From beb710d3da8f264246342e0b8cc04b6fec9aaab8 Mon Sep 17 00:00:00 2001 From: Hez Ronningen Date: Fri, 11 Apr 2025 10:18:50 -0700 Subject: [PATCH 1/7] fix broken app installation, we need to redirect to shopify for install --- CHANGELOG.md | 2 ++ lib/shopify_api/config.ex | 7 ++++ lib/shopify_api/plugs/admin_authenticator.ex | 38 ++++++++++++++++++-- 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e296d1cd..2c4201fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## Unreleased +- Fix: 0.16.2 broke the installation path when no JWT was passed along + ## 0.16.2 - BREAKING: Reworked Plugs.AdminAuthenticator to use the new JWT Session functions, this breaks the old redirect for exchanging sessions on install diff --git a/lib/shopify_api/config.ex b/lib/shopify_api/config.ex index f4572e8f..431994af 100644 --- a/lib/shopify_api/config.ex +++ b/lib/shopify_api/config.ex @@ -3,4 +3,11 @@ defmodule ShopifyAPI.Config do def lookup(key), do: Application.get_env(:shopify_api, key) def lookup(key, subkey), do: Application.get_env(:shopify_api, key)[subkey] + + @spec app_name() :: String.t() | nil + @spec app_name(Plug.Conn.t(), keyword()) :: String.t() | nil + def app_name, do: lookup(:app_name) + + def app_name(%Plug.Conn{path_info: path_info}, opts \\ []), + do: Keyword.get(opts, :app_name) || app_name() || List.last(path_info) end diff --git a/lib/shopify_api/plugs/admin_authenticator.ex b/lib/shopify_api/plugs/admin_authenticator.ex index f16d907a..c03727b7 100644 --- a/lib/shopify_api/plugs/admin_authenticator.ex +++ b/lib/shopify_api/plugs/admin_authenticator.ex @@ -48,9 +48,9 @@ defmodule ShopifyAPI.Plugs.AdminAuthenticator do end end - defp do_authentication(conn, _options) do - token = conn.params["id_token"] - + # User auth + defp do_authentication(%{params: %{"id_token" => token}} = conn, _options) + when is_binary(token) do with {:ok, app} <- JWTSessionToken.app(token), {true, jwt, _jws} <- JWTSessionToken.verify(token, app.client_secret), :ok <- validate_hmac(app, conn.query_params), @@ -76,6 +76,38 @@ defmodule ShopifyAPI.Plugs.AdminAuthenticator do end end + # Offline token auth OR new install + defp do_authentication(conn, options) do + myshopify_domain = conn.params["shop"] + app_name = ShopifyAPI.Config.app_name(conn, options) + {:ok, app} = ShopifyAPI.AppServer.get(app_name) + + with :ok <- validate_hmac(app, conn.query_params), + {:ok, shop} <- ShopifyAPI.ShopServer.get_or_create(myshopify_domain, true), + {:ok, auth_token} <- ShopifyAPI.AuthTokenServer.get(myshopify_domain, app_name) do + conn + |> assign_app(app) + |> assign_shop(shop) + |> assign_auth_token(auth_token) + else + {:error, :invalid_hmac} -> + Logger.info("#{__MODULE__} failed hmac validation") + + conn + |> Conn.resp(401, "Not Authorized.") + |> Conn.halt() + + _err -> + oauth_url = ShopifyAPI.shopify_oauth_url(app, myshopify_domain) + Logger.info("redirecting to Shop oauth url: #{oauth_url}") + + conn + |> Conn.put_resp_header("location", oauth_url) + |> Conn.resp(unquote(302), "You are being redirected.") + |> Conn.halt() + end + end + defp should_do_authentication?(conn), do: has_hmac(conn.query_params) == :ok defp assign_app(conn, app), do: Conn.assign(conn, :app, app) From 6ffb3d461aff0f79dd86ebca33f815a560891443 Mon Sep 17 00:00:00 2001 From: Hez Ronningen Date: Fri, 11 Apr 2025 09:08:01 -0700 Subject: [PATCH 2/7] refactored webhooks - moved to using a body reader for validation - ensure validation moved in to its own plug - all the logic for settings assigns (shop, topic, etc) moved in to its own plug - setup of the route for the webhooks should now happen in the router.ex - handling and business logic for webhooks should now happen in a controller --- CHANGELOG.md | 2 + README.md | 50 ++++++++++--- config/dev.exs | 2 + config/test.exs | 2 + lib/shopify_api/config.ex | 8 ++- lib/shopify_api/model/webhook_scope.ex | 22 ++++++ lib/shopify_api/plugs/webhook.ex | 1 + .../plugs/webhook_ensure_validation.ex | 21 ++++++ lib/shopify_api/plugs/webhook_scope_setup.ex | 68 ++++++++++++++++++ lib/shopify_api/shop_server.ex | 8 +++ lib/shopify_api/webhook_hmac_validator.ex | 57 +++++++++++++++ .../plugs/webhook_scope_setup_test.exs | 69 ++++++++++++++++++ .../webhook_hmac_validator_text.exs | 72 +++++++++++++++++++ 13 files changed, 370 insertions(+), 12 deletions(-) create mode 100644 lib/shopify_api/model/webhook_scope.ex create mode 100644 lib/shopify_api/plugs/webhook_ensure_validation.ex create mode 100644 lib/shopify_api/plugs/webhook_scope_setup.ex create mode 100644 lib/shopify_api/webhook_hmac_validator.ex create mode 100644 test/shopify_api/plugs/webhook_scope_setup_test.exs create mode 100644 test/shopify_api/webhook_hmac_validator_text.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c4201fb..f3f3edaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ ## Unreleased - Fix: 0.16.2 broke the installation path when no JWT was passed along +- New: Reworked webhook flow, [check readme](README.md#Webhooks) for details on how to use +- Deprecation: old Plugs.Webhook is being replaced and will be removed eventually ## 0.16.2 diff --git a/README.md b/README.md index 38fd6605..ba46e0f5 100644 --- a/README.md +++ b/README.md @@ -102,26 +102,54 @@ end ## Webhooks -To set up your app to receive webhooks, first you'll need to add `ShopifyAPI.Plugs.Webhook` to your `Endpoint` module: - +Add a custom body reader and HMAC validation to your parser config `body_reader: {ShopifyAPI.WebhookHMACValidator, :read_body, []}` Your parser should now look like: ```elixir -plug ShopifyAPI.Plugs.Webhook, - app_name: "my-app-name", - prefix: "/shopify/webhook", - callback: {WebhookHandler, :handle_webhook, []} +plug Plug.Parsers, + parsers: [:urlencoded, :multipart, :json], + pass: ["*/*"], + body_reader: {ShopifyAPI.WebhookHMACValidator, :read_body, []}, + json_decoder: Phoenix.json_library() ``` -You'll also need to define a corresponding `WebhookHandler` module in your app: +Add a route: +```elixir +pipeline :shopify_webhook do + plug ShopifyAPI.Plugs.WebhookEnsureValidation + plug ShopifyAPI.Plugs.WebhookScopeSetup +end + +scope "/shopify/webhook", MyAppWeb do + pipe_through :shopify_webhook + # The app_name path param is optional if the `config :shopify_api, :app_name, "my_app"` is set + post "/:app_name", ShopifyWebhooksController, :webhook +end +``` +Add a controller: ```elixir -defmodule WebhookHandler do - def handle_webhook(app, shop, domain, payload) do - # TODO implement me! +defmodule SectionsAppWeb.ShopifyWebhooksController do + use SectionsAppWeb, :controller + require Logger + + def webhook( + %{assigns: %{webhook_scope: %{topic: "app_subscriptions/update"} = webhook_scope}} = conn, + params + ) do + Logger.warning("Doing work on app subscription update with params #{inspect(params)}", + myshopify_domain: webhook_scope.myshopify_domain + ) + + json(conn, %{success: true}) + end + + def webhook(%{assigns: %{webhook_scope: webhook_scope}} = conn, _params) do + Logger.warning("Unhandled webhook: #{inspect(webhook_scope.topic)}") + json(conn, %{success: true}) end end ``` -And there you go! +The old `ShopifyAPI.Plugs.Webhook` method has been deprecated. Now webhooks sent to `YOUR_URL/shopify/webhook` will be interpreted as webhooks for the `my-app-name` app. If you append an app name to the URL in the Shopify configuration, that app will be used instead (e.g. `/shopify/webhook/private-app-name`). diff --git a/config/dev.exs b/config/dev.exs index 9d501fa4..54059642 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -2,3 +2,5 @@ import Config # Do not include metadata nor timestamps in development logs config :logger, :console, format: "[$level] $message\n" + +config :shopify_api, :app_name, "shopify_test_app" diff --git a/config/test.exs b/config/test.exs index e40d0abf..fff04161 100644 --- a/config/test.exs +++ b/config/test.exs @@ -7,3 +7,5 @@ config :bypass, adapter: Plug.Adapters.Cowboy2 config :shopify_api, customer_api_secret_keys: ["new_secret", "old_secret"], transport: "http" + +config :shopify_api, :app_name, "testapp" diff --git a/lib/shopify_api/config.ex b/lib/shopify_api/config.ex index 431994af..e52f04cf 100644 --- a/lib/shopify_api/config.ex +++ b/lib/shopify_api/config.ex @@ -1,6 +1,5 @@ defmodule ShopifyAPI.Config do @moduledoc false - def lookup(key), do: Application.get_env(:shopify_api, key) def lookup(key, subkey), do: Application.get_env(:shopify_api, key)[subkey] @@ -10,4 +9,11 @@ defmodule ShopifyAPI.Config do def app_name(%Plug.Conn{path_info: path_info}, opts \\ []), do: Keyword.get(opts, :app_name) || app_name() || List.last(path_info) + + @spec app() :: ShopifyAPI.App.t() | nil + def app do + with app_name when is_binary(app_name) <- app_name() do + ShopifyAPI.AppServer.get(app_name) + end + end end diff --git a/lib/shopify_api/model/webhook_scope.ex b/lib/shopify_api/model/webhook_scope.ex new file mode 100644 index 00000000..119ed715 --- /dev/null +++ b/lib/shopify_api/model/webhook_scope.ex @@ -0,0 +1,22 @@ +defmodule ShopifyAPI.Model.WebhookScope do + @type t() :: %__MODULE__{ + shopify_api_version: String.t(), + shopify_webhook_id: String.t(), + # Using the Shopify CLI the event id can be unset + shopify_event_id: String.t() | nil, + topic: String.t(), + myshopify_domain: String.t(), + app: ShopifyAPI.App.t(), + shop: ShopifyAPI.Shop.t() + } + + defstruct [ + :shopify_api_version, + :shopify_webhook_id, + :shopify_event_id, + :topic, + :myshopify_domain, + :app, + :shop + ] +end diff --git a/lib/shopify_api/plugs/webhook.ex b/lib/shopify_api/plugs/webhook.ex index 2ce69849..c34ae00f 100644 --- a/lib/shopify_api/plugs/webhook.ex +++ b/lib/shopify_api/plugs/webhook.ex @@ -1,4 +1,5 @@ defmodule ShopifyAPI.Plugs.Webhook do + @deprecated "Please use the WebhookHMACValidator and WebhookScopeSetup see the readme for examples" @moduledoc """ A Plug to handle incoming webhooks from Shopify. diff --git a/lib/shopify_api/plugs/webhook_ensure_validation.ex b/lib/shopify_api/plugs/webhook_ensure_validation.ex new file mode 100644 index 00000000..f5c1c9f9 --- /dev/null +++ b/lib/shopify_api/plugs/webhook_ensure_validation.ex @@ -0,0 +1,21 @@ +defmodule ShopifyAPI.Plugs.WebhookEnsureValidation do + require Logger + import Plug.Conn, only: [send_resp: 3, halt: 1] + + def init(opts), do: opts + + def call(%Plug.Conn{assigns: %{shopify_hmac_validated: true}} = conn, _opts), do: conn + + def call(%Plug.Conn{assigns: %{shopify_hmac_validated: false}} = conn, _opts), + do: send_failure(conn, "HMAC was invalid") + + def call(conn, _opts), do: send_failure(conn, "HMAC validation did not happen") + + defp send_failure(conn, msg) do + Logger.error(msg) + + conn + |> send_resp(200, "Failed HMAC Validation") + |> halt() + end +end diff --git a/lib/shopify_api/plugs/webhook_scope_setup.ex b/lib/shopify_api/plugs/webhook_scope_setup.ex new file mode 100644 index 00000000..f60ff8f7 --- /dev/null +++ b/lib/shopify_api/plugs/webhook_scope_setup.ex @@ -0,0 +1,68 @@ +defmodule ShopifyAPI.Plugs.WebhookScopeSetup do + @moduledoc """ + The Webhook Scope Setup plug reads all the shopify headers and assigns + a %Model.WebHookScope{} to the conn. + + ## Options + + - app_name: optional, the name of the app for look up in the AppServer if left blank + it will use the Application Config or the last element of the request path. + + ## Usage + + This should be put in a pipeline afer the ensure validation plug in your router + on your webhook endpoint. + + ```elixir + pipeline :shopify_webhook do + plug ShopifyAPI.Plugs.WebhookEnsureValidation + plug ShopifyAPI.Plugs.WebhookScopeSetup + end + ``` + """ + require Logger + + import Plug.Conn, only: [assign: 3, get_req_header: 2] + + @shopify_topic_header "x-shopify-topic" + @shopify_myshopify_domain_header "x-shopify-shop-domain" + @shopify_api_version_header "x-shopify-api-version" + @shopify_webhook_id_header "x-shopify-webhook-id" + @shopify_event_id_header "x-shopify-event-id" + + def init(opts), do: opts + + def call(%Plug.Conn{} = conn, opts) do + with app_name when is_binary(app_name) <- ShopifyAPI.Config.app_name(conn, opts), + {:ok, %ShopifyAPI.App{} = app} <- ShopifyAPI.AppServer.get(app_name), + myshopify_domain when is_binary(myshopify_domain) <- myshopify_domain(conn) do + webhook_scope = %ShopifyAPI.Model.WebhookScope{ + shopify_api_version: shopify_api_version(conn), + shopify_webhook_id: shopify_webhook_id(conn), + shopify_event_id: shopify_event_id(conn), + topic: webhook_topic(conn), + myshopify_domain: myshopify_domain, + app: app, + shop: ShopifyAPI.ShopServer.find(myshopify_domain) + } + + assign(conn, :webhook_scope, webhook_scope) + else + error -> + Logger.debug("error setting up webhook scope #{inspect(error)}") + conn + end + end + + @spec webhook_topic(Plug.Conn.t()) :: String.t() | nil + def webhook_topic(%Plug.Conn{} = conn), do: get_header(conn, @shopify_topic_header) + + def myshopify_domain(%Plug.Conn{} = conn), + do: get_header(conn, @shopify_myshopify_domain_header) + + def shopify_api_version(%Plug.Conn{} = conn), do: get_header(conn, @shopify_api_version_header) + def shopify_webhook_id(%Plug.Conn{} = conn), do: get_header(conn, @shopify_webhook_id_header) + def shopify_event_id(%Plug.Conn{} = conn), do: get_header(conn, @shopify_event_id_header) + + defp get_header(conn, key), do: conn |> get_req_header(key) |> List.first() +end diff --git a/lib/shopify_api/shop_server.ex b/lib/shopify_api/shop_server.ex index 331e598e..5b84a628 100644 --- a/lib/shopify_api/shop_server.ex +++ b/lib/shopify_api/shop_server.ex @@ -34,6 +34,14 @@ defmodule ShopifyAPI.ShopServer do end end + @spec get(String.t()) :: Shop.t() | nil + def find(domain) do + case get(domain) do + {:ok, shop} -> shop + _ -> nil + end + end + @spec get_or_create(String.t(), boolean()) :: {:ok, Shop.t()} def get_or_create(domain, should_persist \\ true) do case get(domain) do diff --git a/lib/shopify_api/webhook_hmac_validator.ex b/lib/shopify_api/webhook_hmac_validator.ex new file mode 100644 index 00000000..ce885164 --- /dev/null +++ b/lib/shopify_api/webhook_hmac_validator.ex @@ -0,0 +1,57 @@ +defmodule ShopifyAPI.WebhookHMACValidator do + @moduledoc """ + A custom body reader to handle authenticating incoming webhooks from Shopify. + + This plug reads the body and verifies the HMAC if the header is present setting a + `shopify_hmac_validated` on the conn's assigns. + + ## Usage + + Add the following configuration to your endpoint.ex for the Plug.Parser config. + `body_reader: {ShopifyAPI.WebhookHMACValidator, :read_body, []},` + + Should now look something like: + ```elixir + plug Plug.Parsers, + parsers: [:urlencoded, :multipart, :json], + pass: ["*/*"], + body_reader: {ShopifyAPI.WebhookHMACValidator, :read_body, []}, + json_decoder: Phoenix.json_library() + ``` + + ## Options + + - app_name: optional, the name of the app for look up in the AppServer if left blank + it will use the Application Config or the last element of the request path. + """ + require Logger + import Plug.Conn, only: [assign: 3, get_req_header: 2] + + @shopify_hmac_header "x-shopify-hmac-sha256" + + def read_body(conn, opts) do + with {:ok, body, conn} <- Plug.Conn.read_body(conn, opts) do + conn = assign_hmac_validation(conn, body, opts) + {:ok, body, conn} + end + end + + def assign_hmac_validation(conn, body, opts) do + with shopify_hmac when is_binary(shopify_hmac) <- get_header(conn, @shopify_hmac_header), + app_name when is_binary(app_name) <- ShopifyAPI.Config.app_name(conn, opts), + {:ok, %ShopifyAPI.App{client_secret: client_secret}} <- + ShopifyAPI.AppServer.get(app_name) do + payload_hmac = ShopifyAPI.Security.base64_sha256_hmac(body, client_secret) + + assign( + conn, + :shopify_hmac_validated, + Plug.Crypto.secure_compare(shopify_hmac, payload_hmac) + ) + else + _ -> conn + end + end + + defp get_header(conn, key), do: conn |> get_req_header(key) |> List.first() +end diff --git a/test/shopify_api/plugs/webhook_scope_setup_test.exs b/test/shopify_api/plugs/webhook_scope_setup_test.exs new file mode 100644 index 00000000..f15b6b40 --- /dev/null +++ b/test/shopify_api/plugs/webhook_scope_setup_test.exs @@ -0,0 +1,69 @@ +defmodule ShopifyAPI.Plugs.WebhookScopeSetupTest do + use ExUnit.Case, async: true + + import Plug.Test + import Plug.Conn + import ShopifyAPI.Factory + + alias ShopifyAPI.{AppServer, ShopServer} + alias ShopifyAPI.Model + alias ShopifyAPI.Plugs.WebhookScopeSetup + + setup do + app = build(:app) + shop = build(:shop) + AppServer.set(app) + ShopServer.set(shop) + + conn = + :post + |> conn("/shopify/webhooks/", Jason.encode!(%{"id" => 1234})) + |> put_req_header("content-type", "application/json") + + [conn: conn, app: app, shop: shop] + end + + @api_version "2025-04" + @topic "orders/create" + + describe "call/2 with app name and myshopify domain" do + test "sets webhook scope on assigns", %{conn: conn, app: app, shop: shop} do + conn = + conn + |> put_req_header("x-shopify-shop-domain", shop.domain) + |> put_req_header("x-shopify-topic", @topic) + |> put_req_header("x-shopify-api-version", @api_version) + |> WebhookScopeSetup.call([]) + + %{assigns: %{webhook_scope: %Model.WebhookScope{} = webhook_scope}} = conn + assert webhook_scope.myshopify_domain == shop.domain + assert webhook_scope.shop == shop + assert webhook_scope.app == app + assert webhook_scope.topic == @topic + assert webhook_scope.shopify_api_version == @api_version + end + end + + describe "call/2 without required inputs" do + test "without an app name the scope is not set", %{conn: conn, shop: shop} do + conn = + conn + |> put_req_header("x-shopify-shop-domain", shop.domain) + |> put_req_header("x-shopify-topic", @topic) + |> put_req_header("x-shopify-api-version", @api_version) + |> WebhookScopeSetup.call(app_name: "invalid") + + refute conn.assigns[:webhook_scope] + end + + test "without a shop myshopify_domain scope is not set", %{conn: conn} do + conn = + conn + |> put_req_header("x-shopify-topic", @topic) + |> put_req_header("x-shopify-api-version", @api_version) + |> WebhookScopeSetup.call([]) + + refute conn.assigns[:webhook_scope] + end + end +end diff --git a/test/shopify_api/webhook_hmac_validator_text.exs b/test/shopify_api/webhook_hmac_validator_text.exs new file mode 100644 index 00000000..9a40b250 --- /dev/null +++ b/test/shopify_api/webhook_hmac_validator_text.exs @@ -0,0 +1,72 @@ +defmodule ShopifyAPI.WebhookHMACValidatorTest do + use ExUnit.Case, async: true + + import Plug.Test + import Plug.Conn + import ShopifyAPI.Factory + + alias ShopifyAPI.AppServer + alias ShopifyAPI.WebhookHMACValidator + + setup do + app = build(:app) + AppServer.set(app) + + {hmac, payload} = encode_with_hmac(app, %{"id" => 1234}) + + conn = + :post + |> conn("/shopify/webhooks/testapp", payload) + |> put_req_header("content-type", "application/json") + + [conn: conn, app: app, payload: payload, hmac: hmac] + end + + describe "read_body/2 with required attributes set" do + test "happy path", %{conn: conn, payload: payload, hmac: hmac} do + {:ok, body, conn} = + conn + |> put_req_header("x-shopify-hmac-sha256", hmac) + |> WebhookHMACValidator.read_body([]) + + assert body == payload + assert conn.assigns.shopify_hmac_validated + end + + test "with invalid hmac", %{conn: conn, payload: payload} do + {:ok, body, conn} = + conn + |> put_req_header("x-shopify-hmac-sha256", "invalid") + |> WebhookHMACValidator.read_body([]) + + assert body == payload + refute conn.assigns.shopify_hmac_validated + end + end + + describe "read_body/2 with some missing attributes" do + test "without the hmac header", %{conn: conn, payload: payload} do + {:ok, body, conn} = WebhookHMACValidator.read_body(conn, []) + assert body == payload + refute conn.assigns[:shopify_hmac_validated] + end + + test "with an invalid app_name", %{conn: conn, payload: payload, hmac: hmac} do + {:ok, body, conn} = + conn + |> put_req_header("x-shopify-hmac-sha256", hmac) + |> WebhookHMACValidator.read_body(app_name: "invalid") + + assert body == payload + refute conn.assigns[:shopify_hmac_validated] + end + end + + # Encodes an object as JSON, generating a HMAC string for integrity verification. + # Returns a two-tuple containing the Base64-encoded HMAC and JSON payload string. + defp encode_with_hmac(%{client_secret: secret}, payload) do + json = Jason.encode!(payload) + hmac = Base.encode64(:crypto.mac(:hmac, :sha256, secret, json)) + {hmac, json} + end +end From 7d750a5ca09b3a826f199c7c501162d2cbd06656 Mon Sep 17 00:00:00 2001 From: Hez Ronningen Date: Sat, 3 May 2025 08:25:41 -0700 Subject: [PATCH 3/7] adding a few fields to the App struct and a App.new/1 This allows us to use the shopify app toml for creating the App struct --- CHANGELOG.md | 2 ++ lib/shopify_api/app.ex | 41 +++++++++++++++++++++++++++++++++++++---- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f3f3edaf..b7939781 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## Unreleased +- New: Add handle and raw app config to the App struct +- New: App.new/1 function to load app from parsed Shopify app config toml file - Fix: 0.16.2 broke the installation path when no JWT was passed along - New: Reworked webhook flow, [check readme](README.md#Webhooks) for details on how to use - Deprecation: old Plugs.Webhook is being replaced and will be removed eventually diff --git a/lib/shopify_api/app.ex b/lib/shopify_api/app.ex index 06d7fd02..dc929e67 100644 --- a/lib/shopify_api/app.ex +++ b/lib/shopify_api/app.ex @@ -2,25 +2,28 @@ defmodule ShopifyAPI.App do @moduledoc """ ShopifyAPI.App contains logic and a struct for representing a Shopify App. """ - @derive {Jason.Encoder, - only: [:name, :client_id, :client_secret, :auth_redirect_uri, :nonce, :scope]} defstruct name: "", + handle: "", client_id: "", client_secret: "", auth_redirect_uri: "", nonce: "", - scope: "" + scope: "", + config: %{} @typedoc """ Type that represents a Shopify App """ @type t :: %__MODULE__{ name: String.t(), + handle: String.t(), client_id: String.t(), client_secret: String.t(), auth_redirect_uri: String.t(), nonce: String.t(), - scope: String.t() + scope: String.t(), + # THE toml file + config: map() } require Logger @@ -30,6 +33,36 @@ defmodule ShopifyAPI.App do alias ShopifyAPI.JSONSerializer alias ShopifyAPI.UserToken + @doc """ + Build a new App. Maybe even from a Toml file. + + Maybe even see: + - https://hex.pm/packages/toml + - https://shopify.dev/docs/apps/build/cli-for-apps/app-configuration + """ + def new( + %{ + "name" => name, + "handle" => handle, + "client_id" => client_id, + "access_scopes" => %{"scopes" => scopes} + } = toml_config + ) do + %__MODULE__{ + name: name, + handle: handle, + client_id: client_id, + scope: scopes, + config: toml_config + } + end + + @doc """ + The client secret likely lives in a runtime variable and should be loaded outside of the usual app definition. + """ + def with_client_secret(%__MODULE__{} = app, client_secret), + do: %{app | client_secret: client_secret} + @doc """ After an App is installed and the Shop owner ends up back on ourside of the fence we need to request an AuthToken. This function uses ShopifyAPI.AuthRequest.post/3 to From c7b9198bd5c6fa51026c2294684eda4776c4f1ac Mon Sep 17 00:00:00 2001 From: Graham Baradoy Date: Sun, 4 May 2025 17:30:27 -0700 Subject: [PATCH 4/7] Add Scopes context and Scope protocol This uses the now commonly accepted pattern of passing down a scope to requests. The protocol defines how the data from the scope can be accessed. AuthToken has an implementation of the new protocol for backwards compatability reasons. --- CHANGELOG.md | 1 + lib/shopify_api.ex | 14 +-- lib/shopify_api/auth_token.ex | 26 +++++ lib/shopify_api/bulk/query.ex | 121 +++++++++++--------- lib/shopify_api/graphql.ex | 37 ++++-- lib/shopify_api/graphql/telemetry.ex | 13 ++- lib/shopify_api/model/webhook_scope.ex | 17 +++ lib/shopify_api/scope.ex | 26 +++++ lib/shopify_api/scopes.ex | 55 +++++++++ test/shopify_api/bulk/query_test.exs | 45 ++++---- test/shopify_api/graphql/graphql_test.exs | 30 +++-- test/shopify_api/graphql/telemetry_test.exs | 18 +-- test/shopify_api/scopes_test.exs | 5 + test/support/factory.ex | 13 ++- test/support/session_token_setup.ex | 12 +- 15 files changed, 294 insertions(+), 139 deletions(-) create mode 100644 lib/shopify_api/scope.ex create mode 100644 lib/shopify_api/scopes.ex create mode 100644 test/shopify_api/scopes_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index b7939781..c0cb9594 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - Fix: 0.16.2 broke the installation path when no JWT was passed along - New: Reworked webhook flow, [check readme](README.md#Webhooks) for details on how to use - Deprecation: old Plugs.Webhook is being replaced and will be removed eventually +- 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. ## 0.16.2 diff --git a/lib/shopify_api.ex b/lib/shopify_api.ex index b51fdfac..f50effd1 100644 --- a/lib/shopify_api.ex +++ b/lib/shopify_api.ex @@ -16,14 +16,14 @@ defmodule ShopifyAPI do iex> estimated_cost = 10 iex> variables = %{input: %{id: "gid://shopify/Metafield/9208558682200"}} iex> options = [debug: true] - iex> ShopifyAPI.graphql_request(auth_token, query, estimated_cost, variables, options) + iex> ShopifyAPI.graphql_request(scope, query, estimated_cost, variables, options) {:ok, %ShopifyAPI.GraphQL.Response{...}} """ - @spec graphql_request(ShopifyAPI.AuthToken.t(), String.t(), integer(), map(), list()) :: + @spec graphql_request(ShopifyAPI.Scope.t(), String.t(), integer(), map(), list()) :: ShopifyAPI.GraphQL.query_response() - def graphql_request(token, query, estimated_cost, variables \\ %{}, opts \\ []) do - func = fn -> ShopifyAPI.GraphQL.query(token, query, variables, opts) end - Throttled.graphql_request(func, token, estimated_cost) + def graphql_request(scope, query, estimated_cost, variables \\ %{}, opts \\ []) do + func = fn -> ShopifyAPI.GraphQL.query(scope, query, variables, opts) end + Throttled.graphql_request(func, scope, estimated_cost) end def request(token, func), do: Throttled.request(func, token, RateLimiting.RESTTracker) @@ -47,8 +47,8 @@ defmodule ShopifyAPI do depending on if you enable user_user_tokens. """ @spec shopify_oauth_url(ShopifyAPI.App.t(), String.t(), list()) :: String.t() - def shopify_oauth_url(app, domain, opts \\ []) - when is_struct(app, ShopifyAPI.App) and is_binary(domain) and is_list(opts) do + def shopify_oauth_url(%ShopifyAPI.App{} = app, domain, opts \\ []) + when is_binary(domain) and is_list(opts) do opts = Keyword.merge(@oauth_default_options, opts) user_token_query_params = opts |> Keyword.get(:use_user_tokens) |> per_user_query_params() query_params = oauth_query_params(app) ++ user_token_query_params diff --git a/lib/shopify_api/auth_token.ex b/lib/shopify_api/auth_token.ex index 79b43a81..a8166824 100644 --- a/lib/shopify_api/auth_token.ex +++ b/lib/shopify_api/auth_token.ex @@ -46,3 +46,29 @@ defmodule ShopifyAPI.AuthToken do new(app, myshopify_domain, code, attrs["access_token"]) end end + +defimpl ShopifyAPI.Scope, for: ShopifyAPI.AuthToken do + def shop(auth_token) do + case ShopifyAPI.ShopServer.get(auth_token.shop_name) do + {:ok, shop} -> + shop + + _ -> + raise "Failed to find Shop for Scope out of AuthToken #{auth_token.shop_name} in ShopServer" + end + end + + def app(auth_token) do + case ShopifyAPI.AppServer.get(auth_token.app_name) do + {:ok, app} -> + app + + _ -> + raise "Failed to find App for Scope out of AuthToken #{auth_token.app_name} in AppServer" + end + end + + def auth_token(auth_token), do: auth_token + + def user_token(_auth_token), do: nil +end diff --git a/lib/shopify_api/bulk/query.ex b/lib/shopify_api/bulk/query.ex index 7bb3fd1e..e2c574f3 100644 --- a/lib/shopify_api/bulk/query.ex +++ b/lib/shopify_api/bulk/query.ex @@ -1,5 +1,4 @@ defmodule ShopifyAPI.Bulk.Query do - alias ShopifyAPI.AuthToken alias ShopifyAPI.Bulk.{Cancel, Telemetry} @type status_response :: map() @@ -31,8 +30,8 @@ defmodule ShopifyAPI.Bulk.Query do } """ - @spec cancel(AuthToken.t(), String.t()) :: {:ok | :error, any()} - def cancel(token, bulk_query_id) do + @spec cancel(ShopifyAPI.Scope.t(), String.t()) :: {:ok | :error, any()} + def cancel(scope, bulk_query_id) do query = """ mutation { bulkOperationCancel(id: "#{bulk_query_id}") { @@ -69,14 +68,14 @@ defmodule ShopifyAPI.Bulk.Query do # } # } - case ShopifyAPI.graphql_request(token, query, 1) do + case ShopifyAPI.graphql_request(scope, query, 1) do {:ok, %{response: %{"bulkOperationCancel" => resp}}} -> {:ok, resp} error -> error end end - @spec fetch_url!(AuthToken.t(), String.t()) :: String.t() - def fetch_url!(%AuthToken{} = token, id) do + @spec fetch_url!(ShopifyAPI.Scope.t(), String.t()) :: String.t() + def fetch_url!(scope, id) do query = """ query { node(id: "#{id}") { @@ -87,86 +86,94 @@ defmodule ShopifyAPI.Bulk.Query do } """ - case ShopifyAPI.graphql_request(token, query, 1) do + case ShopifyAPI.graphql_request(scope, query, 1) do {:ok, %{response: %{"node" => %{"url" => url}}}} -> url - error -> raise_error!(error, token) + error -> raise_error!(error, scope) end end - @spec async_exec!(AuthToken.t(), String.t()) :: {:ok, String.t()} - def async_exec!(%AuthToken{} = token, query) do + @spec async_exec!(ShopifyAPI.Scope.t(), String.t()) :: {:ok, String.t()} + def async_exec!(scope, query) do with bulk_query <- bulk_query_string(query), - {:ok, resp} <- ShopifyAPI.graphql_request(token, bulk_query, 10), + {:ok, resp} <- ShopifyAPI.graphql_request(scope, bulk_query, 10), :ok <- handle_errors(resp), bulk_query_id <- get_in(resp.response, ["bulkOperationRunQuery", "bulkOperation", "id"]) do {:ok, bulk_query_id} else {:error, msg} -> - raise_error!(msg, token) + raise_error!(msg, scope) end end - @spec exec!(AuthToken.t(), String.t(), list()) :: bulk_query_response() - def exec!(%AuthToken{} = token, query, opts) do - with {:ok, bulk_query_id} <- async_exec!(token, query), - {:ok, url} <- poll(token, bulk_query_id, opts[:polling_rate], opts[:max_poll_count]) do - Telemetry.send(@log_module, token, {:success, :query}) + @spec exec!(ShopifyAPI.Scope.t(), String.t(), list()) :: bulk_query_response() + def exec!(scope, query, opts) do + with {:ok, bulk_query_id} <- async_exec!(scope, query), + {:ok, url} <- poll(scope, bulk_query_id, opts[:polling_rate], opts[:max_poll_count]) do + Telemetry.send(@log_module, scope, {:success, :query}) url else {:error, :timeout, bulk_id} -> - Telemetry.send(@log_module, token, {:error, :timeout, "Bulk op timed out"}, bulk_id) - Cancel.perform(opts[:auto_cancel], token, bulk_id) - raise(ShopifyAPI.Bulk.TimeoutError, "Shop: #{token.shop_name}, bulk id: #{bulk_id}") + Telemetry.send(@log_module, scope, {:error, :timeout, "Bulk op timed out"}, bulk_id) + Cancel.perform(opts[:auto_cancel], scope, bulk_id) + + raise( + ShopifyAPI.Bulk.TimeoutError, + "Shop: #{ShopifyAPI.Scopes.myshopify_domain(scope)}, bulk id: #{bulk_id}" + ) :no_objects -> - Telemetry.send(@log_module, token, {:success, :no_objects}) + Telemetry.send(@log_module, scope, {:success, :no_objects}) :no_objects end end defp raise_error!( "A bulk query operation for this app and shop is already in progress: " <> bulk_id = msg, - token + scope ) do - Telemetry.send(@log_module, token, {:error, :in_progress, msg}, bulk_id) - raise(ShopifyAPI.Bulk.InProgressError, "Shop: #{token.shop_name}, bulk id: #{bulk_id}") + Telemetry.send(@log_module, scope, {:error, :in_progress, msg}, bulk_id) + + raise( + ShopifyAPI.Bulk.InProgressError, + "Shop: #{ShopifyAPI.Scopes.myshopify_domain(scope)}, bulk id: #{bulk_id}" + ) end - defp raise_error!(%HTTPoison.Response{status_code: code} = msg, token) + defp raise_error!(%HTTPoison.Response{status_code: code} = msg, scope) when code in @shop_unavailable_status_codes do - Telemetry.send(@log_module, token, {:error, :shop_unavailable, msg}) - raise(ShopifyAPI.ShopUnavailableError, "Shop: #{token.shop_name}") + Telemetry.send(@log_module, scope, {:error, :shop_unavailable, msg}) + raise(ShopifyAPI.ShopUnavailableError, "Shop: #{ShopifyAPI.Scopes.myshopify_domain(scope)}") end - defp raise_error!(%HTTPoison.Response{status_code: 404} = msg, token) do - Telemetry.send(@log_module, token, {:error, :shop_not_found, msg}) - raise(ShopifyAPI.ShopNotFoundError, "Shop: #{token.shop_name}") + defp raise_error!(%HTTPoison.Response{status_code: 404} = msg, scope) do + Telemetry.send(@log_module, scope, {:error, :shop_not_found, msg}) + raise(ShopifyAPI.ShopNotFoundError, "Shop: #{ShopifyAPI.Scopes.myshopify_domain(scope)}") end - defp raise_error!(%HTTPoison.Response{status_code: 401} = msg, token) do - Telemetry.send(@log_module, token, {:error, :shop_auth, msg}) - raise(ShopifyAPI.ShopAuthError, "Shop: #{token.shop_name}") + defp raise_error!(%HTTPoison.Response{status_code: 401} = msg, scope) do + Telemetry.send(@log_module, scope, {:error, :shop_auth, msg}) + raise(ShopifyAPI.ShopAuthError, "Shop: #{ShopifyAPI.Scopes.myshopify_domain(scope)}") end - defp raise_error!(msg, token) do - Telemetry.send(@log_module, token, {:error, :generic, msg}) + defp raise_error!(msg, scope) do + Telemetry.send(@log_module, scope, {:error, :generic, msg}) raise(ShopifyAPI.Bulk.QueryError, inspect(msg)) end - @spec fetch(bulk_query_response(), AuthToken.t()) :: {:ok, String.t()} | {:error, any()} + @spec fetch(bulk_query_response(), ShopifyAPI.Scope.t()) :: {:ok, String.t()} | {:error, any()} # handle no object bulk responses - def fetch(:no_objects, _token), do: {:ok, ""} + def fetch(:no_objects, _scope), do: {:ok, ""} - def fetch(url, token) when is_binary(url) do + def fetch(url, scope) when is_binary(url) do url |> HTTPoison.get() |> case do {:ok, %{body: jsonl}} -> - Telemetry.send(@log_module, token, {:success, :fetch}) + Telemetry.send(@log_module, scope, {:success, :fetch}) {:ok, jsonl} error -> - Telemetry.send(@log_module, token, {:error, :fetch, error}) + Telemetry.send(@log_module, scope, {:error, :fetch, error}) error end end @@ -179,12 +186,12 @@ defmodule ShopifyAPI.Bulk.Query do Warning: Since HTTPoison spawns a seperate process which uses send/receive to stream HTTP fetches be careful where you use this. """ - @spec stream_fetch!(bulk_query_response(), AuthToken.t()) :: Enumerable.t() + @spec stream_fetch!(bulk_query_response(), ShopifyAPI.Scope.t()) :: Enumerable.t() # handle no object bulk responses - def stream_fetch!(:no_objects, _token), do: [] + def stream_fetch!(:no_objects, _scope), do: [] - def stream_fetch!(url, token) when is_binary(url) do - url |> httpoison_streamed_get!(token) |> Stream.transform("", &transform_chunks_to_jsonl/2) + def stream_fetch!(url, scope) when is_binary(url) do + url |> httpoison_streamed_get!(scope) |> Stream.transform("", &transform_chunks_to_jsonl/2) end def parse_response!(""), do: [] @@ -194,9 +201,9 @@ defmodule ShopifyAPI.Bulk.Query do def parse_response!(jsonl) when is_binary(jsonl), do: jsonl |> String.split("\n", trim: true) |> Enum.map(&ShopifyAPI.JSONSerializer.decode!/1) - @spec status(AuthToken.t()) :: {:ok, status_response()} | {:error, any()} - def status(%AuthToken{} = token) do - token + @spec status(ShopifyAPI.Scope.t()) :: {:ok, status_response()} | {:error, any()} + def status(scope) do + scope |> ShopifyAPI.graphql_request(@bulk_status_query, 1) |> case do {:ok, %{response: %{"currentBulkOperation" => response}}} -> @@ -243,29 +250,29 @@ defmodule ShopifyAPI.Bulk.Query do |> Map.get("message") end - defp poll(token, bulk_query_id, polling_rate, max_poll, depth \\ 0) + defp poll(scope, bulk_query_id, polling_rate, max_poll, depth \\ 0) - defp poll(_token, bulk_query_id, _, max_poll, depth) when max_poll == depth, + defp poll(_scope, bulk_query_id, _, max_poll, depth) when max_poll == depth, do: {:error, :timeout, bulk_query_id} - defp poll(token, bulk_query_id, polling_rate, max_poll, depth) do + defp poll(scope, bulk_query_id, polling_rate, max_poll, depth) do Process.sleep(polling_rate) - case status(token) do + case status(scope) do {:ok, %{"status" => "COMPLETED", "url" => nil, "objectCount" => "0"}} -> :no_objects {:ok, %{"status" => "COMPLETED", "url" => url} = _response} -> {:ok, url} - _ -> poll(token, bulk_query_id, polling_rate, max_poll, depth + 1) + _ -> poll(scope, bulk_query_id, polling_rate, max_poll, depth + 1) end end - defp httpoison_streamed_get!(url, token) do + defp httpoison_streamed_get!(url, scope) do Stream.resource( fn -> try do HTTPoison.get!(url, %{}, stream_to: self(), async: :once) rescue error -> - Telemetry.send(@log_module, token, {:error, :streamed_fetch, error}) + Telemetry.send(@log_module, scope, {:error, :streamed_fetch, error}) reraise error, __STACKTRACE__ end end, @@ -277,7 +284,7 @@ defmodule ShopifyAPI.Bulk.Query do %HTTPoison.AsyncStatus{id: ^id, code: code} -> error = "ShopifyAPI.Bulk stream fetch got non 200 code of: #{code}" - Telemetry.send(@log_module, token, {:error, :streamed_fetch, error}) + Telemetry.send(@log_module, scope, {:error, :streamed_fetch, error}) raise(error) %HTTPoison.AsyncHeaders{id: ^id, headers: _headers} -> @@ -293,12 +300,12 @@ defmodule ShopifyAPI.Bulk.Query do after @stream_http_timeout -> error = "receive timeout" - Telemetry.send(@log_module, token, {:error, :streamed_fetch, error}) + Telemetry.send(@log_module, scope, {:error, :streamed_fetch, error}) raise error end end, fn _resp -> - Telemetry.send(@log_module, token, {:success, :streamed_fetch}) + Telemetry.send(@log_module, scope, {:success, :streamed_fetch}) :ok end ) diff --git a/lib/shopify_api/graphql.ex b/lib/shopify_api/graphql.ex index 933d5a34..53e647a0 100644 --- a/lib/shopify_api/graphql.ex +++ b/lib/shopify_api/graphql.ex @@ -5,7 +5,6 @@ defmodule ShopifyAPI.GraphQL do require Logger - alias ShopifyAPI.AuthToken alias ShopifyAPI.GraphQL.{JSONParseError, Response, Telemetry} alias ShopifyAPI.JSONSerializer @@ -28,10 +27,13 @@ defmodule ShopifyAPI.GraphQL do iex> ShopifyAPI.GraphQL.query(auth, query, variables) {:ok, %Response{...}} """ - @spec query(AuthToken.t(), String.t(), map(), list()) :: query_response() - def query(%AuthToken{} = auth, query_string, variables \\ %{}, opts \\ []) do - url = build_url(auth, opts) - headers = build_headers(auth, opts) + @spec query(ShopifyAPI.Scope.t(), String.t(), map(), list()) :: query_response() + + def query(auth_or_scope, query_string, variables \\ %{}, opts \\ []) + + def query(scope, query_string, variables, opts) do + url = build_url(ShopifyAPI.Scopes.myshopify_domain(scope), opts) + headers = build_headers(ShopifyAPI.Scopes.access_token(scope), opts) body = query_string @@ -39,7 +41,7 @@ defmodule ShopifyAPI.GraphQL do |> insert_variables(variables) |> JSONSerializer.encode!() - logged_request(auth, url, body, headers, opts) + logged_request(scope, url, body, headers, opts) end defp build_body(query_string), do: %{query: query_string} @@ -87,14 +89,14 @@ defmodule ShopifyAPI.GraphQL do } end - defp logged_request(auth, url, body, headers, options) do + defp logged_request(scope_or_auth, url, body, headers, options) do {time, raw_response} = :timer.tc(HTTPoison, :post, [url, body, headers, options]) response = Response.handle(raw_response) - log_request(auth, response, time) + log_request(scope_or_auth, response, time) - Telemetry.send(@log_module, auth, url, time, response) + Telemetry.send(@log_module, scope_or_auth, url, time, response) response end @@ -121,12 +123,23 @@ defmodule ShopifyAPI.GraphQL do end) end - defp build_url(%{shop_name: domain}, opts) do + defp log_request(scope, response, time) do + log_request( + %{ + app_name: ShopifyAPI.Scopes.app_name(scope), + shop_name: ShopifyAPI.Scopes.myshopify_domain(scope) + }, + response, + time + ) + end + + defp build_url(myshopify_domain, opts) do version = Keyword.get(opts, :version, configured_version()) - "#{ShopifyAPI.transport()}://#{domain}/admin/api/#{version}/graphql.json" + "#{ShopifyAPI.transport()}://#{myshopify_domain}/admin/api/#{version}/graphql.json" end - defp build_headers(%{token: access_token}, opts) do + defp build_headers(access_token, opts) do headers = [ {"Content-Type", "application/json"}, {"X-Shopify-Access-Token", access_token} diff --git a/lib/shopify_api/graphql/telemetry.ex b/lib/shopify_api/graphql/telemetry.ex index d62d2ecc..4416a749 100644 --- a/lib/shopify_api/graphql/telemetry.ex +++ b/lib/shopify_api/graphql/telemetry.ex @@ -4,16 +4,17 @@ defmodule ShopifyAPI.GraphQL.Telemetry do """ alias HTTPoison.Error alias ShopifyAPI.GraphQL.Response + alias ShopifyAPI.Scopes def send( module_name, - %{app_name: app, shop_name: shop} = _token, + scope, time, {:ok, %Response{response: response}} = _response ) do metadata = %{ - app: app, - shop: shop, + app: Scopes.app(scope), + shop: Scopes.shop(scope), module: module_name, response: response } @@ -23,7 +24,7 @@ defmodule ShopifyAPI.GraphQL.Telemetry do def send( module_name, - %{app_name: app, shop_name: shop} = _token, + scope, time, response ) do @@ -34,8 +35,8 @@ defmodule ShopifyAPI.GraphQL.Telemetry do end metadata = %{ - app: app, - shop: shop, + app: Scopes.app(scope), + shop: Scopes.shop(scope), module: module_name, reason: reason } diff --git a/lib/shopify_api/model/webhook_scope.ex b/lib/shopify_api/model/webhook_scope.ex index 119ed715..bdc5e1c1 100644 --- a/lib/shopify_api/model/webhook_scope.ex +++ b/lib/shopify_api/model/webhook_scope.ex @@ -20,3 +20,20 @@ defmodule ShopifyAPI.Model.WebhookScope do :shop ] end + +defimpl ShopifyAPI.Scope, for: ShopifyAPI.Model.WebhoookScope do + def shop(%{shop: shop}), do: shop + def app(%{app: app}), do: app + + def auth_token(%{app: app, shop: shop}) do + case ShopifyAPI.AuthTokenServer.get(shop, app) do + {:ok, token} -> + token + + _ -> + raise "Failed to find AuthToken for Scope out of WebhookScope #{shop.domain} #{app.name} in AuthTokenServer" + end + end + + def user_token(_auth_token), do: nil +end diff --git a/lib/shopify_api/scope.ex b/lib/shopify_api/scope.ex new file mode 100644 index 00000000..a0340ca1 --- /dev/null +++ b/lib/shopify_api/scope.ex @@ -0,0 +1,26 @@ +defprotocol ShopifyAPI.Scope do + @fallback_to_any true + + @spec auth_token(__MODULE__.t()) :: ShopifyAPI.AuthToken.t() + def auth_token(scope) + + @spec user_token(__MODULE__.t()) :: ShopifyAPI.UserToken.t() | nil + def user_token(scope) + + @spec shop(__MODULE__.t()) :: ShopifyAPI.Shop.t() + def shop(scope) + + @spec app(__MODULE__.t()) :: ShopifyAPI.App.t() + def app(scope) +end + +defimpl ShopifyAPI.Scope, for: Any do + def shop(%{shop: shop}), do: shop + + def app(%{app: app}), do: app + + def auth_token(%{auth_token: auth_token}), do: auth_token + + def user_token(%{user_token: %ShopifyAPI.UserToken{} = user_token}), do: user_token + def user_token(_), do: nil +end diff --git a/lib/shopify_api/scopes.ex b/lib/shopify_api/scopes.ex new file mode 100644 index 00000000..f94f5cfe --- /dev/null +++ b/lib/shopify_api/scopes.ex @@ -0,0 +1,55 @@ +defmodule ShopifyAPI.Scopes do + alias ShopifyAPI.Scope + + @spec shop(Scope.t()) :: ShopifyAPI.Shop.t() + def shop(scope), do: Scope.shop(scope) + + @spec app(Scope.t()) :: ShopifyAPI.App.t() + def app(scope), do: Scope.app(scope) + + @spec auth_token(Scope.t()) :: ShopifyAPI.AuthToken.t() + def auth_token(scope), do: Scope.auth_token(scope) + + @spec user_token(Scope.t()) :: ShopifyAPI.UserToken.t() | nil + def user_token(scope), do: Scope.user_token(scope) + + @spec myshopify_domain(Scope.t()) :: String.t() + def myshopify_domain(scope), do: shop(scope).domain + + @spec shop_slug(Scope.t()) :: String.t() + def shop_slug(scope), do: scope |> myshopify_domain() |> ShopifyAPI.Shop.slug_from_domain() + + @spec app_name(Scope.t()) :: String.t() + def app_name(scope), do: Scope.app(scope).name + + @spec app_handle(Scope.t()) :: String.t() + def app_handle(scope), do: Scope.app(scope).handle + + @doc """ + Accessor for either the User's Token (aka online token) falling back + to the Shop's Token (aka offline token) + + ## Examples + iex> %{user_token: %ShopifyAPI.UserToken{token: "ftw"}} |> ShopifyAPI.Scopes.access_token() + "ftw" + + iex> %{user_token: %ShopifyAPI.UserToken{token: "wtf"}, auth_token: %ShopifyAPI.AuthToken{token: "foo"}} |> ShopifyAPI.Scopes.access_token() + "wtf" + + iex> %{user_token: nil, auth_token: %ShopifyAPI.AuthToken{token: "foo"}} |> ShopifyAPI.Scopes.access_token() + "foo" + + iex> %{auth_token: %ShopifyAPI.AuthToken{token: "bar"}} |> ShopifyAPI.Scopes.access_token() + "bar" + """ + @spec access_token(Scope.t()) :: String.t() + def access_token(scope) do + token = + case user_token(scope) do + user_token = %ShopifyAPI.UserToken{} -> user_token + _ -> auth_token(scope) + end + + token.token + end +end diff --git a/test/shopify_api/bulk/query_test.exs b/test/shopify_api/bulk/query_test.exs index b51726b4..5e5c1920 100644 --- a/test/shopify_api/bulk/query_test.exs +++ b/test/shopify_api/bulk/query_test.exs @@ -1,6 +1,9 @@ defmodule ShopifyAPI.Bulk.QueryTest do use ExUnit.Case + import ShopifyAPI.Factory + import ShopifyAPI.SessionTokenSetup + alias ShopifyAPI.Bulk.Query @valid_graphql_response %{ @@ -15,30 +18,26 @@ defmodule ShopifyAPI.Bulk.QueryTest do @graphql_path "/admin/api/#{@graphql_ver}/graphql.json" setup _context do - bypass = Bypass.open() - Application.put_env(:shopify_api, ShopifyAPI.GraphQL, graphql_version: @graphql_ver) - token = %ShopifyAPI.AuthToken{ - token: "token", - shop_name: "localhost:#{bypass.port}" - } - - shop = %ShopifyAPI.Shop{domain: "localhost:#{bypass.port}"} - + bypass = Bypass.open() + myshopify_domain = "localhost:#{bypass.port}" + shop = build(:shop, domain: myshopify_domain) opts = [polling_rate: 1, max_poll_count: 1, auto_cancel: false] {:ok, - %{ + [ + myshopify_domain: myshopify_domain, shop: shop, - auth_token: token, bypass: bypass, options: opts, url: "localhost:#{bypass.port}/" - }} + ]} end - test "happy path", %{bypass: bypass, shop: _shop, auth_token: token, options: options} do + setup [:offline_token] + + test "happy path", %{bypass: bypass, shop: _shop, offline_token: token, options: options} do Bypass.expect(bypass, "POST", @graphql_path, fn conn -> body = @valid_graphql_response @@ -59,7 +58,7 @@ defmodule ShopifyAPI.Bulk.QueryTest do assert {:ok, _} = Query.fetch(url, token) end - test "polling timeout", %{bypass: bypass, shop: _shop, auth_token: token, options: options} do + test "polling timeout", %{bypass: bypass, shop: _shop, offline_token: token, options: options} do Bypass.expect(bypass, "POST", @graphql_path, fn conn -> body = @valid_graphql_response @@ -77,7 +76,7 @@ defmodule ShopifyAPI.Bulk.QueryTest do end end - test "invalid graphql", %{bypass: bypass, shop: _shop, auth_token: token, options: options} do + test "invalid graphql", %{bypass: bypass, shop: _shop, offline_token: token, options: options} do Bypass.expect(bypass, "POST", @graphql_path, fn conn -> body = @valid_graphql_response @@ -100,7 +99,7 @@ defmodule ShopifyAPI.Bulk.QueryTest do test "bulk op already in progress", %{ bypass: bypass, shop: _shop, - auth_token: token, + offline_token: token, options: options } do Bypass.expect(bypass, "POST", @graphql_path, fn conn -> @@ -129,7 +128,7 @@ defmodule ShopifyAPI.Bulk.QueryTest do test "exec/1 with 404 response", %{ bypass: bypass, shop: _shop, - auth_token: token, + offline_token: token, options: options } do Bypass.expect(bypass, "POST", @graphql_path, fn conn -> @@ -144,7 +143,7 @@ defmodule ShopifyAPI.Bulk.QueryTest do test "exec/1 with 423 response", %{ bypass: bypass, shop: _shop, - auth_token: token, + offline_token: token, options: options } do Bypass.expect(bypass, "POST", @graphql_path, fn conn -> @@ -160,7 +159,7 @@ defmodule ShopifyAPI.Bulk.QueryTest do @json2 %{"test" => "bar fuzz"} @json3 %{"test" => "baz\nbuzz"} - test "stream_fetch!/2", %{bypass: bypass, url: url, auth_token: token} do + test "stream_fetch!/2", %{bypass: bypass, url: url, offline_token: token} do Bypass.expect(bypass, "GET", "/", fn conn -> conn = conn @@ -178,7 +177,11 @@ defmodule ShopifyAPI.Bulk.QueryTest do |> Enum.map(&Jason.decode!/1) == [@json1, @json2, @json3] end - test "stream_fetch!/2 with jsonl across chunks", %{bypass: bypass, url: url, auth_token: token} do + test "stream_fetch!/2 with jsonl across chunks", %{ + bypass: bypass, + url: url, + offline_token: token + } do Bypass.expect(bypass, "GET", "/", fn conn -> conn = conn @@ -200,7 +203,7 @@ defmodule ShopifyAPI.Bulk.QueryTest do test "stream_fetch!/2 with non-200 response codes", %{ bypass: bypass, url: url, - auth_token: token + offline_token: token } do Bypass.expect(bypass, "GET", "/", fn conn -> conn = diff --git a/test/shopify_api/graphql/graphql_test.exs b/test/shopify_api/graphql/graphql_test.exs index 4be09976..f433cb96 100644 --- a/test/shopify_api/graphql/graphql_test.exs +++ b/test/shopify_api/graphql/graphql_test.exs @@ -1,10 +1,14 @@ defmodule ShopifyAPI.GraphQL.GraphQLTest do use ExUnit.Case + import ShopifyAPI.Factory + import ShopifyAPI.SessionTokenSetup + alias Plug.Conn - alias ShopifyAPI.{AuthToken, JSONSerializer, Shop} + alias ShopifyAPI.GraphQL alias ShopifyAPI.GraphQL.Response + alias ShopifyAPI.JSONSerializer @data %{ "metafield1" => %{ @@ -52,22 +56,16 @@ defmodule ShopifyAPI.GraphQL.GraphQLTest do setup _context do bypass = Bypass.open() - - token = %AuthToken{ - token: "1234", - shop_name: "localhost:#{bypass.port}" - } - - shop = %Shop{domain: "localhost:#{bypass.port}"} - - {:ok, %{bypass: bypass, auth_token: token, shop: shop}} + {:ok, [bypass: bypass, shop: build(:shop, domain: "localhost:#{bypass.port}")]} end + setup [:offline_token] + describe "GraphQL query/2" do test "when mutation has parametized variables", %{ bypass: bypass, shop: _shop, - auth_token: token + offline_token: token } do response = Map.merge(%{"data" => @data}, %{"extensions" => @metadata}) @@ -88,7 +86,7 @@ defmodule ShopifyAPI.GraphQL.GraphQLTest do test "when mutation is a query string", %{ bypass: bypass, shop: _shop, - auth_token: token + offline_token: token } do response = Map.merge(%{"data" => @data}, %{"extensions" => @metadata}) @@ -109,7 +107,7 @@ defmodule ShopifyAPI.GraphQL.GraphQLTest do test "when deleting a metafield that does not exist", %{ bypass: bypass, shop: _shop, - auth_token: token + offline_token: token } do response = Map.merge(%{"data" => @data_does_not_exist}, %{"extensions" => @metadata}) @@ -131,7 +129,7 @@ defmodule ShopifyAPI.GraphQL.GraphQLTest do test "when query exceeds max cost for 1000", %{ bypass: bypass, shop: _shop, - auth_token: token + offline_token: token } do Bypass.expect_once( bypass, @@ -151,7 +149,7 @@ defmodule ShopifyAPI.GraphQL.GraphQLTest do test "when response contains metadata", %{ bypass: bypass, shop: _shop, - auth_token: token + offline_token: token } do response = Map.merge(%{"data" => @data}, %{"extensions" => @metadata}) @@ -180,7 +178,7 @@ defmodule ShopifyAPI.GraphQL.GraphQLTest do test "when response does not contain metadata", %{ bypass: bypass, shop: _shop, - auth_token: token + offline_token: token } do Bypass.expect_once( bypass, diff --git a/test/shopify_api/graphql/telemetry_test.exs b/test/shopify_api/graphql/telemetry_test.exs index 975d33a9..3f470c9d 100644 --- a/test/shopify_api/graphql/telemetry_test.exs +++ b/test/shopify_api/graphql/telemetry_test.exs @@ -1,33 +1,27 @@ defmodule ShopifyAPI.GraphQL.TelemetryTest do use ExUnit.Case + import ShopifyAPI.SessionTokenSetup + alias HTTPoison.Error - alias ShopifyAPI.AuthToken alias ShopifyAPI.GraphQL.{Response, Telemetry} @module "module" @time 1202 - setup _context do - token = %AuthToken{ - token: "1234", - shop_name: "localhost" - } - - {:ok, %{auth_token: token}} - end + setup [:offline_token] describe "Telemetry send/4" do - test "when graphql response is succesful", %{auth_token: token} do + test "when graphql response is succesful", %{offline_token: token} do assert :ok == Telemetry.send(@module, token, @time, {:ok, %Response{response: "response"}}) end - test "when graphql request fails", %{auth_token: token} do + test "when graphql request fails", %{offline_token: token} do assert :ok == Telemetry.send(@module, token, @time, {:error, %Response{response: "response"}}) end - test "when graphql request errors out", %{auth_token: token} do + test "when graphql request errors out", %{offline_token: token} do assert :ok == Telemetry.send(@module, token, @time, {:error, %Error{reason: "reason"}}) end end diff --git a/test/shopify_api/scopes_test.exs b/test/shopify_api/scopes_test.exs new file mode 100644 index 00000000..1b82e3a3 --- /dev/null +++ b/test/shopify_api/scopes_test.exs @@ -0,0 +1,5 @@ +defmodule ShopifyAPI.ScopesTest do + use ExUnit.Case, async: true + + doctest ShopifyAPI.Scopes +end diff --git a/test/support/factory.ex b/test/support/factory.ex index 2754b1fe..5c7b2d0b 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -16,17 +16,22 @@ defmodule ShopifyAPI.Factory do def myshopify_domain, do: Faker.Internet.slug() <> ".myshopify.com" - def shop_factory do - domain = myshopify_domain() - %ShopifyAPI.Shop{domain: domain} + def shop_factory(params) do + domain = params[:domain] || myshopify_domain() + shop = %ShopifyAPI.Shop{domain: domain} + ShopifyAPI.ShopServer.set(shop) + shop end def app_factory do - %ShopifyAPI.App{ + app = %ShopifyAPI.App{ name: shopify_app_name(), client_id: "#{__MODULE__}.id", client_secret: shopify_app_secret() } + + ShopifyAPI.AppServer.set(app) + app end def auth_token_factory(params) do diff --git a/test/support/session_token_setup.ex b/test/support/session_token_setup.ex index 4c0c38e3..0848364c 100644 --- a/test/support/session_token_setup.ex +++ b/test/support/session_token_setup.ex @@ -1,14 +1,18 @@ defmodule ShopifyAPI.SessionTokenSetup do import ShopifyAPI.Factory - def offline_token(%{shop: %ShopifyAPI.Shop{} = shop}) do - token = build(:auth_token, %{shop_name: shop.domain}) + def offline_token(context) do + app = context[:app] || build(:app) + shop = context[:shop] || build(:shop) + token = build(:auth_token, %{app_name: app.name, shop_name: shop.domain}) ShopifyAPI.AuthTokenServer.set(token) [offline_token: token] end - def online_token(%{shop: %ShopifyAPI.Shop{} = shop}) do - token = build(:user_token, %{shop_name: shop.domain}) + def online_token(context) do + app = context[:app] || build(:app) + shop = context[:shop] || build(:shop) + token = build(:user_token, %{app_name: app.name, shop_name: shop.domain}) ShopifyAPI.UserTokenServer.set(token) [online_token: token] end From 6e37f42fefd991a628e866c796b76972980120ab Mon Sep 17 00:00:00 2001 From: Hez Ronningen Date: Thu, 8 May 2025 08:28:45 -0700 Subject: [PATCH 5/7] new app server single app mode This greatly simplifies the most common use case, 1 shopify app to one phx app. --- CHANGELOG.md | 2 + lib/shopify_api/app_server.ex | 77 +++++++++++++------ lib/shopify_api/config.ex | 42 +++++++--- .../plugs/webhook_scope_setup_test.exs | 11 --- test/shopify_api/router_test.exs | 23 ------ 5 files changed, 88 insertions(+), 67 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0cb9594..79f900ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## Unreleased +- BREAKING: AppServer now defaults to a single app instance, this is a compile env if you want to use the old multi app config add `config :shopify_api, :app_server, :multi_app` to your `config/config.exs` +- New: Single app mode for AppServer, is API compatible with the multi app setup. This greatly simplifies the most common setup, one app <> one phoenix setup. - New: Add handle and raw app config to the App struct - New: App.new/1 function to load app from parsed Shopify app config toml file - Fix: 0.16.2 broke the installation path when no JWT was passed along diff --git a/lib/shopify_api/app_server.ex b/lib/shopify_api/app_server.ex index b7b06ee5..5628a36e 100644 --- a/lib/shopify_api/app_server.ex +++ b/lib/shopify_api/app_server.ex @@ -9,49 +9,78 @@ defmodule ShopifyAPI.AppServer do alias ShopifyAPI.Config @table __MODULE__ + @name __MODULE__ + @single_app_install Application.compile_env(:shopify_api, :app_server, :single_app) == + :single_app || true + + if @single_app_install do + @spec set(App.t()) :: :ok + def set(%App{} = app) do + GenServer.cast(@name, {:app, app}) + do_persist(app) + :ok + end - def all, do: @table |> :ets.tab2list() |> Map.new() + @spec set(String.t(), App.t()) :: :ok + def set(_, app), do: set(app) - @spec count() :: integer() - def count, do: :ets.info(@table, :size) + @spec get(String.t()) :: {:ok, App.t()} | :error + def get(_name \\ ""), do: GenServer.call(@name, :app) - @spec set(App.t()) :: :ok - def set(%App{name: name} = app), do: set(name, app) + def get_by_client_id(client_id), do: get(client_id) - @spec set(String.t(), App.t()) :: :ok - def set(name, app) when is_binary(name) and is_struct(app, App) do - :ets.insert(@table, {name, app}) - do_persist(app) - :ok - end + def mode, do: :single_app + else + def all, do: @table |> :ets.tab2list() |> Map.new() + + @spec count() :: integer() + def count, do: :ets.info(@table, :size) - @spec get(String.t()) :: {:ok, App.t()} | :error - def get(name) when is_binary(name) do - case :ets.lookup(@table, name) do - [{^name, app}] -> {:ok, app} - [] -> :error + @spec set(App.t()) :: :ok + def set(%App{name: name} = app), do: set(name, app) + + @spec set(String.t(), App.t()) :: :ok + def set(name, app) when is_binary(name) and is_struct(app, App) do + :ets.insert(@table, {name, app}) + do_persist(app) + :ok end - end - def get_by_client_id(client_id) do - case :ets.match_object(@table, {:_, %{client_id: client_id}}) do - [{_, app}] -> {:ok, app} - [] -> :error + @spec get(String.t()) :: {:ok, App.t()} | :error + def get(name) when is_binary(name) do + case :ets.lookup(@table, name) do + [{^name, app}] -> {:ok, app} + [] -> :error + end end + + def get_by_client_id(client_id) do + case :ets.match_object(@table, {:_, %{client_id: client_id}}) do + [{_, app}] -> {:ok, app} + [] -> :error + end + end + + def mode, do: :multi_app end ## GenServer Callbacks - def start_link(_opts), do: GenServer.start_link(__MODULE__, :ok, name: __MODULE__) + def start_link(_opts), do: GenServer.start_link(__MODULE__, :ok, name: @name) @impl GenServer def init(:ok) do create_table!() for %App{} = app <- do_initialize(), do: set(app) - {:ok, :no_state} + {:ok, %{}} end - ## Private Helpers + @impl GenServer + def handle_cast({:app, app}, state), do: {:noreply, Map.put(state, :app, app)} + + @impl GenServer + def handle_call(:app, _from, %{app: app} = state), do: {:reply, {:ok, app}, state} + def handle_call(:app, _from, state), do: {:reply, :error, state} defp create_table! do :ets.new(@table, [ diff --git a/lib/shopify_api/config.ex b/lib/shopify_api/config.ex index e52f04cf..333ea70b 100644 --- a/lib/shopify_api/config.ex +++ b/lib/shopify_api/config.ex @@ -1,19 +1,43 @@ defmodule ShopifyAPI.Config do + @single_app_install Application.compile_env(:shopify_api, :app_server, :single_app) == + :single_app || true + @moduledoc false def lookup(key), do: Application.get_env(:shopify_api, key) def lookup(key, subkey), do: Application.get_env(:shopify_api, key)[subkey] - @spec app_name() :: String.t() | nil - @spec app_name(Plug.Conn.t(), keyword()) :: String.t() | nil - def app_name, do: lookup(:app_name) + if @single_app_install do + @spec app_name() :: String.t() | nil + @spec app_name(Plug.Conn.t(), keyword()) :: String.t() | nil + def app_name do + case app() do + %ShopifyAPI.App{} = app -> app.name + nil -> nil + end + end + + def app_name(_conn, _opts \\ []), do: app_name() + + @spec app() :: ShopifyAPI.App.t() | nil + def app do + case ShopifyAPI.AppServer.get() do + {:ok, app} -> app + :error -> nil + end + end + else + @spec app_name() :: String.t() | nil + @spec app_name(Plug.Conn.t(), keyword()) :: String.t() | nil + def app_name, do: lookup(:app_name) - def app_name(%Plug.Conn{path_info: path_info}, opts \\ []), - do: Keyword.get(opts, :app_name) || app_name() || List.last(path_info) + def app_name(%Plug.Conn{path_info: path_info}, opts \\ []), + do: Keyword.get(opts, :app_name) || app_name() || List.last(path_info) - @spec app() :: ShopifyAPI.App.t() | nil - def app do - with app_name when is_binary(app_name) <- app_name() do - ShopifyAPI.AppServer.get(app_name) + @spec app() :: ShopifyAPI.App.t() | nil + def app do + with app_name when is_binary(app_name) <- app_name() do + ShopifyAPI.AppServer.get(app_name) + end end end end diff --git a/test/shopify_api/plugs/webhook_scope_setup_test.exs b/test/shopify_api/plugs/webhook_scope_setup_test.exs index f15b6b40..26942e59 100644 --- a/test/shopify_api/plugs/webhook_scope_setup_test.exs +++ b/test/shopify_api/plugs/webhook_scope_setup_test.exs @@ -45,17 +45,6 @@ defmodule ShopifyAPI.Plugs.WebhookScopeSetupTest do end describe "call/2 without required inputs" do - test "without an app name the scope is not set", %{conn: conn, shop: shop} do - conn = - conn - |> put_req_header("x-shopify-shop-domain", shop.domain) - |> put_req_header("x-shopify-topic", @topic) - |> put_req_header("x-shopify-api-version", @api_version) - |> WebhookScopeSetup.call(app_name: "invalid") - - refute conn.assigns[:webhook_scope] - end - test "without a shop myshopify_domain scope is not set", %{conn: conn} do conn = conn diff --git a/test/shopify_api/router_test.exs b/test/shopify_api/router_test.exs index 3879a29e..149663c2 100644 --- a/test/shopify_api/router_test.exs +++ b/test/shopify_api/router_test.exs @@ -50,16 +50,6 @@ defmodule Test.ShopifyAPI.RouterTest do assert URI.parse(redirect_uri).host == @shop_domain end - - test "without a valid app it errors" do - conn = - :get - |> conn("/install?app=not-an-app&shop=#{@shop_domain}") - |> parse() - |> Router.call(%{}) - - assert conn.status == 404 - end end describe "/authorized" do @@ -119,19 +109,6 @@ defmodule Test.ShopifyAPI.RouterTest do assert conn.status == 404 end - test "fails without a valid app", %{bypass: _bypass, shop_domain: shop_domain} do - conn = - :get - |> conn( - "/authorized/invalid-app?" <> - add_hmac_to_params("code=#{@code}&shop=#{shop_domain}&state=#{@nonce}×tamp=1234") - ) - |> parse() - |> Router.call(%{}) - - assert conn.status == 404 - end - test "fails without a valid shop", %{bypass: _bypass} do conn = :get From 384d16e2c031c4815ce22fba026363a9baea2b8c Mon Sep 17 00:00:00 2001 From: Hez Ronningen Date: Thu, 3 Jul 2025 10:22:35 -0700 Subject: [PATCH 6/7] on install the shop will not be set, use shopifys param as a fallback --- lib/shopify_api/plugs/put_shopify_content_headers.ex | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/lib/shopify_api/plugs/put_shopify_content_headers.ex b/lib/shopify_api/plugs/put_shopify_content_headers.ex index 5111a399..8dd45d75 100644 --- a/lib/shopify_api/plugs/put_shopify_content_headers.ex +++ b/lib/shopify_api/plugs/put_shopify_content_headers.ex @@ -20,12 +20,18 @@ defmodule ShopifyAPI.Plugs.PutShopifyContentHeaders do def call(conn, _options) do conn - |> put_resp_header("x-frame-options", "ALLOW-FROM https://" <> myshopify_domain(conn)) + |> put_resp_header("x-frame-options", "ALLOW-FROM " <> myshopify_domain_url(conn)) |> put_resp_header( "content-security-policy", - "frame-ancestors https://" <> myshopify_domain(conn) <> " https://admin.shopify.com;" + "frame-ancestors " <> myshopify_domain_url(conn) <> " https://admin.shopify.com;" ) end - defp myshopify_domain(%{assigns: %{shop: %{domain: domain}}}), do: domain + defp myshopify_domain_url(conn) do + case conn do + %{assigns: %{shop: %{domain: domain}}} -> "https://" <> domain + %{params: %{"shop" => domain}} -> "https://" <> domain + _ -> "" + end + end end From 6f08afd3c8749a667c663bb7f1e528ae052044ae Mon Sep 17 00:00:00 2001 From: Hez Ronningen Date: Thu, 24 Jul 2025 08:55:12 -0700 Subject: [PATCH 7/7] wip --- lib/shopify_api/shop_server.ex | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/lib/shopify_api/shop_server.ex b/lib/shopify_api/shop_server.ex index 5b84a628..20e992a1 100644 --- a/lib/shopify_api/shop_server.ex +++ b/lib/shopify_api/shop_server.ex @@ -68,8 +68,13 @@ defmodule ShopifyAPI.ShopServer do @impl GenServer def init(:ok) do create_table!() - for shop when is_struct(shop, Shop) <- do_initialize(), do: set(shop, false) - {:ok, :no_state} + {:ok, :no_state, {:continue, :init_shops}} + end + + @impl GenServer + def handle_continue(:init_shops, state) do + for %Shop{} = shop <- do_initialize(), do: set(shop, false) + {:noreply, state} end ## Private Helpers