Skip to content
Draft
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
99 changes: 73 additions & 26 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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`).
Expand Down Expand Up @@ -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.
Expand All @@ -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]`

Expand Down
2 changes: 2 additions & 0 deletions config/dev.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
2 changes: 2 additions & 0 deletions config/test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
28 changes: 21 additions & 7 deletions lib/shopify_api.ex
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
defmodule ShopifyAPI do
alias ShopifyAPI.GraphQL.GraphQLQuery
alias ShopifyAPI.GraphQL.GraphQLResponse
alias ShopifyAPI.RateLimiting
alias ShopifyAPI.Throttled

Expand All @@ -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
Expand All @@ -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
Expand Down
41 changes: 37 additions & 4 deletions lib/shopify_api/app.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading
Loading