Skip to content
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .formatter.exs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# Used by "mix format"
[
import_deps: [:ecto, :ecto_sql, :phoenix],
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]
115 changes: 115 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -348,3 +348,118 @@ window.app = createApp({
const sessionData = await instance.get('/api/initialize');
// Now you will have access to the current shop and Bob's-yer-uncle!
```

## Using Shopifex with Shopify CLI

Use the Shopify CLI to simplify the setup process and avoid manual configuration of URLs and environment variables.

For more details on using the Shopify CLI, refer to the [Shopify CLI documentation](https://shopify.dev/docs/api/shopify-cli).

### Folder Structure

Your project structure should look something like this:

```md
- phx/ # Phoenix app
- shopify/ # Shopify cli (`shopify app init`) generated contents
```

### Shopify CLI Configuration

1. Modify the existing `shopify.app.toml` file in the `shopify/` folder to include the following configuration. This ensures that Cloudflare tunnels are automatically updated during development:

```toml
[build]
automatically_update_urls_on_dev = true
```

2. Create a `shopify.web.toml` file in the `shopify/web/` folder. This ensures that the Shopify CLI starts your Phoenix app when running `shopify app dev`.

```toml
name = "phx"
roles = ["backend", "frontend"]
port = 4000

webhooks_path = "/shopify/webhooks"

[commands]
dev = "./start.sh"
```

3. Add the following `start.sh` script to the `shopify/web/` folder to start your Phoenix server:

```bash
#!/bin/bash

# Navigate to the phoenix directory
cd ./../phx

# Available environment variables
# https://shopify.dev/docs/apps/build/cli-for-apps/migrate-to-latest-cli#provided-variables
echo "[start.sh] Served from: '$HOST'"
echo "[start.sh] Enabled API scopes: '$SCOPES'"

# Start the Phoenix server
mix phx.server
```

### Configuring Phoenix with Shopify CLI

The Shopify CLI sets certain environment variables that can be used to configure your Phoenix app dynamically. Update your `runtime.exs` file to use these variables:

```elixir
# filepath: phx/config/runtime.exs
import Config

if config_env() != :test do
shopify_api_key = System.get_env("SHOPIFY_API_KEY")
shopify_api_secret = System.get_env("SHOPIFY_API_SECRET")
shopify_api_scopes = System.get_env("SCOPES")

confs = %{
"SHOPIFY_API_KEY" => shopify_api_key,
"SHOPIFY_API_SECRET" => shopify_api_secret,
"SCOPES" => shopify_api_scopes
}

for {conf_key, conf_value} <- confs do
if is_nil(conf_value) do
Logger.warning("""
environment variable #{conf_key} is missing.
In development this is automatically set when running `shopify app dev`
""")
end
end

config :shopifex,
api_key: shopify_api_key,
secret: shopify_api_secret,
scopes: shopify_api_scopes
end

if host = System.get_env("HOST") do
# Support proxy URLs `HOST` is set by the Shopify CLI when running the dev command
{:ok, host_uri} = URI.new(host)

config :shopifex,
redirect_uri: host_uri |> URI.append_path("/auth/callback") |> URI.to_string(),
reinstall_uri: host_uri |> URI.append_path("/auth/calback") |> URI.to_string(),
webhook_uri: host_uri |> URI.append_path("/shopify/webhooks") |> URI.to_string(),
payment_redirect_uri: host_uri |> URI.append_path("/shopify/payments") |> URI.to_string()
end
```

### Starting the Development Environment

1. Navigate to the `shopify/` folder.
2. Run the following command to start the Shopify development environment:

```sh
shopify app dev
```

This command will:

- Set up a Cloudflare proxy for your app
- Set environment variables required for your app
- Start your Phoenix server
52 changes: 52 additions & 0 deletions lib/shopifex/oauth.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
defmodule Shopifex.OAuth do
@moduledoc """
Shopify OAuth related functions.
"""

@doc """
Returns an url to redirect the user to the Shopify OAuth page.
Shopify docs: <https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/authorization-code-grant#redirect-to-the-authorization-code-flow>
"""
@spec oauth_redirect_url(String.t(), Keyword.t()) :: String.t()
def oauth_redirect_url(shop_url, opts \\ []) do
redirect_uri =
Keyword.get(opts, :redirect_uri, Application.fetch_env!(:shopifex, :redirect_uri))

query_params =
URI.encode_query(%{
client_id: Application.fetch_env!(:shopifex, :api_key),
scope: Application.fetch_env!(:shopifex, :scopes),
redirect_uri: redirect_uri,
state: Keyword.get(opts, :state, "")
})

"https://#{shop_url}/admin/oauth/authorize"
|> URI.new!()
|> URI.append_query(query_params)
|> URI.to_string()
end

@doc """
Calls the Shopify OAuth endpoint to get the access token.
Shopify docs: <https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/authorization-code-grant#step-4-get-an-access-token>
"""
@spec post_access_token(String.t(), String.t()) ::
{:ok, HTTPoison.Response.t()} | {:error, HTTPoison.Error.t()}
def post_access_token(shop_domain, code) do
headers = [
"Content-Type": "application/json",
Accept: "application/json"
]

body = %{
client_id: Application.fetch_env!(:shopifex, :api_key),
client_secret: Application.fetch_env!(:shopifex, :secret),
code: code
}

"https://#{shop_domain}/admin/oauth/access_token"
|> URI.new!()
|> URI.to_string()
|> HTTPoison.post(Jason.encode!(body), headers)
end
end
4 changes: 3 additions & 1 deletion lib/shopifex/plug.ex
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ defmodule Shopifex.Plug do
def current_shopify_host(%Plug.Conn{private: %{shopifex: %{shopify_host: shopify_host}}}),
do: shopify_host

def current_shopify_host(_), do: nil
def current_shopify_host(conn) do
Map.get(conn.params, "host")
Copy link
Collaborator Author

@NexPB NexPB Mar 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will allow the app bridge to render during install redirect as host is required

end

@doc """
Returns the token for the current session in a plug which has
Expand Down
18 changes: 4 additions & 14 deletions lib/shopifex/plug/shopify_session.ex
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,7 @@ defmodule Shopifex.Plug.ShopifySession do
received_hmac = Shopifex.Plug.get_hmac(conn)

if expected_hmac == received_hmac do
conn
|> do_new_session()
do_new_session(conn)
else
Logger.info("Invalid HMAC, expected #{expected_hmac}")
respond_invalid(conn)
Expand All @@ -49,7 +48,9 @@ defmodule Shopifex.Plug.ShopifySession do
defp do_new_session(conn = %{params: %{"shop" => shop_url}}) do
case Shopifex.Shops.get_shop_by_url(shop_url) do
nil ->
redirect_to_install(conn, shop_url)
conn
|> redirect(to: "/initialize-installation?#{conn.query_string}")
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hardcoding this redirect doesn't feel quite right

|> halt()

shop ->
locale = get_locale(conn)
Expand All @@ -59,17 +60,6 @@ defmodule Shopifex.Plug.ShopifySession do
end
end

defp redirect_to_install(conn, shop_url) do
Logger.info("Initiating shop installation for #{shop_url}")

install_url =
"https://#{shop_url}/admin/oauth/authorize?client_id=#{Application.fetch_env!(:shopifex, :api_key)}&scope=#{Application.fetch_env!(:shopifex, :scopes)}&redirect_uri=#{Application.fetch_env!(:shopifex, :redirect_uri)}"

conn
|> redirect(external: install_url)
|> halt()
end

defp respond_invalid(%Plug.Conn{private: %{phoenix_format: "json"}} = conn) do
conn
|> put_status(:forbidden)
Expand Down
18 changes: 15 additions & 3 deletions lib/shopifex/shops.ex
Original file line number Diff line number Diff line change
Expand Up @@ -110,16 +110,18 @@ defmodule Shopifex.Shops do
Returns a list of webhooks which were created.
"""
def configure_webhooks(shop) do
with {:ok, current_webhooks} <- get_current_webhooks(shop) do
configured_webhook_topics = Application.get_env(:shopifex, :webhook_topics)

with {:webhooks, [_ | _]} <- {:webhooks, configured_webhook_topics},
{:ok, current_webhooks} <- get_current_webhooks(shop) do
current_webhook_topics = Enum.map(current_webhooks, & &1.topic)

Logger.info(
"All current webhook topics for #{Shopifex.Shops.get_url(shop)}: #{Enum.join(current_webhook_topics, ", ")}"
)

current_webhook_topics = MapSet.new(current_webhook_topics)

topics = MapSet.new(Application.fetch_env!(:shopifex, :webhook_topics))
topics = MapSet.new(configured_webhook_topics)

# Make sure all the required topics are conifgured.
subscribe_to_topics = MapSet.difference(topics, current_webhook_topics)
Expand All @@ -136,6 +138,16 @@ defmodule Shopifex.Shops do
acc
end
end)
else
{:webhooks, nil} ->
Logger.info(
"Missing webhook_topics configuration, assuming webhooks will be managed through the Shopify CLI."
)

{:ok, []}

fallback ->
fallback
end
end

Expand Down
Loading