Skip to content

Next #600

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 10 commits into
base: main
Choose a base branch
from
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
- Fix: 0.16.2 broke the installation path when no JWT was passed along
- New: Reworked webhook flow, [check readme](README.md#Webhooks) for details on how to use
- Deprecation: old Plugs.Webhook is being replaced and will be removed eventually
- New: Add Scopes context and Scope protocol. Change GraphQL queries to expect scopes. AuthToken can be used as a scope as a fallback via the defimpl in the AuthToken file.

## 0.16.2

- BREAKING: Reworked Plugs.AdminAuthenticator to use the new JWT Session functions, this breaks the old redirect for exchanging sessions on install
Expand Down
50 changes: 39 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,26 +102,54 @@ end

## Webhooks

To set up your app to receive webhooks, first you'll need to add `ShopifyAPI.Plugs.Webhook` to your `Endpoint` module:

Add a custom body reader and HMAC validation to your parser config `body_reader: {ShopifyAPI.WebhookHMACValidator, :read_body, []}` Your parser should now look like:
```elixir
plug ShopifyAPI.Plugs.Webhook,
app_name: "my-app-name",
prefix: "/shopify/webhook",
callback: {WebhookHandler, :handle_webhook, []}
plug Plug.Parsers,
parsers: [:urlencoded, :multipart, :json],
pass: ["*/*"],
body_reader: {ShopifyAPI.WebhookHMACValidator, :read_body, []},
json_decoder: Phoenix.json_library()
```

You'll also need to define a corresponding `WebhookHandler` module in your app:
Add a route:
```elixir
pipeline :shopify_webhook do
plug ShopifyAPI.Plugs.WebhookEnsureValidation
plug ShopifyAPI.Plugs.WebhookScopeSetup
end

scope "/shopify/webhook", MyAppWeb do
pipe_through :shopify_webhook
# The app_name path param is optional if the `config :shopify_api, :app_name, "my_app"` is set
post "/:app_name", ShopifyWebhooksController, :webhook
end
```

Add a controller:
```elixir
defmodule WebhookHandler do
def handle_webhook(app, shop, domain, payload) do
# TODO implement me!
defmodule SectionsAppWeb.ShopifyWebhooksController do
use SectionsAppWeb, :controller
require Logger

def webhook(
%{assigns: %{webhook_scope: %{topic: "app_subscriptions/update"} = webhook_scope}} = conn,
params
) do
Logger.warning("Doing work on app subscription update with params #{inspect(params)}",
myshopify_domain: webhook_scope.myshopify_domain
)

json(conn, %{success: true})
end

def webhook(%{assigns: %{webhook_scope: webhook_scope}} = conn, _params) do
Logger.warning("Unhandled webhook: #{inspect(webhook_scope.topic)}")
json(conn, %{success: true})
end
end
```

And there you go!
The old `ShopifyAPI.Plugs.Webhook` method has been deprecated.

Now webhooks sent to `YOUR_URL/shopify/webhook` will be interpreted as webhooks for the `my-app-name` app.
If you append an app name to the URL in the Shopify configuration, that app will be used instead (e.g. `/shopify/webhook/private-app-name`).
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"
14 changes: 7 additions & 7 deletions lib/shopify_api.ex
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,14 @@ defmodule ShopifyAPI do
iex> estimated_cost = 10
iex> variables = %{input: %{id: "gid://shopify/Metafield/9208558682200"}}
iex> options = [debug: true]
iex> ShopifyAPI.graphql_request(auth_token, query, estimated_cost, variables, options)
iex> ShopifyAPI.graphql_request(scope, query, estimated_cost, variables, options)
{:ok, %ShopifyAPI.GraphQL.Response{...}}
"""
@spec graphql_request(ShopifyAPI.AuthToken.t(), String.t(), integer(), map(), list()) ::
@spec graphql_request(ShopifyAPI.Scope.t(), String.t(), integer(), map(), list()) ::
ShopifyAPI.GraphQL.query_response()
def graphql_request(token, query, estimated_cost, variables \\ %{}, opts \\ []) do
func = fn -> ShopifyAPI.GraphQL.query(token, query, variables, opts) end
Throttled.graphql_request(func, token, estimated_cost)
def graphql_request(scope, query, estimated_cost, variables \\ %{}, opts \\ []) do
func = fn -> ShopifyAPI.GraphQL.query(scope, query, variables, opts) end
Throttled.graphql_request(func, scope, estimated_cost)
end

def request(token, func), do: Throttled.request(func, token, RateLimiting.RESTTracker)
Expand All @@ -47,8 +47,8 @@ defmodule ShopifyAPI do
depending on if you enable user_user_tokens.
"""
@spec shopify_oauth_url(ShopifyAPI.App.t(), String.t(), list()) :: String.t()
def shopify_oauth_url(app, domain, opts \\ [])
when is_struct(app, ShopifyAPI.App) and is_binary(domain) and is_list(opts) do
def shopify_oauth_url(%ShopifyAPI.App{} = app, domain, opts \\ [])
when is_binary(domain) and is_list(opts) do
opts = Keyword.merge(@oauth_default_options, opts)
user_token_query_params = opts |> Keyword.get(:use_user_tokens) |> per_user_query_params()
query_params = oauth_query_params(app) ++ user_token_query_params
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
77 changes: 53 additions & 24 deletions lib/shopify_api/app_server.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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, [
Expand Down
26 changes: 26 additions & 0 deletions lib/shopify_api/auth_token.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading