diff --git a/CHANGELOG.md b/CHANGELOG.md index fc0ada96..7cdafb90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ ## 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 +- New: GraphQL requests through [Req](https://hexdocs.pm/req/Req.html) are now done with GraphQLQuery modules and return GraphQLResponses. Ideally we will deprecate the previoud GraphQL method once people have had a chance to move over from the old method. +- 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. +- 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.4 - Fix: Add support for larger webhook payload bodies (15MB vs. the previous 8MB) diff --git a/README.md b/README.md index 5b1ad58a..bc454821 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,7 @@ - [ShopifyAPI and Plug.ShopifyAPI](#ShopifyAPI-and-PlugShopifyAPI) - [Installation](#Installation) + - [Upgrading to 1.0](#Upgrading-to-1.0) - [Installing this app in a Shop](#Installing-this-app-in-a-Shop) - [Configuration](#Configuration) - [API Version](#API-Version) @@ -56,6 +57,14 @@ config :shopify_api, ShopifyAPI.ShopServer, persistence: {MyApp.Shop, :save, []} ``` +### Upgrading to 1.0 + +With version 1.0 there are some stabilty changes and developer UX changes that make this easier to use. + +- Webhook Handler has been refactored to some simple configuration and a standard Phoenix Controller + - A [Webhook Scope](https://github.com/orbit-apps/elixir-shopifyapi/blob/next/lib/shopify_api/model/webhook_scope.ex) struct is passed along in to your controller for easier access of standard information (Shop, App, etc) + - The new setup is [here](https://github.com/orbit-apps/elixir-shopifyapi/blob/next/README.md#webhooks) as a intermediate step this could all be added and the controller could call the existing webhook handler module in the app's codebase. + ## Installing this app in a Shop There is a boilerplate repo for quickly getting up and running at [ShopifyApp](https://github.com/pixelunion/elixir-shopify-app) @@ -102,26 +111,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`). @@ -152,31 +189,40 @@ be found at [https://hexdocs.pm/shopify_api](https://hexdocs.pm/shopify_api). ## GraphQL -`GraphQL` implementation handles GraphQL Queries against Shopify API using HTTPoison library as client, this initial implementation consists of hitting Shopify GraphQL and returning the response in a tuple `{:ok, %Response{}} | {:error, %Response{}}` containing the response and metadata(actualQueryCost, throttleStatus). +GraphQL requests against [Shopify's GraphQL API](https://shopify.dev/docs/api/admin-graphql) are done through modules that use `ShopifyAPI.GraphQL.GraphQLQuery`. -Configure the version to use in your config.exs, it will default to a stable version as ref'd in the [graphql module](lib/shopify_api/graphql.ex). +### GraphQL Queries +GraphQL query modules are created with `use ShopifyAPI.GraphQL.GraphQLQuery` and implement the `ShopifyAPI.GraphQL.GraphQLQuery` behaviour. See the [GraphQLQuery](lib/shopify_api/graphql/graphql_query.ex) module for documentation. -```elixir -config :shopify_api, ShopifyAPI.GraphQL, graphql_version: "2019-07" -``` +### GraphQL Logging -### GraphQL Response +Logging is handled through Telemetry (`[:shopify_api, :graphql_request, :start]`, `[:shopify_api, :graphql_request, :stop]`, `[:shopify_api, :graphql_request, :exception]`). A basic logger is proivided with [ShopifyAPI.GraphQL.TelemetryLogger](lib/shopify_api/graphql/telemetry_logger.ex) and can be used with `ShopifyAPI.GraphQL.TelemetryLogger.attach()` in `application.ex`. -Because `GraphQL responses` can be a little complex we are parsing/wraping responses `%HTTPoison.response` to `%GraphQL.Response`. +### Handling GraphQL Errors -Successful response: +The happy path response from `GraphQLQuery.execute/2` is `{:ok, %ShopifyAPI.GraphQL.GraphQLResponse{results: ..., errors?: false}`. In most cases you can pipe your response through `ShopifyAPI.GraphQL.GraphQLResponse.resolve/1` to get `{:ok, results} | {:error, ShopifyAPI.GraphQL.GraphQLResponse{errors?: true} | {:error, exception}`. -```elixir -{:ok, %ShopifyAPI.GraphQL.Response{response: %{}, metadata: %{}, status_code: code}} -``` +Unfortuneately, GraphQL is not always that simple. GraphQL makes no promises of a transactional api and you can have partial success and partial failures. In that case you will need to dig deeper into the `GraphQLResponse`. In that case, you may need to stich together the `:results` and `:user_errors` from `%GraphQLResponse{}`. + +There are four main types of errors returned form GraphQL + - "errors" array in the response body. These can arrise from malformed queries or missing variables. This will return `{:ok, GraphQLResponse{errors?: false, errors: [_ | _]}}` + - "userErrors" array at the root of the query response. This is specific to Shopify's implementation of GraphQL. This will return `{:ok, GraphQLResponse{errors?: false, user_errors: [_ | _]}}` + - Non-200 responses - This will return `{:ok, GraphQLResponse{errors?: false, raw: %Req.Response{status: _}}}` + - Network errors - These will return a `{:error, Exception.t()}` from the `Req` request. + +### GraphQL version -Failed response: +Configure the version to use in your config.exs, it will default to a stable version as ref'd in the [graphql module](lib/shopify_api/graphql.ex). ```elixir -{:error, %HTTPoison.Response{}} +config :shopify_api, ShopifyAPI.GraphQL, graphql_version: "2024-10" ``` +### Previous GraphQL version + +We are soft deprecating the old `ShopifyAPI.graphql_request/4`. It will not be marked as deprecated until people have had a chance to move over to the new method. The reasons for the move include 1) moving away from HTTPoison and towards Req. 2) Better handling of partial failures. 3) Overall cleaner implementations and more access to proper errors. + ## Telemetry The `shopify_api` library will emit events using the [`:telemetry`](https://github.com/beam-telemetry/telemetry) library. Consumers of `shopify_api` can then use these events for customized metrics aggregation and more. @@ -185,8 +231,9 @@ The following telemetry events are generated: - `[:shopify_api, :rest_request, :failure]` - `[:shopify_api, :throttling, :over_limit]` - `[:shopify_api, :throttling, :within_limit]` -- `[:shopify_api, :graphql_request, :success]` -- `[:shopify_api, :graphql_request, :failure]` +- `[:shopify_api, :graphql_request, :start]` +- `[:shopify_api, :graphql_request, :stop]` +- `[:shopify_api, :graphql_request, :exception]` - `[:shopify_api, :bulk_operation, :success]` - `[:shopify_api, :bulk_operation, :failure]` 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.ex b/lib/shopify_api.ex index b51fdfac..825204ba 100644 --- a/lib/shopify_api.ex +++ b/lib/shopify_api.ex @@ -1,4 +1,6 @@ defmodule ShopifyAPI do + alias ShopifyAPI.GraphQL.GraphQLQuery + alias ShopifyAPI.GraphQL.GraphQLResponse alias ShopifyAPI.RateLimiting alias ShopifyAPI.Throttled @@ -10,22 +12,34 @@ defmodule ShopifyAPI do @doc """ A helper function for making throttled GraphQL requests. + Soft deprecated. Please use execute_graphql/3 or [GraphQLQuery](lib/shopify_api/graphql/graphql_query.ex) instead. + ## Example: iex> query = "mutation metafieldDelete($input: MetafieldDeleteInput!){ metafieldDelete(input: $input) {deletedId userErrors {field message }}}", 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 + @doc """ + Executes the given GrahpQLQuery for the given scope. + + See [GraphQLQuery](lib/shopify_api/graphql/graphql_query.ex) for details. + """ + @spec execute_graphql(GraphQLQuery.t(), ShopifyAPI.Scope.t(), keyword()) :: + {:ok, GraphQLResponse.t()} | {:error, Exception.t()} + def execute_graphql(%GraphQLQuery{} = query, scope, opts \\ []), + do: ShopifyAPI.GraphQL.execute(query, scope, opts) + def request(token, func), do: Throttled.request(func, token, RateLimiting.RESTTracker) @doc false @@ -47,8 +61,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/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 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/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/config.ex b/lib/shopify_api/config.ex index 431994af..333ea70b 100644 --- a/lib/shopify_api/config.ex +++ b/lib/shopify_api/config.ex @@ -1,13 +1,43 @@ defmodule ShopifyAPI.Config do - @moduledoc false + @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) + end + end + end end diff --git a/lib/shopify_api/graphql.ex b/lib/shopify_api/graphql.ex index 933d5a34..5405061d 100644 --- a/lib/shopify_api/graphql.ex +++ b/lib/shopify_api/graphql.ex @@ -5,14 +5,18 @@ defmodule ShopifyAPI.GraphQL do require Logger - alias ShopifyAPI.AuthToken - alias ShopifyAPI.GraphQL.{JSONParseError, Response, Telemetry} + alias ShopifyAPI.GraphQL.GraphQLQuery + alias ShopifyAPI.GraphQL.GraphQLResponse + alias ShopifyAPI.GraphQL.JSONParseError + alias ShopifyAPI.GraphQL.Response + alias ShopifyAPI.GraphQL.Telemetry alias ShopifyAPI.JSONSerializer @default_graphql_version "2020-10" @log_module __MODULE__ |> to_string() |> String.trim_leading("Elixir.") + @type opts :: keyword() @type query_response :: {:ok, Response.t()} | {:error, JSONParseError.t() | HTTPoison.Response.t() | HTTPoison.Error.t()} @@ -28,10 +32,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 +46,43 @@ defmodule ShopifyAPI.GraphQL do |> insert_variables(variables) |> JSONSerializer.encode!() - logged_request(auth, url, body, headers, opts) + logged_request(scope, url, body, headers, opts) + end + + @doc """ + Executes the given GrahpQLQuery for the given scope + + Telemetry events are sent to + - [:shopify_api, :graphql_request, :start], + - [:shopify_api, :graphql_request, :stop], + - [:shopify_api, :graphql_request, :exception] + + ShopifyAPI.GraphQL.TelemetryLogger is provided for basic logging. + """ + @spec execute(GraphQLQuery.t(), ShopifyAPI.Scope.t(), opts()) :: + {:ok, GraphQLResponse.success_t()} + | {:ok, GraphQLResponse.failure_t()} + | {:error, Exception.t()} + def execute(%GraphQLQuery{} = query, scope, opts \\ []) do + url = build_url(ShopifyAPI.Scopes.myshopify_domain(scope), opts) + headers = build_headers(ShopifyAPI.Scopes.access_token(scope), opts) + body = JSONSerializer.encode!(%{query: query.query_string, variables: query.variables}) + metadata = %{scope: scope, query: query} + + :telemetry.span( + [:shopify_api, :graphql_request], + metadata, + fn -> + case Req.post(url, body: body, headers: headers) do + {:ok, raw_response} -> + response = GraphQLResponse.parse(raw_response, query) + {{:ok, response}, Map.put(metadata, :response, response)} + + {:error, exception} -> + {{:error, exception}, Map.put(metadata, :error, exception)} + end + end + ) end defp build_body(query_string), do: %{query: query_string} @@ -87,14 +130,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 +164,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/graphql_query.ex b/lib/shopify_api/graphql/graphql_query.ex new file mode 100644 index 00000000..34b6a2c9 --- /dev/null +++ b/lib/shopify_api/graphql/graphql_query.ex @@ -0,0 +1,133 @@ +defmodule ShopifyAPI.GraphQL.GraphQLQuery do + @moduledoc """ + A quiery builder for Shopify GraphQL + + The query is made with a scope that implements `ShopifyAPI.Scope` + + In your query file `use ShopifyAPI.GraphQL.GraphQLQuery` and implement + - query_string/1 + - name/1 - matches the root name of the query + - path/1 - a list of access functions for the returning data. + + ```elixir + defmodule MyApp.Shopify.Query.ThemeList do + use ShopifyAPI.GraphQL.GraphQLQuery + + @theme_list ~S[ + query { + themes(first: 20) { + edges { + node { + name + id + role + } + } + } + } + ] + + def query_string, do: @theme_list + def name, do: "themes" + def path, do: ["edges", Access.all(), "node"] + end + ``` + + ```elixir + def list_themes(%Model.Scope{} = scope, variables) do + Query.ThemeList.query() + |> Query.ThemeList.assigns(variables) + |> Query.ThemeList.execute(scope) + |> GraphQLResponse.resolve() + end + ``` + """ + + defstruct [:name, :query_string, :variables, :path] + + alias ShopifyAPI.GraphQL.GraphQLResponse + + @type t :: %__MODULE__{ + name: String.t(), + query_string: String.t(), + variables: map(), + path: [term()] + } + + @callback query_string() :: String.t() + @callback name() :: String.t() + @callback path() :: list() + + defmacro __using__(_opts) do + quote do + @behaviour unquote(__MODULE__) + + @type t :: unquote(__MODULE__).t() + + @spec query :: t() + def query do + query_string() + |> unquote(__MODULE__).build(name()) + |> append_path(path()) + end + + defdelegate assign(query, key, value), to: unquote(__MODULE__) + defdelegate assigns(query, map), to: unquote(__MODULE__) + defdelegate append_path(query, access), to: unquote(__MODULE__) + defdelegate execute(query, scope), to: unquote(__MODULE__) + end + end + + def build(query_string, name) do + %__MODULE__{ + name: name, + query_string: query_string, + variables: %{}, + path: [name] + } + end + + @spec append_path(t(), any()) :: t() + def append_path(%__MODULE__{} = query, access), + do: %{query | path: query.path ++ List.wrap(access)} + + @spec assign(t(), any(), any()) :: t() + def assign(%__MODULE__{} = query, key, value), do: assigns(query, %{key => value}) + + @spec assigns(t(), map()) :: t() + def assigns(%__MODULE__{} = query, map) when is_map(map), + do: %{query | variables: Map.merge(query.variables, map)} + + @spec execute(t(), ShopifyAPI.Scope.t()) :: + {:ok, GraphQLResponse.success_t()} + | {:ok, GraphQLResponse.failure_t()} + | {:error, Exception.t()} + def execute(query, scope), do: ShopifyAPI.GraphQL.execute(query, scope) + + @doc """ + Returns a function that accesses the key/value paths as a map. + + ## Examples + iex> get_in( + ...> %{"nodes" => [ + ...> %{"filename" => "file1", "body" => %{"content" => "file1 content"}}, + ...> %{"filename" => "file2", "body" => %{"content" => "file2 content"}} + ...> ]}, + ...> ["nodes", GraphQLQuery.access_map(["filename"], ["body", "content"])] + ...> ) + %{"file1" => "file1 content", "file2" => "file2 content"} + """ + @spec access_map(term, term) :: Access.access_fun(data :: map, current_value :: term) + def access_map(key, value) do + fn + :get, data, next when is_list(data) -> + next.(Map.new(data, &{get_in(&1, key), get_in(&1, value)})) + + :get, data, next -> + next.(%{get_in(data, key) => get_in(data, value)}) + + :get_and_update, _data, _next -> + raise "access_map not implemented for get_and_update" + end + end +end diff --git a/lib/shopify_api/graphql/graphql_response.ex b/lib/shopify_api/graphql/graphql_response.ex new file mode 100644 index 00000000..5cb5c8d4 --- /dev/null +++ b/lib/shopify_api/graphql/graphql_response.ex @@ -0,0 +1,84 @@ +defmodule ShopifyAPI.GraphQL.GraphQLResponse do + @doc """ + Results of a GraphQLQuery + """ + alias ShopifyAPI.GraphQL.GraphQLQuery + + defstruct query: nil, + results: nil, + raw: nil, + errors: [], + user_errors: [], + metadata: nil, + errors?: false + + @type t() :: t(any()) + + @type t(results) :: success_t(results) | failure_t(results) + + @type success_t() :: success_t(any()) + @type success_t(results) :: %__MODULE__{ + results: results, + query: GraphQLQuery.t(), + raw: Req.Response.t(), + errors?: true + } + + @type failure_t() :: failure_t(any()) + @type failure_t(results) :: %__MODULE__{ + results: results | nil, + query: GraphQLQuery.t(), + raw: Req.Response.t(), + errors?: false + } + + @spec parse(Req.Response.t(), GraphQLQuery.t()) :: t() + def parse(%Req.Response{} = raw, %GraphQLQuery{} = query) do + %__MODULE__{query: query, raw: raw} + |> set_results() + |> set_errors() + |> set_user_errors() + end + + @spec resolve({:ok, success_t(type)}) :: {:ok, type} when type: any() + @spec resolve({:ok, failure_t(type)}) :: {:error, failure_t(type)} when type: any() + @spec resolve({:error, Exception.t()}) :: {:error, Exception.t()} + def resolve({:ok, %__MODULE__{errors?: false, results: results}}), do: {:ok, results} + def resolve({:ok, %__MODULE__{errors?: true} = response}), do: {:error, response} + def resolve({:error, error}), do: {:error, error} + + defp set_results( + %__MODULE__{raw: %Req.Response{body: %{"data" => data}, status: 200}} = graphql_response + ) do + if is_map(data) and Map.has_key?(data, graphql_response.query.name), + do: %{graphql_response | results: get_in(data, graphql_response.query.path)}, + else: %{graphql_response | errors?: true} + end + + defp set_results(%__MODULE__{raw: %Req.Response{body: _body}} = graphql_response), + do: %{graphql_response | errors?: true} + + defp set_errors( + %__MODULE__{raw: %Req.Response{body: %{"errors" => [_ | _] = errors}}} = graphql_response + ), + do: %{graphql_response | errors: errors, errors?: true} + + defp set_errors(%__MODULE__{raw: %Req.Response{body: _body}} = graphql_response), + do: graphql_response + + defp set_user_errors( + %__MODULE__{ + query: %{name: name}, + raw: %Req.Response{body: %{"data" => data}} + } = graphql_response + ) + when is_map(data) do + case get_in(data, [name, "userErrors"]) do + [_ | _] = user_errors -> %{graphql_response | user_errors: user_errors, errors?: true} + _ -> graphql_response + end + end + + defp set_user_errors(%__MODULE__{raw: %Req.Response{body: _body}} = graphql_response), + do: %{graphql_response | errors?: true} +end 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/graphql/telemetry_logger.ex b/lib/shopify_api/graphql/telemetry_logger.ex new file mode 100644 index 00000000..c6d1ebce --- /dev/null +++ b/lib/shopify_api/graphql/telemetry_logger.ex @@ -0,0 +1,79 @@ +defmodule ShopifyAPI.GraphQL.TelemetryLogger do + @doc """ + A basic implementation of logging for GraphQLQuery + + In your `application.ex` add `ShopifyAPI.GraphQL.TelemetryLogger.attach()` + """ + require Logger + + alias ShopifyAPI.GraphQL.GraphQLResponse + + def handle_event([:shopify_api, :graphql_request, :start], _measurements, metadata, _config) do + Logger.info("ShopifyAPI.GraphQL.start #{metadata.query.name}", details(metadata)) + end + + def handle_event( + [:shopify_api, :graphql_request, :stop], + measurements, + %{response: %GraphQLResponse{errors?: false}} = metadata, + _config + ) do + Logger.info( + "ShopifyAPI.GraphQL.stop #{metadata.query.name} finished in #{measurements.duration}", + details(metadata) + ) + end + + def handle_event( + [:shopify_api, :graphql_request, :stop], + measurements, + %{response: %GraphQLResponse{errors?: true}} = metadata, + _config + ) do + Logger.info( + "ShopifyAPI.GraphQL.stop #{metadata.query.name} finished with errors in #{measurements.duration}, #{inspect(metadata.response.errors)}#{inspect(metadata.response.errors)}", + details(metadata) + ) + end + + def handle_event( + [:shopify_api, :graphql_request, :stop], + measurements, + %{error: exception} = metadata, + _config + ) do + Logger.info( + "ShopifyAPI.GraphQL.stop #{metadata.query.name} failed with exception in #{measurements.duration}, #{inspect(exception)}", + details(metadata) + ) + end + + def handle_event([:shopify_api, :graphql_request, :exception], _measurements, metadata, _config) do + Logger.error( + "ShopifyAPI.GraphQL.exception #{metadata.query.name} #{metadata.kind} #{inspect(metadata.reason)} #{Exception.format_stacktrace(metadata.stacktrace)}", + details(metadata) + ) + end + + defp details(metadata) do + myshopify_domain = ShopifyAPI.Scopes.myshopify_domain(metadata.scope) + + [ + query: metadata.query.name, + myshopify_domain: myshopify_domain + ] + end + + def attach do + :telemetry.attach_many( + "shopifyapi-graphql-request", + [ + [:shopify_api, :graphql_request, :start], + [:shopify_api, :graphql_request, :stop], + [:shopify_api, :graphql_request, :exception] + ], + &handle_event/4, + nil + ) + 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..bdc5e1c1 --- /dev/null +++ b/lib/shopify_api/model/webhook_scope.ex @@ -0,0 +1,39 @@ +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 + +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/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 diff --git a/lib/shopify_api/plugs/webhook.ex b/lib/shopify_api/plugs/webhook.ex index 432f3f11..e63bd94f 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/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/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/mix.exs b/mix.exs index 2d5970b7..2df8b623 100644 --- a/mix.exs +++ b/mix.exs @@ -53,6 +53,7 @@ defmodule Plug.ShopifyAPI.MixProject do {:jason, "~> 1.0"}, {:jose, "~> 1.11.2"}, {:plug, "~> 1.0"}, + {:req, "~> 0.5.0"}, {:telemetry, "~> 0.4 or ~> 1.0"} ] end diff --git a/mix.lock b/mix.lock index b4fcb03a..91697cf4 100644 --- a/mix.lock +++ b/mix.lock @@ -17,8 +17,10 @@ "ex_machina": {:hex, :ex_machina, "2.8.0", "a0e847b5712065055ec3255840e2c78ef9366634d62390839d4880483be38abe", [:mix], [{:ecto, "~> 2.2 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}], "hexpm", "79fe1a9c64c0c1c1fab6c4fa5d871682cb90de5885320c187d117004627a7729"}, "faker": {:hex, :faker, "0.18.0", "943e479319a22ea4e8e39e8e076b81c02827d9302f3d32726c5bf82f430e6e14", [:mix], [], "hexpm", "bfbdd83958d78e2788e99ec9317c4816e651ad05e24cfd1196ce5db5b3e81797"}, "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, + "finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"}, "gen_stage": {:hex, :gen_stage, "1.2.1", "19d8b5e9a5996d813b8245338a28246307fd8b9c99d1237de199d21efc4c76a1", [:mix], [], "hexpm", "83e8be657fa05b992ffa6ac1e3af6d57aa50aace8f691fcf696ff02f8335b001"}, "hackney": {:hex, :hackney, "1.23.0", "55cc09077112bcb4a69e54be46ed9bc55537763a96cd4a80a221663a7eafd767", [:rebar3], [{:certifi, "~> 2.14.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", "6cd1c04cd15c81e5a493f167b226a15f0938a84fc8f0736ebe4ddcab65c0b44e"}, + "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "httpoison": {:hex, :httpoison, "2.2.3", "a599d4b34004cc60678999445da53b5e653630651d4da3d14675fedc9dd34bd6", [:mix], [{:hackney, "~> 1.21", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "fa0f2e3646d3762fdc73edb532104c8619c7636a6997d20af4003da6cfc53e53"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, @@ -29,12 +31,16 @@ "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mimerl": {:hex, :mimerl, "1.3.0", "d0cd9fc04b9061f82490f6581e0128379830e78535e017f7780f37fea7545726", [:rebar3], [], "hexpm", "a1e15a50d1887217de95f0b9b0793e32853f7c258a5cd227650889b38839fe9d"}, + "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, + "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, + "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, "plug": {:hex, :plug, "1.18.1", "5067f26f7745b7e31bc3368bc1a2b818b9779faa959b49c934c17730efc911cf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "57a57db70df2b422b564437d2d33cf8d33cd16339c1edb190cd11b1a3a546cc2"}, "plug_cowboy": {:hex, :plug_cowboy, "2.5.2", "62894ccd601cf9597e2c23911ff12798a8a18d237e9739f58a6b04e4988899fe", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "ea6e87f774c8608d60c8d34022a7d073bd7680a0a013f049fc62bf35efea1044"}, "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, + "req": {:hex, :req, "0.5.15", "662020efb6ea60b9f0e0fac9be88cd7558b53fe51155a2d9899de594f9906ba9", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "a6513a35fad65467893ced9785457e91693352c70b58bbc045b47e5eb2ef0c53"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, "stream_data": {:hex, :stream_data, "1.2.0", "58dd3f9e88afe27dc38bef26fce0c84a9e7a96772b2925c7b32cd2435697a52b", [:mix], [], "hexpm", "eb5c546ee3466920314643edf68943a5b14b32d1da9fe01698dc92b73f89a9ed"}, "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, 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_query_test.exs b/test/shopify_api/graphql/graphql_query_test.exs new file mode 100644 index 00000000..efd08e34 --- /dev/null +++ b/test/shopify_api/graphql/graphql_query_test.exs @@ -0,0 +1,99 @@ +defmodule ShopifyAPI.GraphQL.GraphQLQueryTest do + use ExUnit.Case, async: true + + import ShopifyAPI.Factory + import ShopifyAPI.SessionTokenSetup + import ShopifyAPI.BypassSetup + + alias ShopifyAPI.GraphQL.GraphQLQuery + alias ShopifyAPI.GraphQL.GraphQLResponse + + alias ShopifyAPI.Support.Query.FetchShop + alias ShopifyAPI.Support.Query.ThemeFileCopy + alias ShopifyAPI.Support.Query.ThemesFilesFetch + + doctest ShopifyAPI.GraphQL.GraphQLQuery + + setup [:bypass, :offline_token] + + describe "modules using GraphQLQuery" do + test "make a successful graphql request", %{ + bypass: bypass, + offline_token: scope + } do + expect_once(bypass, FetchShop.success_response()) + + assert {:ok, shop} = + FetchShop.query() |> FetchShop.execute(scope) |> GraphQLResponse.resolve() + + assert shop["id"] == "gid://shopify/Shop/10000000000" + end + + test "handles userErrors responses ", %{ + bypass: bypass, + shop: _shop, + offline_token: scope + } do + expect_once(bypass, ThemeFileCopy.user_errors_response()) + + variables = %{ + theme_id: "gid://shopify/Shop/10000000000", + files: [ + %{"srcFilename" => "templates/index.json", "dstFilename" => "layout/deleteme.json"} + ] + } + + assert {:error, %GraphQLResponse{} = response} = + ThemeFileCopy.query() + |> ThemeFileCopy.assigns(variables) + |> ThemeFileCopy.execute(scope) + |> GraphQLResponse.resolve() + + assert [%{"field" => ["files"], "message" => _} | _] = response.user_errors + end + + test "handles error responses", %{ + bypass: bypass, + shop: _shop, + offline_token: scope + } do + expect_once(bypass, ThemeFileCopy.error_response()) + + variables = %{ + theme_id: "gid://shopify/Shop/invalid", + files: [ + %{"srcFilename" => "templates/index.json", "dstFilename" => "layout/deleteme.json"} + ] + } + + assert {:error, %GraphQLResponse{} = response} = + ThemeFileCopy.query() + |> ThemeFileCopy.assigns(variables) + |> ThemeFileCopy.execute(scope) + |> GraphQLResponse.resolve() + + assert [%{"path" => ["themeFilesCopy"], "message" => "invalid id"} | _] = response.errors + end + + test "decomposes complex responses", %{ + bypass: bypass, + shop: _shop, + offline_token: scope + } do + expect_once(bypass, ThemesFilesFetch.success_response()) + + variables = %{ + theme_id: "gid://shopify/Shop/10000000000", + files: ["layout/theme.liquid"] + } + + assert {:ok, results} = + ThemesFilesFetch.query() + |> ThemesFilesFetch.assigns(variables) + |> ThemesFilesFetch.execute(scope) + |> GraphQLResponse.resolve() + + assert results == %{"layout/theme.liquid" => "redacted content"} + end + end +end diff --git a/test/shopify_api/graphql/graphql_test.exs b/test/shopify_api/graphql/graphql_test.exs index 4be09976..845eb8b3 100644 --- a/test/shopify_api/graphql/graphql_test.exs +++ b/test/shopify_api/graphql/graphql_test.exs @@ -1,10 +1,15 @@ 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.GraphQLResponse alias ShopifyAPI.GraphQL.Response + alias ShopifyAPI.JSONSerializer @data %{ "metafield1" => %{ @@ -52,22 +57,89 @@ defmodule ShopifyAPI.GraphQL.GraphQLTest do setup _context do bypass = Bypass.open() + {:ok, [bypass: bypass, shop: build(:shop, domain: "localhost:#{bypass.port}")]} + end + + setup [:offline_token] + + describe "execute/3" do + defmodule FetchShopTest do + use ShopifyAPI.GraphQL.GraphQLQuery + + @theme_list """ + query { + shop { + id + createdAt + email + contactEmail + shopOwnerName + name + plan { + shopifyPlus + } + url + ianaTimezone + } + } + """ + + def query_string, do: @theme_list + def name, do: "shop" + def path, do: [] + end - token = %AuthToken{ - token: "1234", - shop_name: "localhost:#{bypass.port}" + @fetch_shop_response %{ + "data" => %{ + "shop" => %{ + "contactEmail" => "email@example.com", + "createdAt" => "2024-07-31T00:55:30Z", + "email" => "email@example.com", + "ianaTimezone" => "America/New_York", + "id" => "gid://shopify/Shop/10000000000", + "name" => "example_shop", + "plan" => %{"shopifyPlus" => false}, + "shopOwnerName" => "Graham Baradoy", + "url" => "https://example_shop.myshopify.com" + } + }, + "extensions" => %{ + "cost" => %{ + "actualQueryCost" => 2, + "requestedQueryCost" => 2, + "throttleStatus" => %{ + "currentlyAvailable" => 1998, + "maximumAvailable" => 2000.0, + "restoreRate" => 100.0 + } + } + } } - shop = %Shop{domain: "localhost:#{bypass.port}"} + test "excutes a GraphQLQuery", %{ + bypass: bypass, + shop: _shop, + offline_token: scope + } do + Bypass.expect_once( + bypass, + "POST", + "/admin/api/#{GraphQL.configured_version()}/graphql.json", + fn conn -> Conn.resp(conn, 200, @fetch_shop_response |> JSONSerializer.encode!()) end + ) - {:ok, %{bypass: bypass, auth_token: token, shop: shop}} + assert {:ok, %GraphQLResponse{results: shop}} = + FetchShopTest.query() |> GraphQL.execute(scope) + + assert shop["id"] == "gid://shopify/Shop/10000000000" + end end 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 +160,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 +181,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 +203,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 +223,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 +252,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/plugs/webhook_scope_setup_test.exs b/test/shopify_api/plugs/webhook_scope_setup_test.exs new file mode 100644 index 00000000..26942e59 --- /dev/null +++ b/test/shopify_api/plugs/webhook_scope_setup_test.exs @@ -0,0 +1,58 @@ +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 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/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 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/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 diff --git a/test/support/bypass_setup.ex b/test/support/bypass_setup.ex new file mode 100644 index 00000000..fd8b276a --- /dev/null +++ b/test/support/bypass_setup.ex @@ -0,0 +1,26 @@ +defmodule ShopifyAPI.BypassSetup do + import ShopifyAPI.Factory + + alias ShopifyAPI.GraphQL + alias ShopifyAPI.JSONSerializer + + alias Plug.Conn + + def bypass(_context) do + bypass = Bypass.open() + myshopify_domain = "localhost:#{bypass.port}" + shop = build(:shop, domain: myshopify_domain) + {:ok, [bypass: bypass, shop: shop, myshopify_domain: myshopify_domain]} + end + + def expect_once(bypass, responose) do + response_string = JSONSerializer.encode!(responose) + + Bypass.expect_once( + bypass, + "POST", + "/admin/api/#{GraphQL.configured_version()}/graphql.json", + fn conn -> Conn.resp(conn, 200, response_string) end + ) + end +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/query/fetch_shop.ex b/test/support/query/fetch_shop.ex new file mode 100644 index 00000000..eda44b79 --- /dev/null +++ b/test/support/query/fetch_shop.ex @@ -0,0 +1,53 @@ +defmodule ShopifyAPI.Support.Query.FetchShop do + use ShopifyAPI.GraphQL.GraphQLQuery + + @theme_list """ + query { + shop { + id + createdAt + email + contactEmail + shopOwnerName + name + plan { + shopifyPlus + } + url + ianaTimezone + } + } + """ + + def query_string, do: @theme_list + def name, do: "shop" + def path, do: [] + + @success_response %{ + "data" => %{ + "shop" => %{ + "contactEmail" => "email@example.com", + "createdAt" => "2024-07-31T00:55:30Z", + "email" => "email@example.com", + "ianaTimezone" => "America/New_York", + "id" => "gid://shopify/Shop/10000000000", + "name" => "example_shop", + "plan" => %{"shopifyPlus" => false}, + "shopOwnerName" => "Example Owner", + "url" => "https://example_shop.myshopify.com" + } + }, + "extensions" => %{ + "cost" => %{ + "actualQueryCost" => 2, + "requestedQueryCost" => 2, + "throttleStatus" => %{ + "currentlyAvailable" => 1998, + "maximumAvailable" => 2000.0, + "restoreRate" => 100.0 + } + } + } + } + def success_response, do: @success_response +end diff --git a/test/support/query/theme_file_copy.ex b/test/support/query/theme_file_copy.ex new file mode 100644 index 00000000..01100563 --- /dev/null +++ b/test/support/query/theme_file_copy.ex @@ -0,0 +1,80 @@ +defmodule ShopifyAPI.Support.Query.ThemeFileCopy do + use ShopifyAPI.GraphQL.GraphQLQuery + + @theme_files_copy """ + mutation themeFilesCopy($files: [ThemeFilesCopyFileInput!]!, $theme_id: ID!) { + themeFilesCopy(files: $files, themeId: $theme_id) { + copiedThemeFiles { + filename + } + userErrors { + field + message + } + } + } + """ + + def query_string, do: @theme_files_copy + def name, do: "themeFilesCopy" + def path, do: ["copiedThemeFiles"] + + @error_response %{ + "data" => %{"themeFilesCopy" => nil}, + "errors" => [ + %{ + "extensions" => %{"code" => "RESOURCE_NOT_FOUND"}, + "locations" => [%{"column" => 3, "line" => 2}], + "message" => "invalid id", + "path" => ["themeFilesCopy"] + } + ], + "extensions" => %{ + "cost" => %{ + "actualQueryCost" => 1, + "requestedQueryCost" => 10, + "throttleStatus" => %{ + "currentlyAvailable" => 1999, + "maximumAvailable" => 2000.0, + "restoreRate" => 100.0 + } + } + } + } + + @user_errors_response %{ + "data" => %{ + "themeFilesCopy" => %{ + "copiedThemeFiles" => [], + "userErrors" => [ + %{ + "field" => ["files"], + "message" => "Missing {{content_for_header}} in the head section of the template" + }, + %{ + "field" => ["files"], + "message" => "Missing {{content_for_layout}} in the body section of the template" + }, + %{ + "field" => ["files"], + "message" => "Must have a .liquid file extension" + } + ] + } + }, + "extensions" => %{ + "cost" => %{ + "actualQueryCost" => 10, + "requestedQueryCost" => 10, + "throttleStatus" => %{ + "currentlyAvailable" => 1990, + "maximumAvailable" => 2000.0, + "restoreRate" => 100.0 + } + } + } + } + + def error_response, do: @error_response + def user_errors_response, do: @user_errors_response +end diff --git a/test/support/query/theme_files_fetch.ex b/test/support/query/theme_files_fetch.ex new file mode 100644 index 00000000..894319bc --- /dev/null +++ b/test/support/query/theme_files_fetch.ex @@ -0,0 +1,62 @@ +defmodule ShopifyAPI.Support.Query.ThemesFilesFetch do + use ShopifyAPI.GraphQL.GraphQLQuery + + alias ShopifyAPI.GraphQL.GraphQLQuery + + @themes_files_fetch """ + query themeFilesFetch($files: [String!], $theme_id: ID!) { + themeFilesFetch: theme(id: $theme_id) { + id + name + role + files(filenames: $files, first: 250) { + nodes { + filename + body { + ... on OnlineStoreThemeFileBodyText { + content + } + } + } + } + } + } + """ + + def query_string, do: @themes_files_fetch + def name, do: "themeFilesFetch" + def path, do: ["files", "nodes", GraphQLQuery.access_map(["filename"], ["body", "content"])] + + @success_response %{ + "data" => %{ + "themeFilesFetch" => %{ + "files" => %{ + "nodes" => [ + %{ + "body" => %{ + "content" => "redacted content" + }, + "filename" => "layout/theme.liquid" + } + ] + }, + "id" => "gid://shopify/Shop/10000000000", + "name" => "Rise", + "role" => "UNPUBLISHED" + } + }, + "extensions" => %{ + "cost" => %{ + "actualQueryCost" => 5, + "requestedQueryCost" => 24, + "throttleStatus" => %{ + "currentlyAvailable" => 1983, + "maximumAvailable" => 2000.0, + "restoreRate" => 100.0 + } + } + } + } + + def success_response, do: @success_response +end 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