├── .credo.exs ├── .editorconfig ├── .formatter.exs ├── .github ├── dependabot.yml └── workflows │ └── push.yml ├── .gitignore ├── .tool-versions ├── CHANGELOG.md ├── LICENSE ├── README.md ├── config ├── config.exs ├── dev.exs ├── prod.exs └── test.exs ├── coveralls.json ├── lib ├── shopify_api.ex └── shopify_api │ ├── app.ex │ ├── app_server.ex │ ├── application.ex │ ├── associated_user.ex │ ├── auth_request.ex │ ├── auth_token.ex │ ├── auth_token_server.ex │ ├── bulk.ex │ ├── bulk │ ├── cancel.ex │ ├── query.ex │ └── telemetry.ex │ ├── config.ex │ ├── exception.ex │ ├── graphql.ex │ ├── graphql │ ├── parse_error.ex │ ├── response.ex │ └── telemetry.ex │ ├── json_serializer.ex │ ├── jwt_session_token.ex │ ├── plugs │ ├── admin_authenticator.ex │ ├── auth_shop_session_token.ex │ ├── customer_authenticator.ex │ ├── put_shopify_content_headers.ex │ └── webhook.ex │ ├── rate_limiting │ ├── graphql.ex │ ├── graphql_call_limits.ex │ ├── graphql_tracker.ex │ ├── rest.ex │ ├── rest_call_limits.ex │ ├── rest_tracker.ex │ └── tracker.ex │ ├── rest.ex │ ├── rest │ ├── access_scope.ex │ ├── application_charge.ex │ ├── application_credit.ex │ ├── asset.ex │ ├── carrier_service.ex │ ├── checkout.ex │ ├── collect.ex │ ├── custom_collection.ex │ ├── customer.ex │ ├── customer_address.ex │ ├── customer_saved_search.ex │ ├── discount_code.ex │ ├── draft_order.ex │ ├── event.ex │ ├── fulfillment.ex │ ├── fulfillment_event.ex │ ├── fulfillment_service.ex │ ├── inventory_item.ex │ ├── inventory_level.ex │ ├── location.ex │ ├── marketing_event.ex │ ├── metafield.ex │ ├── order.ex │ ├── price_rule.ex │ ├── product.ex │ ├── product_image.ex │ ├── recurring_application_charge.ex │ ├── redirect.ex │ ├── refund.ex │ ├── report.ex │ ├── request.ex │ ├── shop.ex │ ├── smart_collection.ex │ ├── tender_transaction.ex │ ├── theme.ex │ ├── transaction.ex │ ├── usage_charge.ex │ ├── user.ex │ ├── variant.ex │ └── webhook.ex │ ├── router.ex │ ├── security.ex │ ├── shop.ex │ ├── shop_server.ex │ ├── shopify_id.ex │ ├── shopify_id │ └── ecto.ex │ ├── supervisor.ex │ ├── throttled.ex │ ├── user_token.ex │ └── user_token_server.ex ├── mix.exs ├── mix.lock ├── priv └── plts │ └── .gitkeep └── test ├── shopify_api ├── bulk │ ├── query_test.exs │ └── telemetry_test.exs ├── graphql │ ├── graphql_test.exs │ ├── response_test.exs │ └── telemetry_test.exs ├── plugs │ ├── admin_authenticator_test.exs │ ├── customer_authenticator_test.exs │ └── webhook_test.exs ├── rate_limiting │ ├── graphql_call_limits_test.exs │ ├── graphql_tracker_test.exs │ ├── rest_call_limits_test.exs │ └── rest_tracker_test.exs ├── rest │ ├── product_test.exs │ └── redirect_test.exs ├── rest_test.exs ├── router_test.exs ├── shopify_id_test.exs └── throttled_test.exs ├── support ├── factory.ex ├── session_token_setup.ex └── shopify_validation_setup.ex └── test_helper.exs /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | indent_style = space 7 | indent_size = 2 8 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["mix.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: mix 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | open-pull-requests-limit: 10 8 | - package-ecosystem: "github-actions" 9 | directory: "/" 10 | schedule: 11 | interval: "daily" 12 | -------------------------------------------------------------------------------- /.github/workflows/push.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | test: 6 | runs-on: ubuntu-latest 7 | 8 | env: 9 | MIX_ENV: test 10 | 11 | strategy: 12 | matrix: 13 | otp: ['26', '27'] 14 | elixir: ['1.17', '1.18'] 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Cache dialyzer plts 20 | uses: actions/cache@v4 21 | with: 22 | path: priv/plts 23 | key: ${{runner.os}}-${{matrix.otp}}-${{matrix.elixir}}-plts 24 | 25 | - name: Setup elixir 26 | uses: erlef/setup-beam@v1 27 | with: 28 | otp-version: ${{matrix.otp}} 29 | elixir-version: ${{matrix.elixir}} 30 | 31 | - name: Deps get and check unused 32 | run: mix deps.get && mix deps.unlock --check-unused 33 | 34 | - name: Check Credo 35 | run: mix credo 36 | 37 | - name: Run Tests 38 | run: mix do compile --warnings-as-errors, test 39 | 40 | - name: Dialyzer 41 | run: mix dialyzer --halt-exit-status 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | .elixir_ls 4 | 5 | # If you run "mix test --cover", coverage assets end up here. 6 | /cover/ 7 | 8 | # The directory Mix downloads your dependencies sources to. 9 | /deps/ 10 | 11 | # Where 3rd-party dependencies like ExDoc output generated docs. 12 | /doc/ 13 | 14 | # Ignore .fetch files in case you like to edit your project deps locally. 15 | /.fetch 16 | 17 | # If the VM crashes, it generates a dump, let's ignore it too. 18 | erl_crash.dump 19 | 20 | # Also ignore archive artifacts (built via "mix archive.build"). 21 | *.ez 22 | 23 | # Ignore package tarball (built via "mix hex.build"). 24 | shopify_api-*.tar 25 | 26 | # Dialyzer will create PLT files here 27 | /priv/plts/*.plt 28 | /priv/plts/*.plt.hash 29 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | erlang 27.2.1 2 | elixir 1.17.3-otp-27 3 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | import Config 4 | 5 | # This configuration is loaded before any dependency and is restricted 6 | # to this project. If another project depends on this project, this 7 | # file won't be loaded nor affect the parent project. For this reason, 8 | # if you want to provide default values for your application for 9 | # 3rd-party users, it should be done in your "mix.exs" file. 10 | 11 | # You can configure your application as: 12 | # 13 | # config :shopify_api, key: :value 14 | # 15 | # and access this configuration in your application as: 16 | # 17 | # Application.get_env(:shopify_api, :key) 18 | # 19 | # You can also configure a 3rd-party app: 20 | # 21 | # config :logger, level: :info 22 | # 23 | 24 | # Provider out server with a min of an empty Map 25 | config :shopify_api, ShopifyAPI.ShopServer, %{} 26 | config :shopify_api, ShopifyAPI.AppServer, %{} 27 | 28 | # 29 | config :shopify_api, ShopifyAPI.Webhook, %{ 30 | uri: "https://example.com/shop/webhook" 31 | } 32 | 33 | config :shopify_api, 34 | rest_recv_timeout: 5000 35 | 36 | # Import environment specific config. This must remain at the bottom 37 | # of this file so it overrides the configuration defined above. 38 | import_config "#{Mix.env()}.exs" 39 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # Do not include metadata nor timestamps in development logs 4 | config :logger, :console, format: "[$level] $message\n" 5 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orbit-apps/elixir-shopifyapi/65174ad92f4212d5a02ac6bc5847b15d597a5de3/config/prod.exs -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # Print only warnings and errors during test 4 | config :logger, level: :warning 5 | config :bypass, adapter: Plug.Adapters.Cowboy2 6 | 7 | config :shopify_api, 8 | customer_api_secret_keys: ["new_secret", "old_secret"], 9 | transport: "http" 10 | -------------------------------------------------------------------------------- /coveralls.json: -------------------------------------------------------------------------------- 1 | { 2 | "skip_files": [ 3 | "lib/shopify_api/rest/access_scope.ex", 4 | "lib/shopify_api/rest/application_charge.ex", 5 | "lib/shopify_api/rest/application_credit.ex", 6 | "lib/shopify_api/rest/asset.ex", 7 | "lib/shopify_api/rest/carrier_service.ex", 8 | "lib/shopify_api/rest/collect.ex", 9 | "lib/shopify_api/rest/custom_collection.ex", 10 | "lib/shopify_api/rest/customer.ex", 11 | "lib/shopify_api/rest/customer_address.ex", 12 | "lib/shopify_api/rest/customer_saved_search.ex", 13 | "lib/shopify_api/rest/discount_code.ex", 14 | "lib/shopify_api/rest/draft_order.ex", 15 | "lib/shopify_api/rest/event.ex", 16 | "lib/shopify_api/rest/fulfillment.ex", 17 | "lib/shopify_api/rest/fulfillment_event.ex", 18 | "lib/shopify_api/rest/fulfillment_service.ex", 19 | "lib/shopify_api/rest/inventory_item.ex", 20 | "lib/shopify_api/rest/inventory_level.ex", 21 | "lib/shopify_api/rest/location.ex", 22 | "lib/shopify_api/rest/marketing_event.ex", 23 | "lib/shopify_api/rest/metafield.ex", 24 | "lib/shopify_api/rest/order.ex", 25 | "lib/shopify_api/rest/price_rule.ex", 26 | "lib/shopify_api/rest/product.ex", 27 | "lib/shopify_api/rest/product_image.ex", 28 | "lib/shopify_api/rest/recurring_application_charge.ex", 29 | "lib/shopify_api/rest/refund.ex", 30 | "lib/shopify_api/rest/report.ex", 31 | "lib/shopify_api/rest/shop.ex", 32 | "lib/shopify_api/rest/smart_collection.ex", 33 | "lib/shopify_api/rest/tag.ex", 34 | "lib/shopify_api/rest/tender_transaction.ex", 35 | "lib/shopify_api/rest/theme.ex", 36 | "lib/shopify_api/rest/transaction.ex", 37 | "lib/shopify_api/rest/usage_charge.ex", 38 | "lib/shopify_api/rest/user.ex", 39 | "lib/shopify_api/rest/variant.ex", 40 | "lib/shopify_api/rest/webhook.ex" 41 | ] 42 | } -------------------------------------------------------------------------------- /lib/shopify_api.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI do 2 | alias ShopifyAPI.RateLimiting 3 | alias ShopifyAPI.Throttled 4 | 5 | @shopify_admin_uri URI.new!("https://admin.shopify.com") 6 | @shopify_oauth_path "/admin/oauth/authorize" 7 | @oauth_default_options [use_user_tokens: false] 8 | @per_user_query_params ["grant_options[]": "per-user"] 9 | 10 | @doc """ 11 | A helper function for making throttled GraphQL requests. 12 | 13 | ## Example: 14 | 15 | iex> query = "mutation metafieldDelete($input: MetafieldDeleteInput!){ metafieldDelete(input: $input) {deletedId userErrors {field message }}}", 16 | iex> estimated_cost = 10 17 | iex> variables = %{input: %{id: "gid://shopify/Metafield/9208558682200"}} 18 | iex> options = [debug: true] 19 | iex> ShopifyAPI.graphql_request(auth_token, query, estimated_cost, variables, options) 20 | {:ok, %ShopifyAPI.GraphQL.Response{...}} 21 | """ 22 | @spec graphql_request(ShopifyAPI.AuthToken.t(), String.t(), integer(), map(), list()) :: 23 | ShopifyAPI.GraphQL.query_response() 24 | def graphql_request(token, query, estimated_cost, variables \\ %{}, opts \\ []) do 25 | func = fn -> ShopifyAPI.GraphQL.query(token, query, variables, opts) end 26 | Throttled.graphql_request(func, token, estimated_cost) 27 | end 28 | 29 | def request(token, func), do: Throttled.request(func, token, RateLimiting.RESTTracker) 30 | 31 | @doc false 32 | # Accessor for API transport layer, defaults to `https://`. 33 | # Override in configuration to `http://` for testing using Bypass. 34 | @spec transport() :: String.t() 35 | def transport, do: Application.get_env(:shopify_api, :transport, "https") 36 | 37 | @spec port() :: integer() 38 | def port do 39 | case transport() do 40 | "https" -> 443 41 | _ -> 80 42 | end 43 | end 44 | 45 | @doc """ 46 | Generates the OAuth URL for fetching the App<>Shop token or the UserToken 47 | depending on if you enable user_user_tokens. 48 | """ 49 | @spec shopify_oauth_url(ShopifyAPI.App.t(), String.t(), list()) :: String.t() 50 | def shopify_oauth_url(app, domain, opts \\ []) 51 | when is_struct(app, ShopifyAPI.App) and is_binary(domain) and is_list(opts) do 52 | opts = Keyword.merge(@oauth_default_options, opts) 53 | user_token_query_params = opts |> Keyword.get(:use_user_tokens) |> per_user_query_params() 54 | query_params = oauth_query_params(app) ++ user_token_query_params 55 | 56 | domain 57 | |> ShopifyAPI.Shop.to_uri() 58 | # TODO use URI.append_path when we drop 1.14 support 59 | |> URI.merge(shopify_oauth_path()) 60 | |> URI.append_query(URI.encode_query(query_params)) 61 | |> URI.to_string() 62 | end 63 | 64 | @doc """ 65 | Helper function to get Shopify's Admin URI. 66 | """ 67 | @spec shopify_admin_uri() :: URI.t() 68 | def shopify_admin_uri, do: @shopify_admin_uri 69 | 70 | @spec shopify_oauth_path() :: String.t() 71 | def shopify_oauth_path, do: @shopify_oauth_path 72 | 73 | defp oauth_query_params(app) do 74 | [ 75 | client_id: app.client_id, 76 | scope: app.scope, 77 | redirect_uri: app.auth_redirect_uri, 78 | state: app.nonce 79 | ] 80 | end 81 | 82 | defp per_user_query_params(true), do: @per_user_query_params 83 | defp per_user_query_params(_use_user_tokens), do: [] 84 | end 85 | -------------------------------------------------------------------------------- /lib/shopify_api/app.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.App do 2 | @moduledoc """ 3 | ShopifyAPI.App contains logic and a struct for representing a Shopify App. 4 | """ 5 | @derive {Jason.Encoder, 6 | only: [:name, :client_id, :client_secret, :auth_redirect_uri, :nonce, :scope]} 7 | defstruct name: "", 8 | client_id: "", 9 | client_secret: "", 10 | auth_redirect_uri: "", 11 | nonce: "", 12 | scope: "" 13 | 14 | @typedoc """ 15 | Type that represents a Shopify App 16 | """ 17 | @type t :: %__MODULE__{ 18 | name: String.t(), 19 | client_id: String.t(), 20 | client_secret: String.t(), 21 | auth_redirect_uri: String.t(), 22 | nonce: String.t(), 23 | scope: String.t() 24 | } 25 | 26 | require Logger 27 | 28 | alias ShopifyAPI.AuthRequest 29 | alias ShopifyAPI.AuthToken 30 | alias ShopifyAPI.JSONSerializer 31 | alias ShopifyAPI.UserToken 32 | 33 | @doc """ 34 | After an App is installed and the Shop owner ends up back on ourside of the fence we 35 | need to request an AuthToken. This function uses ShopifyAPI.AuthRequest.post/3 to 36 | fetch and parse the AuthToken. 37 | """ 38 | @spec fetch_token(__MODULE__.t(), String.t(), String.t()) :: 39 | UserToken.ok_t() | AuthToken.ok_t() | {:error, String.t()} 40 | def fetch_token(app, domain, auth_code) when is_struct(app, __MODULE__) do 41 | case AuthRequest.post(app, domain, auth_code) do 42 | {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> 43 | Logger.info("#{__MODULE__} [#{domain}] fetched token") 44 | body |> JSONSerializer.decode!() |> create_token(app, domain, auth_code) 45 | 46 | {:ok, %HTTPoison.Response{} = response} -> 47 | Logger.warning("#{__MODULE__} fetching token code: #{response.status_code}") 48 | {:error, response.status_code} 49 | 50 | {:error, %HTTPoison.Error{reason: reason}} -> 51 | Logger.warning("#{__MODULE__} error fetching token: #{inspect(reason)}") 52 | {:error, reason} 53 | end 54 | end 55 | 56 | defp create_token(json, app, domain, auth_code) 57 | when is_map_key(json, "associated_user") and is_map_key(json, "access_token") do 58 | Logger.debug("online token") 59 | {:ok, UserToken.from_auth_request(app, domain, auth_code, json)} 60 | end 61 | 62 | defp create_token(%{"access_token" => token}, app, domain, auth_code) do 63 | Logger.debug("offline token") 64 | {:ok, AuthToken.new(app, domain, auth_code, token)} 65 | end 66 | 67 | defp create_token(_, _, _, _), do: {:error, "Unable to create token"} 68 | end 69 | -------------------------------------------------------------------------------- /lib/shopify_api/app_server.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.AppServer do 2 | @moduledoc """ 3 | Write-through cache for App structs. 4 | """ 5 | 6 | use GenServer 7 | 8 | alias ShopifyAPI.App 9 | alias ShopifyAPI.Config 10 | 11 | @table __MODULE__ 12 | 13 | def all, do: @table |> :ets.tab2list() |> Map.new() 14 | 15 | @spec count() :: integer() 16 | def count, do: :ets.info(@table, :size) 17 | 18 | @spec set(App.t()) :: :ok 19 | def set(%App{name: name} = app), do: set(name, app) 20 | 21 | @spec set(String.t(), App.t()) :: :ok 22 | def set(name, app) when is_binary(name) and is_struct(app, App) do 23 | :ets.insert(@table, {name, app}) 24 | do_persist(app) 25 | :ok 26 | end 27 | 28 | @spec get(String.t()) :: {:ok, App.t()} | :error 29 | def get(name) when is_binary(name) do 30 | case :ets.lookup(@table, name) do 31 | [{^name, app}] -> {:ok, app} 32 | [] -> :error 33 | end 34 | end 35 | 36 | def get_by_client_id(client_id) do 37 | case :ets.match_object(@table, {:_, %{client_id: client_id}}) do 38 | [{_, app}] -> {:ok, app} 39 | [] -> :error 40 | end 41 | end 42 | 43 | ## GenServer Callbacks 44 | 45 | def start_link(_opts), do: GenServer.start_link(__MODULE__, :ok, name: __MODULE__) 46 | 47 | @impl GenServer 48 | def init(:ok) do 49 | create_table!() 50 | for %App{} = app <- do_initialize(), do: set(app) 51 | {:ok, :no_state} 52 | end 53 | 54 | ## Private Helpers 55 | 56 | defp create_table! do 57 | :ets.new(@table, [ 58 | :set, 59 | :public, 60 | :named_table, 61 | read_concurrency: true 62 | ]) 63 | end 64 | 65 | # Calls a configured initializer to obtain a list of Apps. 66 | defp do_initialize do 67 | case Config.lookup(__MODULE__, :initializer) do 68 | {module, function, args} -> apply(module, function, args) 69 | {module, function} -> apply(module, function, []) 70 | _ -> [] 71 | end 72 | end 73 | 74 | # Attempts to persist a App if a persistence callback is configured 75 | defp do_persist(%App{name: name} = app) do 76 | case Config.lookup(__MODULE__, :persistence) do 77 | {module, function, args} -> apply(module, function, [name, app | args]) 78 | {module, function} -> apply(module, function, [name, app]) 79 | _ -> nil 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /lib/shopify_api/application.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.Application do 2 | use Application 3 | 4 | alias ShopifyAPI.RateLimiting 5 | 6 | # See https://hexdocs.pm/elixir/Application.html 7 | # for more information on OTP Applications 8 | def start(_type, _args) do 9 | RateLimiting.RESTTracker.init() 10 | RateLimiting.GraphQLTracker.init() 11 | 12 | # Define workers and child supervisors to be supervised 13 | children = [] 14 | 15 | # See https://hexdocs.pm/elixir/Supervisor.html 16 | # for other strategies and supported options 17 | opts = [strategy: :one_for_one, name: :shopify_api_supervisor] 18 | Supervisor.start_link(children, opts) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/shopify_api/associated_user.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.AssociatedUser do 2 | @derive {Jason.Encoder, 3 | only: [ 4 | :id, 5 | :first_name, 6 | :last_name, 7 | :email, 8 | :email_verified, 9 | :account_owner, 10 | :locale, 11 | :collaborator 12 | ]} 13 | defstruct id: 0, 14 | first_name: "", 15 | last_name: "", 16 | email: "", 17 | email_verified: false, 18 | account_owner: false, 19 | locale: "", 20 | collaborator: false 21 | 22 | @typedoc """ 23 | Type that represents a Shopify Associated User 24 | """ 25 | @type t :: %__MODULE__{ 26 | id: integer(), 27 | first_name: String.t(), 28 | last_name: String.t(), 29 | email: String.t(), 30 | email_verified: boolean(), 31 | account_owner: boolean(), 32 | locale: String.t(), 33 | collaborator: boolean() 34 | } 35 | 36 | @spec from_auth_request(map()) :: t() 37 | def from_auth_request(params) do 38 | %__MODULE__{ 39 | id: params["id"], 40 | first_name: params["first_name"], 41 | last_name: params["last_name"], 42 | email: params["email"], 43 | email_verified: params["email_verified"], 44 | account_owner: params["account_owner"], 45 | locale: params["locale"], 46 | collaborator: params["collaborator"] 47 | } 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/shopify_api/auth_request.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.AuthRequest do 2 | @moduledoc """ 3 | AuthRequest.post/3 contains logic to request AuthTokens from Shopify given an App, 4 | Shop domain, and the auth code from the App install. 5 | """ 6 | require Logger 7 | 8 | alias ShopifyAPI.App 9 | alias ShopifyAPI.AuthToken 10 | alias ShopifyAPI.AuthTokenServer 11 | alias ShopifyAPI.JSONSerializer 12 | alias ShopifyAPI.UserToken 13 | alias ShopifyAPI.UserTokenServer 14 | 15 | @headers [{"Content-Type", "application/json"}, {"Accept", "application/json"}] 16 | 17 | @spec post(ShopifyAPI.App.t(), String.t() | list(), String.t()) :: 18 | {:ok, any()} | {:error, any()} 19 | def post(app, myshopify_domain, auth_code) when is_struct(app, App) do 20 | http_body = %{ 21 | client_id: app.client_id, 22 | client_secret: app.client_secret, 23 | code: auth_code 24 | } 25 | 26 | access_token_url = myshopify_domain |> base_uri() |> URI.to_string() 27 | 28 | Logger.debug("#{__MODULE__} requesting token from #{access_token_url}") 29 | encoded_body = JSONSerializer.encode!(http_body) 30 | 31 | HTTPoison.post(access_token_url, encoded_body, @headers) 32 | end 33 | 34 | @spec base_uri(String.t()) :: URI.t() 35 | def base_uri(myshopify_domain) do 36 | myshopify_domain 37 | |> ShopifyAPI.Shop.to_uri() 38 | # TODO use URI.append_path when we drop 1.14 support 39 | |> URI.merge("/admin/oauth/access_token") 40 | end 41 | 42 | @doc """ 43 | Shopify docs: 44 | - https://shopify.dev/docs/apps/build/authentication-authorization/session-tokens/set-up-session-tokens 45 | - https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/token-exchange 46 | """ 47 | @spec request_offline_access_token(App.t(), String.t(), String.t()) :: 48 | {:ok, AuthToken.t()} | {:error, :failed_fetching_offline_token} 49 | def request_offline_access_token(app, myshopify_domain, session_token) do 50 | http_body = %{ 51 | client_id: app.client_id, 52 | client_secret: app.client_secret, 53 | grant_type: "urn:ietf:params:oauth:grant-type:token-exchange", 54 | subject_token: session_token, 55 | subject_token_type: "urn:ietf:params:oauth:token-type:id_token", 56 | requested_token_type: "urn:shopify:params:oauth:token-type:offline-access-token" 57 | } 58 | 59 | access_token_url = myshopify_domain |> base_uri() |> URI.to_string() 60 | encoded_body = JSONSerializer.encode!(http_body) 61 | 62 | case HTTPoison.post(access_token_url, encoded_body, @headers) do 63 | {:ok, %{status_code: 200, body: body}} -> 64 | json = JSONSerializer.decode!(body) 65 | token = AuthToken.from_auth_request(app, myshopify_domain, json) 66 | AuthTokenServer.set(token) 67 | {:ok, token} 68 | 69 | err -> 70 | Logger.error("error creating token #{inspect(err)}") 71 | {:error, :failed_fetching_offline_token} 72 | end 73 | end 74 | 75 | @doc """ 76 | Shopify docs: 77 | - https://shopify.dev/docs/apps/build/authentication-authorization/session-tokens/set-up-session-tokens 78 | - https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/token-exchange 79 | """ 80 | @spec request_online_access_token(App.t(), String.t(), String.t()) :: 81 | {:ok, UserToken.t()} | {:error, :failed_fetching_online_token} 82 | def request_online_access_token(app, myshopify_domain, session_token) do 83 | http_body = %{ 84 | client_id: app.client_id, 85 | client_secret: app.client_secret, 86 | grant_type: "urn:ietf:params:oauth:grant-type:token-exchange", 87 | subject_token: session_token, 88 | subject_token_type: "urn:ietf:params:oauth:token-type:id_token", 89 | requested_token_type: "urn:shopify:params:oauth:token-type:online-access-token" 90 | } 91 | 92 | access_token_url = myshopify_domain |> base_uri() |> URI.to_string() 93 | encoded_body = JSONSerializer.encode!(http_body) 94 | 95 | case HTTPoison.post(access_token_url, encoded_body, @headers) do 96 | {:ok, %{status_code: 200, body: body}} -> 97 | json = JSONSerializer.decode!(body) 98 | user_token = UserToken.from_auth_request(app, myshopify_domain, json) 99 | UserTokenServer.set(user_token) 100 | {:ok, user_token} 101 | 102 | err -> 103 | Logger.error("error creating token #{inspect(err)}") 104 | {:error, :failed_fetching_online_token} 105 | end 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /lib/shopify_api/auth_token.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.AuthToken do 2 | @derive {Jason.Encoder, only: [:code, :app_name, :shop_name, :token, :timestamp, :plus]} 3 | defstruct code: "", 4 | app_name: "", 5 | shop_name: "", 6 | token: "", 7 | timestamp: 0, 8 | plus: false 9 | 10 | @typedoc """ 11 | Type that represents a Shopify Auth Token with 12 | 13 | - app_name corresponding to %ShopifyAPI.App{name: app_name} 14 | - shop_name corresponding to %ShopifyAPI.Shop{domain: shop_name} 15 | """ 16 | @type t :: %__MODULE__{ 17 | code: String.t(), 18 | app_name: String.t(), 19 | shop_name: String.t(), 20 | token: String.t(), 21 | timestamp: 0, 22 | plus: boolean() 23 | } 24 | @type ok_t :: {:ok, t()} 25 | 26 | alias ShopifyAPI.App 27 | 28 | @spec create_key(t()) :: String.t() 29 | def create_key(%__MODULE__{shop_name: shop, app_name: app}), do: create_key(shop, app) 30 | 31 | @spec create_key(String.t(), String.t()) :: String.t() 32 | def create_key(shop, app), do: "#{shop}:#{app}" 33 | 34 | @spec new(App.t(), String.t(), String.t(), String.t()) :: t() 35 | def new(app, myshopify_domain, auth_code, token) do 36 | %__MODULE__{ 37 | app_name: app.name, 38 | shop_name: myshopify_domain, 39 | code: auth_code, 40 | token: token 41 | } 42 | end 43 | 44 | @spec from_auth_request(App.t(), String.t(), String.t(), map()) :: t() 45 | def from_auth_request(app, myshopify_domain, code \\ "", attrs) when is_struct(app, App) do 46 | new(app, myshopify_domain, code, attrs["access_token"]) 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/shopify_api/auth_token_server.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.AuthTokenServer do 2 | @moduledoc """ 3 | Write-through cache for AuthToken structs. 4 | """ 5 | 6 | use GenServer 7 | 8 | alias ShopifyAPI.AuthToken 9 | alias ShopifyAPI.Config 10 | 11 | @table __MODULE__ 12 | 13 | def all, do: @table |> :ets.tab2list() |> Map.new() 14 | 15 | @spec count() :: integer() 16 | def count, do: :ets.info(@table, :size) 17 | 18 | @spec set(AuthToken.t()) :: :ok 19 | @spec set(AuthToken.t(), boolean()) :: :ok 20 | def set(token, should_persist \\ true) when is_struct(token, AuthToken) do 21 | :ets.insert(@table, {{token.shop_name, token.app_name}, token}) 22 | if should_persist, do: do_persist(token) 23 | :ok 24 | end 25 | 26 | @spec get(String.t(), String.t()) :: {:ok, AuthToken.t()} | {:error, String.t()} 27 | def get(shop, app) when is_binary(shop) and is_binary(app) do 28 | case :ets.lookup(@table, {shop, app}) do 29 | [{_key, token}] -> {:ok, token} 30 | [] -> {:error, "Auth token for #{shop}:#{app} could not be found."} 31 | end 32 | end 33 | 34 | def get_for_shop(shop) when is_binary(shop) do 35 | match_spec = [{{{shop, :_}, :"$1"}, [], [:"$1"]}] 36 | :ets.select(@table, match_spec) 37 | end 38 | 39 | def get_for_app(app) when is_binary(app) do 40 | match_spec = [{{{:_, app}, :"$1"}, [], [:"$1"]}] 41 | :ets.select(@table, match_spec) 42 | end 43 | 44 | @spec delete(String.t(), String.t()) :: :ok 45 | def delete(shop_name, app) do 46 | :ets.delete(@table, {shop_name, app}) 47 | :ok 48 | end 49 | 50 | def drop_all, do: :ets.delete_all_objects(@table) 51 | 52 | ## GenServer Callbacks 53 | 54 | def start_link(_opts), do: GenServer.start_link(__MODULE__, :ok, name: __MODULE__) 55 | 56 | @impl GenServer 57 | def init(:ok) do 58 | create_table!() 59 | for %AuthToken{} = shop <- do_initialize(), do: set(shop, false) 60 | {:ok, :no_state} 61 | end 62 | 63 | ## Private Helpers 64 | 65 | defp create_table! do 66 | :ets.new(@table, [ 67 | :set, 68 | :public, 69 | :named_table, 70 | read_concurrency: true 71 | ]) 72 | end 73 | 74 | # Calls a configured initializer to obtain a list of AuthTokens. 75 | defp do_initialize do 76 | case Config.lookup(__MODULE__, :initializer) do 77 | {module, function, args} -> apply(module, function, args) 78 | {module, function} -> apply(module, function, []) 79 | _ -> [] 80 | end 81 | end 82 | 83 | # Attempts to persist an AuthToken if a persistence callback is configured 84 | defp do_persist(token) when is_struct(token, AuthToken) do 85 | key = AuthToken.create_key(token) 86 | 87 | case Config.lookup(__MODULE__, :persistence) do 88 | {module, function, args} -> apply(module, function, [key, token | args]) 89 | {module, function} -> apply(module, function, [key, token]) 90 | _ -> nil 91 | end 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /lib/shopify_api/bulk.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.Bulk do 2 | alias ShopifyAPI.AuthToken 3 | alias ShopifyAPI.Bulk.Query 4 | 5 | defmodule QueryError do 6 | defexception message: "Error in Bulk query" 7 | end 8 | 9 | defmodule TimeoutError do 10 | defexception message: "Bulk operation timed out" 11 | end 12 | 13 | defmodule InProgressError do 14 | defexception message: "Bulk operation already in progress" 15 | end 16 | 17 | @defaults [polling_rate: 100, max_poll_count: 100, auto_cancel: true] 18 | 19 | @doc """ 20 | ## Options 21 | `:polling_rate` milliseconds between checks, defaults to 100 22 | `:max_poll_count` maximum times to check for bulk query completion, defaults to 100 23 | `:auto_cancel` `true` or `false` should try to cancel bulk query after 24 | timeout, defaults to true 25 | 26 | ## Example 27 | iex> prod_id = 10 28 | iex> query = \""" 29 | { 30 | product(id: "gid://shopify/Product/\#{prod_id}") { 31 | collections(first: 1) { 32 | edges { 33 | node { 34 | collection_id: id 35 | } 36 | } 37 | } 38 | metafields(first: 1) { 39 | edges { 40 | node { 41 | key 42 | value 43 | metafield_id: id 44 | } 45 | } 46 | } 47 | } 48 | } 49 | \""" 50 | iex> {:ok, token} = YourShopifyApp.ShopifyAPI.Shop.get_auth_token_from_slug("slug") 51 | iex> ShopifyAPI.Bulk.process!(token, query) 52 | [%{"collection_id" => "gid://shopify/Collection/xxx", ...}] 53 | """ 54 | @spec process!(AuthToken.t(), String.t(), list() | integer()) :: list() 55 | def process!(token, query, polling_rate \\ 100) 56 | 57 | def process!(%AuthToken{} = token, query, polling_rate) when is_integer(polling_rate), 58 | do: process!(token, query, polling_rate: polling_rate) 59 | 60 | def process!(%AuthToken{} = token, query, opts) do 61 | token 62 | |> Query.exec!(query, resolve_options(opts)) 63 | |> Query.fetch(token) 64 | |> Query.parse_response!() 65 | end 66 | 67 | @doc """ 68 | Like process/3 but returns a Streamable collection of decoded JSON. 69 | 70 | ## Options 71 | `:polling_rate` milliseconds between checks, defaults to 100 72 | `:max_poll_count` maximum times to check for bulk query completion, defaults to 100 73 | `:auto_cancel` `true` or `false` should try to cancel bulk query after 74 | timeout, defaults to true 75 | 76 | ## Example 77 | iex> prod_id = 10 78 | iex> query = \""" 79 | { 80 | products { 81 | edges { 82 | node { 83 | id 84 | } 85 | } 86 | } 87 | } 88 | \""" 89 | iex> {:ok, token} = YourShopifyApp.ShopifyAPI.Shop.get_auth_token_from_slug("slug") 90 | iex> token |> ShopifyAPI.Bulk.process_stream(query) |> Enum.to_list() 91 | [ 92 | %{"id" => "gid://shopify/Product/1"}, 93 | %{"id" => "gid://shopify/Product/2"}, 94 | %{"id" => "gid://shopify/Product/3"} 95 | ] 96 | """ 97 | @spec process_stream!(AuthToken.t(), String.t(), list() | integer()) :: Enumerable.t() 98 | def process_stream!(token, query, polling_rate \\ 100) 99 | 100 | def process_stream!(%AuthToken{} = token, query, polling_rate) 101 | when is_integer(polling_rate), 102 | do: process_stream!(token, query, polling_rate: polling_rate) 103 | 104 | def process_stream!(%AuthToken{} = token, query, opts) do 105 | token 106 | |> Query.exec!(query, resolve_options(opts)) 107 | |> Query.stream_fetch!(token) 108 | |> decode_json!() 109 | end 110 | 111 | @spec process_stream_from_id!(AuthToken.t(), String.t()) :: Enumerable.t() 112 | def process_stream_from_id!(token, bulk_op_id) do 113 | token 114 | |> Query.fetch_url!(bulk_op_id) 115 | |> Query.stream_fetch!(token) 116 | |> decode_json!() 117 | end 118 | 119 | defp resolve_options(opts), do: Keyword.merge(@defaults, opts, fn _k, _dv, nv -> nv end) 120 | 121 | defp decode_json!(stream), do: Stream.map(stream, &ShopifyAPI.JSONSerializer.decode!/1) 122 | end 123 | -------------------------------------------------------------------------------- /lib/shopify_api/bulk/cancel.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.Bulk.Cancel do 2 | require Logger 3 | 4 | alias ShopifyAPI.AuthToken 5 | alias ShopifyAPI.Bulk.Query 6 | 7 | @polling_timeout_message "BulkFetch timed out before completion" 8 | @auto_cancel_sleep_duration 1_000 9 | 10 | @spec perform(boolean(), AuthToken.t(), String.t()) :: {:ok | :error, any()} 11 | def perform(false, _, _), do: {:error, @polling_timeout_message} 12 | 13 | def perform(true, token, bid) do 14 | token 15 | |> Query.cancel(bid) 16 | |> poll(token, bid) 17 | |> case do 18 | {:ok, _} = value -> value 19 | _ -> {:error, @polling_timeout_message} 20 | end 21 | end 22 | 23 | defp poll(resp, token, bid, max_poll \\ 500, depth \\ 0) 24 | 25 | # response from cancel/1 26 | defp poll({:ok, %{"bulkOperation" => %{"status" => "CANCELED"}}}, _token, _bid, _, _), do: true 27 | 28 | defp poll({:ok, %{"bulkOperation" => %{"status" => "COMPLETED"}}}, token, _bid, _, _) do 29 | case Query.status(token) do 30 | {:ok, %{"status" => "COMPLETED", "url" => url}} -> 31 | {:ok, url} 32 | 33 | _ -> 34 | {:error, "#{__MODULE__} got an error while fetching status after getting COMPLETED"} 35 | end 36 | end 37 | 38 | defp poll(_, token, _bid, max_poll, depth) when max_poll == depth do 39 | Logger.warning("#{__MODULE__} Cancel polling timed out for #{token.shop_name}") 40 | {:error, :cancelation_timedout} 41 | end 42 | 43 | defp poll(_, token, bid, max_poll, depth) do 44 | Process.sleep(@auto_cancel_sleep_duration) 45 | 46 | token 47 | |> Query.cancel(bid) 48 | |> poll(token, bid, max_poll, depth + 1) 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/shopify_api/bulk/telemetry.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.Bulk.Telemetry do 2 | @moduledoc """ 3 | Helper module handle instrumentation with telemetry 4 | """ 5 | 6 | def send(module_name, token, data, bulk_id \\ nil) 7 | 8 | def send( 9 | module_name, 10 | %{app_name: app, shop_name: shop} = _token, 11 | {:error, type, reason}, 12 | bulk_id 13 | ) do 14 | metadata = %{ 15 | app: app, 16 | shop: shop, 17 | module: module_name, 18 | bulk_id: bulk_id, 19 | type: type, 20 | reason: reason 21 | } 22 | 23 | telemetry_execute(:failure, metadata) 24 | end 25 | 26 | def send( 27 | module_name, 28 | %{app_name: app, shop_name: shop} = _token, 29 | {:success, type}, 30 | _bulk_id 31 | ) do 32 | metadata = %{ 33 | app: app, 34 | shop: shop, 35 | module: module_name, 36 | type: type 37 | } 38 | 39 | telemetry_execute(:success, metadata) 40 | end 41 | 42 | defp telemetry_execute(event_status, metadata) do 43 | :telemetry.execute( 44 | [:shopify_api, :bulk_operation, event_status], 45 | %{count: 1}, 46 | metadata 47 | ) 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/shopify_api/config.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.Config do 2 | @moduledoc false 3 | 4 | def lookup(key), do: Application.get_env(:shopify_api, key) 5 | def lookup(key, subkey), do: Application.get_env(:shopify_api, key)[subkey] 6 | end 7 | -------------------------------------------------------------------------------- /lib/shopify_api/exception.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.ShopUnavailableError do 2 | defexception message: "Shop Unavailable" 3 | end 4 | 5 | defmodule ShopifyAPI.ShopNotFoundError do 6 | defexception message: "Shop Not Found" 7 | end 8 | 9 | defmodule ShopifyAPI.ShopAuthError do 10 | defexception message: "Invalid API key" 11 | end 12 | -------------------------------------------------------------------------------- /lib/shopify_api/graphql.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.GraphQL do 2 | @moduledoc """ 3 | Interface to Shopify's GraphQL Admin API. 4 | """ 5 | 6 | require Logger 7 | 8 | alias ShopifyAPI.AuthToken 9 | alias ShopifyAPI.GraphQL.{JSONParseError, Response, Telemetry} 10 | alias ShopifyAPI.JSONSerializer 11 | 12 | @default_graphql_version "2020-10" 13 | 14 | @log_module __MODULE__ |> to_string() |> String.trim_leading("Elixir.") 15 | 16 | @type query_response :: 17 | {:ok, Response.t()} 18 | | {:error, JSONParseError.t() | HTTPoison.Response.t() | HTTPoison.Error.t()} 19 | 20 | @doc """ 21 | Makes requests against Shopify GraphQL and returns a tuple containing 22 | a %Response struct with %{response, metadata, status_code} 23 | 24 | ## Example 25 | 26 | iex> query = "mutation metafieldDelete($input: MetafieldDeleteInput!){ metafieldDelete(input: $input) {deletedId userErrors {field message }}}", 27 | iex> variables = %{input: %{id: "gid://shopify/Metafield/9208558682200"}} 28 | iex> ShopifyAPI.GraphQL.query(auth, query, variables) 29 | {:ok, %Response{...}} 30 | """ 31 | @spec query(AuthToken.t(), String.t(), map(), list()) :: query_response() 32 | def query(%AuthToken{} = auth, query_string, variables \\ %{}, opts \\ []) do 33 | url = build_url(auth, opts) 34 | headers = build_headers(auth, opts) 35 | 36 | body = 37 | query_string 38 | |> build_body() 39 | |> insert_variables(variables) 40 | |> JSONSerializer.encode!() 41 | 42 | logged_request(auth, url, body, headers, opts) 43 | end 44 | 45 | defp build_body(query_string), do: %{query: query_string} 46 | 47 | defp insert_variables(body, variables) do 48 | Map.put(body, :variables, variables) 49 | end 50 | 51 | def configured_version do 52 | config = Application.get_env(:shopify_api, ShopifyAPI.GraphQL, []) 53 | Keyword.get(config, :graphql_version, @default_graphql_version) 54 | end 55 | 56 | @doc """ 57 | Returns rate limit info back from succesful responses. 58 | """ 59 | def rate_limit_details({:ok, %Response{metadata: metadata}}) do 60 | actual_cost = 61 | metadata 62 | |> get_in(["cost", "actualQueryCost"]) 63 | |> Kernel.trunc() 64 | 65 | currently_available = 66 | metadata 67 | |> get_in(["cost", "throttleStatus", "currentlyAvailable"]) 68 | |> Kernel.trunc() 69 | 70 | maximum_available = 71 | metadata 72 | |> get_in(["cost", "throttleStatus", "maximumAvailable"]) 73 | |> Kernel.trunc() 74 | 75 | rate_limit(actual_cost, currently_available, maximum_available) 76 | end 77 | 78 | def rate_limit_details(_) do 79 | rate_limit(nil, nil, nil) 80 | end 81 | 82 | defp rate_limit(actual_cost, currently_available, maximum_available) do 83 | %{ 84 | actual_cost: actual_cost, 85 | currently_available: currently_available, 86 | maximum_available: maximum_available 87 | } 88 | end 89 | 90 | defp logged_request(auth, url, body, headers, options) do 91 | {time, raw_response} = :timer.tc(HTTPoison, :post, [url, body, headers, options]) 92 | 93 | response = Response.handle(raw_response) 94 | 95 | log_request(auth, response, time) 96 | 97 | Telemetry.send(@log_module, auth, url, time, response) 98 | 99 | response 100 | end 101 | 102 | defp log_request(%{app_name: app, shop_name: shop} = _token, response, time) do 103 | Logger.debug(fn -> 104 | status = 105 | case response do 106 | {:ok, %{status_code: status}} -> status 107 | {:error, reason} -> "error[#{inspect(reason)}]" 108 | end 109 | 110 | case rate_limit_details(response) do 111 | %{actual_cost: nil, currently_available: nil, maximum_available: nil} -> 112 | "#{@log_module} for #{shop}:#{app} received #{status} in #{div(time, 1_000)}ms" 113 | 114 | %{ 115 | actual_cost: actual_cost, 116 | currently_available: currently_available, 117 | maximum_available: maximum_available 118 | } -> 119 | "#{@log_module} for #{shop}:#{app} received #{status} in #{div(time, 1_000)}ms [cost #{actual_cost} bucket #{currently_available}/#{maximum_available}]" 120 | end 121 | end) 122 | end 123 | 124 | defp build_url(%{shop_name: domain}, opts) do 125 | version = Keyword.get(opts, :version, configured_version()) 126 | "#{ShopifyAPI.transport()}://#{domain}/admin/api/#{version}/graphql.json" 127 | end 128 | 129 | defp build_headers(%{token: access_token}, opts) do 130 | headers = [ 131 | {"Content-Type", "application/json"}, 132 | {"X-Shopify-Access-Token", access_token} 133 | ] 134 | 135 | if Keyword.get(opts, :debug, false) do 136 | [{"X-GraphQL-Cost-Include-Fields", "true"} | headers] 137 | else 138 | headers 139 | end 140 | end 141 | end 142 | -------------------------------------------------------------------------------- /lib/shopify_api/graphql/parse_error.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.GraphQL.JSONParseError do 2 | @moduledoc """ 3 | Struct representation of a JSON parse error. 4 | """ 5 | 6 | alias HTTPoison.Response 7 | 8 | @type t :: %ShopifyAPI.GraphQL.JSONParseError{ 9 | error: map(), 10 | response: Response.t() 11 | } 12 | 13 | defstruct response: %Response{}, 14 | error: nil 15 | end 16 | -------------------------------------------------------------------------------- /lib/shopify_api/graphql/response.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.GraphQL.Response do 2 | @moduledoc """ 3 | The Response module handles parsing and unwrapping responses from Shopify's GraphQL Admin API. 4 | """ 5 | 6 | alias ShopifyAPI.JSONSerializer 7 | alias ShopifyAPI.GraphQL.{JSONParseError, Response} 8 | 9 | @type t :: %ShopifyAPI.GraphQL.Response{ 10 | response: map(), 11 | metadata: map(), 12 | status_code: integer(), 13 | headers: list() 14 | } 15 | 16 | defstruct response: %{}, 17 | metadata: %{}, 18 | status_code: nil, 19 | headers: [] 20 | 21 | @doc """ 22 | Parses a `%HTTPoison.Response{}` GraphQL response. 23 | 24 | Returns `{:ok, %Response{}}` if the API response was successful. 25 | If there were query errors, or a rate limit was exceeded, `{:error, %HTTPoison.Response{}}` is returned. 26 | If an error occurs while parsing the JSON response, `{:error, %JSONParseError{}}` is returned. 27 | If a request error occurs, `{:error, %HTTPoison.Error()}` is returned. 28 | """ 29 | @spec handle({:ok, HTTPoison.Response.t()} | {:error, HTTPoison.Error.t()}) :: 30 | {:ok, t()} | {:error, HTTPoison.Response.t() | JSONParseError.t() | HTTPoison.Error.t()} 31 | def handle({:ok, response}) do 32 | case JSONSerializer.decode(response.body) do 33 | {:ok, body} -> build_response(%{response | body: body}) 34 | {:error, error} -> handle_unparsable(response, error) 35 | end 36 | end 37 | 38 | def handle({:error, _} = response), do: response 39 | 40 | @doc false 41 | def build_response(%{body: %{"data" => nil}} = response), do: {:error, response} 42 | 43 | def build_response(%{body: %{"data" => data, "extensions" => extensions}} = response) do 44 | { 45 | :ok, 46 | %Response{ 47 | status_code: response.status_code, 48 | response: data, 49 | metadata: extensions, 50 | headers: response.headers 51 | } 52 | } 53 | end 54 | 55 | def build_response(response), do: {:error, response} 56 | 57 | def handle_unparsable(response, error) do 58 | { 59 | :error, 60 | %JSONParseError{ 61 | response: response, 62 | error: error 63 | } 64 | } 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /lib/shopify_api/graphql/telemetry.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.GraphQL.Telemetry do 2 | @moduledoc """ 3 | Helper module handle instrumentation with telemetry 4 | """ 5 | alias HTTPoison.Error 6 | alias ShopifyAPI.GraphQL.Response 7 | 8 | def send( 9 | module_name, 10 | %{app_name: app, shop_name: shop} = _token, 11 | time, 12 | {:ok, %Response{response: response}} = _response 13 | ) do 14 | metadata = %{ 15 | app: app, 16 | shop: shop, 17 | module: module_name, 18 | response: response 19 | } 20 | 21 | telemetry_execute(:success, time, metadata) 22 | end 23 | 24 | def send( 25 | module_name, 26 | %{app_name: app, shop_name: shop} = _token, 27 | time, 28 | response 29 | ) do 30 | reason = 31 | case response do 32 | {:error, %Response{response: reason}} -> reason 33 | {:error, %Error{reason: reason}} -> reason 34 | end 35 | 36 | metadata = %{ 37 | app: app, 38 | shop: shop, 39 | module: module_name, 40 | reason: reason 41 | } 42 | 43 | telemetry_execute(:failure, time, metadata) 44 | end 45 | 46 | def send(_token, _method, _url, _time, _response), do: nil 47 | 48 | defp telemetry_execute(event_status, time, metadata) do 49 | :telemetry.execute( 50 | [:shopify_api, :graphql_request, event_status], 51 | %{request_time: time}, 52 | metadata 53 | ) 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/shopify_api/json_serializer.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.JSONSerializer do 2 | @moduledoc """ 3 | Abstraction point allowing for use of a custom JSON serializer, if your app requires it. 4 | By default `shopify_api` uses the popular `jason`, you can override this in your config: 5 | 6 | # use Poison to encode/decode JSON 7 | config :shopify_api, :json_library, Poison 8 | 9 | After doing so, you must make sure to re-compile the `shopify_api` dependency: 10 | 11 | $ mix deps.compile --force shopify_api 12 | """ 13 | 14 | @codec Application.compile_env(:shopify_api, :json_library, Jason) 15 | 16 | defdelegate encode(json_str), to: @codec 17 | defdelegate decode(json_str), to: @codec 18 | 19 | defdelegate encode!(json_str), to: @codec 20 | defdelegate decode!(json_str), to: @codec 21 | end 22 | -------------------------------------------------------------------------------- /lib/shopify_api/jwt_session_token.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.JWTSessionToken do 2 | @doc """ 3 | Handles validation, data fetching, and exchange for Shopify Session Tokens. 4 | 5 | [Shopify documentation](https://shopify.dev/docs/apps/build/authentication-authorization/session-tokens/set-up-session-tokens) 6 | """ 7 | require Logger 8 | 9 | @spec verify(String.t(), String.t()) :: 10 | {valid? :: boolean(), jwt :: JOSE.JWT.t(), jws :: JOSE.JWS.t()} 11 | def verify(token, client_secret) do 12 | jwk = JOSE.JWK.from_oct(client_secret) 13 | JOSE.JWT.verify_strict(jwk, ["HS256"], token) 14 | end 15 | 16 | @spec app(JOSE.JWT.t() | String.t()) :: {:ok, ShopifyAPI.App.t()} | {:error, any()} 17 | def app(%JOSE.JWT{fields: %{"aud" => client_id}}) do 18 | case ShopifyAPI.AppServer.get_by_client_id(client_id) do 19 | {:ok, _} = resp -> resp 20 | _ -> {:error, "Audience claim is not a valid App clientId."} 21 | end 22 | end 23 | 24 | def app(token) when is_binary(token), do: token |> JOSE.JWT.peek_payload() |> app() 25 | 26 | @spec myshopify_domain(JOSE.JWT.t()) :: {:ok, String.t()} | {:error, any()} 27 | def myshopify_domain(%JOSE.JWT{fields: %{"dest" => shop_url}}) do 28 | shop_url 29 | |> URI.parse() 30 | |> Map.get(:host) 31 | |> case do 32 | shop_name when is_binary(shop_name) -> {:ok, shop_name} 33 | _ -> {:error, "Shop name not found"} 34 | end 35 | end 36 | 37 | def myshopify_domain(_), do: {:error, "Invalid user token or shop name not found"} 38 | 39 | @spec user_id(JOSE.JWT.t()) :: {:ok, integer()} | {:error, any()} 40 | def user_id(%JOSE.JWT{fields: %{"sub" => user_id}}), 41 | do: {:ok, String.to_integer(user_id)} 42 | 43 | def user_id(_), 44 | do: {:error, "Invalid user token or no id"} 45 | 46 | @spec get_offline_token(JOSE.JWT.t(), String.t()) :: 47 | {:ok, ShopifyAPI.AuthToken.t()} 48 | | {:error, :invalid_session_token} 49 | | {:error, :failed_fetching_online_token} 50 | def get_offline_token(%JOSE.JWT{} = jwt, token) do 51 | with {:ok, myshopify_domain} <- myshopify_domain(jwt), 52 | {:ok, app} <- app(jwt) do 53 | case ShopifyAPI.AuthTokenServer.get(myshopify_domain, app.name) do 54 | {:ok, _} = resp -> 55 | resp 56 | 57 | {:error, _} -> 58 | Logger.warning("No token found, exchanging for new") 59 | 60 | case ShopifyAPI.AuthRequest.request_offline_access_token(app, myshopify_domain, token) do 61 | {:ok, token} -> 62 | fire_post_login_hook(token) 63 | {:ok, token} 64 | 65 | error -> 66 | error 67 | end 68 | end 69 | else 70 | error -> 71 | Logger.warning("failed getting required informatio from the JWT #{inspect(error)}") 72 | {:error, :invalid_session_token} 73 | end 74 | end 75 | 76 | @spec get_user_token(JOSE.JWT.t(), String.t()) :: 77 | {:ok, ShopifyAPI.UserToken.t()} 78 | | {:error, :invalid_session_token} 79 | | {:error, :failed_fetching_online_token} 80 | def get_user_token(%JOSE.JWT{} = jwt, token) do 81 | with {:ok, myshopify_domain} <- myshopify_domain(jwt), 82 | {:ok, app} <- app(jwt), 83 | {:ok, user_id} <- user_id(jwt) do 84 | case ShopifyAPI.UserTokenServer.get_valid(myshopify_domain, app.name, user_id) do 85 | {:ok, _} = resp -> 86 | resp 87 | 88 | {:error, :invalid_user_token} -> 89 | Logger.debug("Expired or no user token found, exchanging for new") 90 | 91 | case ShopifyAPI.AuthRequest.request_online_access_token(app, myshopify_domain, token) do 92 | {:ok, user_token} -> 93 | fire_post_login_hook(user_token) 94 | {:ok, user_token} 95 | 96 | error -> 97 | error 98 | end 99 | end 100 | else 101 | error -> 102 | Logger.warning("failed getting required informatio from the JWT #{inspect(error)}") 103 | {:error, :invalid_session_token} 104 | end 105 | end 106 | 107 | defp fire_post_login_hook(user_token), 108 | do: Task.async(fn -> ShopifyAPI.Shop.post_login(user_token) end) 109 | end 110 | -------------------------------------------------------------------------------- /lib/shopify_api/plugs/admin_authenticator.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.Plugs.AdminAuthenticator do 2 | @moduledoc """ 3 | The ShopifyAPI.Plugs.AdminAuthenticator plug allows for easy admin authentication. The plug 4 | when included in your route will verify Shopify signatures, that are added to the iframe call 5 | on admin page load, and set a session cookie for the duration of the session. 6 | 7 | NOTE: This plug only does authentication for the initial iframe load, it will check for the 8 | presents of the hmac and do the validation, if no hmac is present it will just continue. 9 | 10 | The plug will assign the Shop, App and AuthToken to the Conn for easy access in your 11 | admin controller when the a valid hmac is provided. 12 | 13 | When no HMAC is provided, the plug passes through without assigning the Shop, App and AuthToken. 14 | Shopify expects this behaviour and has started rejecting new apps that do not behave this way. 15 | 16 | Make sure to include the App name in the path, in our example it is included directly in the 17 | path `"/shop-admin/:app"`. Or include the :app_name in the mount parameters. 18 | 19 | ## Example Usage 20 | ```elixir 21 | # Router 22 | pipeline :shop_admin do 23 | plug ShopifyAPI.Plugs.AdminAuthenticator 24 | end 25 | 26 | scope "/shop-admin/:app", YourAppWeb do 27 | pipe_through :browser 28 | pipe_through :shop_admin 29 | get "/", SomeAdminController, :index 30 | end 31 | ``` 32 | """ 33 | require Logger 34 | 35 | alias Plug.Conn 36 | 37 | alias ShopifyAPI.JWTSessionToken 38 | 39 | @defaults [shopify_mount_path: "/shop"] 40 | 41 | def init(opts), do: Keyword.merge(opts, @defaults) 42 | 43 | def call(conn, options) do 44 | if should_do_authentication?(conn) do 45 | do_authentication(conn, options) 46 | else 47 | conn 48 | end 49 | end 50 | 51 | defp do_authentication(conn, _options) do 52 | token = conn.params["id_token"] 53 | 54 | with {:ok, app} <- JWTSessionToken.app(token), 55 | {true, jwt, _jws} <- JWTSessionToken.verify(token, app.client_secret), 56 | :ok <- validate_hmac(app, conn.query_params), 57 | {:ok, myshopify_domain} <- JWTSessionToken.myshopify_domain(jwt), 58 | {:ok, shop} <- ShopifyAPI.ShopServer.get_or_create(myshopify_domain, true), 59 | {:ok, auth_token} <- JWTSessionToken.get_offline_token(jwt, token), 60 | {:ok, user_token} <- JWTSessionToken.get_user_token(jwt, token) do 61 | conn 62 | |> assign_app(app) 63 | |> assign_shop(shop) 64 | |> assign_auth_token(auth_token) 65 | |> assign_user_token(user_token) 66 | else 67 | {:error, :invalid_hmac} -> 68 | Logger.info("#{__MODULE__} failed hmac validation") 69 | 70 | conn 71 | |> Conn.resp(401, "Not Authorized.") 72 | |> Conn.halt() 73 | 74 | _ -> 75 | conn 76 | end 77 | end 78 | 79 | defp should_do_authentication?(conn), do: has_hmac(conn.query_params) == :ok 80 | 81 | defp assign_app(conn, app), do: Conn.assign(conn, :app, app) 82 | defp assign_shop(conn, shop), do: Conn.assign(conn, :shop, shop) 83 | defp assign_auth_token(conn, auth_token), do: Conn.assign(conn, :auth_token, auth_token) 84 | defp assign_user_token(conn, user_token), do: Conn.assign(conn, :user_token, user_token) 85 | 86 | defp has_hmac(%{"hmac" => hmac}) when is_binary(hmac), do: :ok 87 | defp has_hmac(_params), do: {:error, :no_hmac} 88 | 89 | defp validate_hmac(%ShopifyAPI.App{client_secret: secret}, params) do 90 | request_hmac = params["hmac"] 91 | 92 | params 93 | |> Enum.reject(fn {key, _} -> key == "hmac" or key == "signature" end) 94 | |> Enum.sort_by(&elem(&1, 0)) 95 | |> Enum.map_join("&", fn {key, value} -> key <> "=" <> value end) 96 | |> ShopifyAPI.Security.base16_sha256_hmac(secret) 97 | |> then(fn 98 | ^request_hmac -> :ok 99 | _ -> {:error, :invalid_hmac} 100 | end) 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /lib/shopify_api/plugs/auth_shop_session_token.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.Plugs.AuthShopSessionToken do 2 | @moduledoc """ 3 | A Plug to handle authenticating the Shop Admin JWT from Shopify. 4 | 5 | ## Example Installations 6 | 7 | Add this plug in a pipeline for your Shop Admin API. 8 | 9 | ```elixir 10 | pipeline :shop_admin_api do 11 | plug :accepts, ["json"] 12 | plug ShopifyAPI.Plugs.AuthShopSessionToken 13 | end 14 | ``` 15 | """ 16 | 17 | import Plug.Conn 18 | 19 | require Logger 20 | 21 | alias ShopifyAPI.AuthTokenServer 22 | alias ShopifyAPI.JWTSessionToken 23 | alias ShopifyAPI.ShopServer 24 | 25 | def init(opts), do: opts 26 | 27 | def call(conn, _options) do 28 | with ["Bearer " <> token] <- get_req_header(conn, "authorization"), 29 | {:ok, app} <- JWTSessionToken.app(token), 30 | {true, jwt, _jws} <- JWTSessionToken.verify(token, app.client_secret), 31 | {:ok, myshopify_domain} <- JWTSessionToken.myshopify_domain(jwt), 32 | {:ok, user_id} <- JWTSessionToken.user_id(jwt), 33 | {:ok, shop} <- ShopServer.get(myshopify_domain), 34 | {:ok, auth_token} <- AuthTokenServer.get(myshopify_domain, app.name), 35 | {:ok, user_token} <- JWTSessionToken.get_user_token(jwt, token) do 36 | conn 37 | |> assign(:app, app) 38 | |> assign(:shop, shop) 39 | |> assign(:auth_token, auth_token) 40 | |> assign(:user_id, user_id) 41 | |> assign(:user_token, user_token) 42 | else 43 | error -> 44 | Logger.debug("Could not authenticate shop #{inspect(error)}") 45 | 46 | conn 47 | |> resp(401, "Not Authorized.") 48 | |> halt() 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/shopify_api/plugs/put_shopify_content_headers.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.Plugs.PutShopifyContentHeaders do 2 | @moduledoc """ 3 | A Plug to handle setting the content security and frame ancestors headers for Shop Admin. 4 | 5 | ## Example Installations 6 | 7 | Add this plug in a pipeline for your Shop Admin after the AdminAuthenticator plug. 8 | 9 | ```elixir 10 | pipeline :shop_admin do 11 | plug ShopifyAPI.Plugs.AdminAuthenticator, shopify_mount_path: "/shop" 12 | plug ShopifyAPI.Plugs.PutShopifyContentHeaders 13 | end 14 | ``` 15 | """ 16 | 17 | import Plug.Conn 18 | 19 | def init(opts), do: opts 20 | 21 | def call(conn, _options) do 22 | conn 23 | |> put_resp_header("x-frame-options", "ALLOW-FROM https://" <> myshopify_domain(conn)) 24 | |> put_resp_header( 25 | "content-security-policy", 26 | "frame-ancestors https://" <> myshopify_domain(conn) <> " https://admin.shopify.com;" 27 | ) 28 | end 29 | 30 | defp myshopify_domain(%{assigns: %{shop: %{domain: domain}}}), do: domain 31 | end 32 | -------------------------------------------------------------------------------- /lib/shopify_api/rate_limiting/graphql.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.RateLimiting.GraphQL do 2 | @plus_bucket 10_000 3 | @nonplus_bucket 1000 4 | 5 | @plus_restore_rate 500 6 | @nonplus_restore_rate 50 7 | 8 | @max_query_cost 1000 9 | 10 | def request_bucket(%{plus: true}), do: @plus_bucket 11 | def request_bucket(%{plus: false}), do: @nonplus_bucket 12 | 13 | def restore_rate_per_second(%{plus: true}), do: @plus_restore_rate 14 | def restore_rate_per_second(%{plus: false}), do: @nonplus_restore_rate 15 | 16 | def max_query_cost, do: @max_query_cost 17 | end 18 | -------------------------------------------------------------------------------- /lib/shopify_api/rate_limiting/graphql_call_limits.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.RateLimiting.GraphQLCallLimits do 2 | alias ShopifyAPI.GraphQL 3 | alias ShopifyAPI.RateLimiting 4 | 5 | # Our internal point availability tracking sometimes differs from Shopify's 6 | # Adding this padding prevents requests being throttled unecessarily 7 | @estimate_padding 20 8 | 9 | @spec calculate_wait( 10 | {String.t(), integer(), DateTime.t()}, 11 | ShopifyAPI.AuthToken.t(), 12 | integer(), 13 | DateTime.t() 14 | ) :: integer() 15 | def calculate_wait( 16 | {_key, points_available, time}, 17 | token, 18 | estimated_cost, 19 | now \\ DateTime.utc_now() 20 | ) do 21 | seconds_elapsed = DateTime.diff(now, time) 22 | restore_rate_per_second = RateLimiting.GraphQL.restore_rate_per_second(token) 23 | estimated_restore_amount = restore_rate_per_second * seconds_elapsed 24 | 25 | diff = points_available + estimated_restore_amount - estimated_cost - @estimate_padding 26 | 27 | case diff > 0 do 28 | true -> 0 29 | false -> round(abs(diff) / restore_rate_per_second * 1000) 30 | end 31 | end 32 | 33 | @spec get_api_remaining_points(GraphQL.Response.t() | HTTPoison.Response.t()) :: integer() 34 | def get_api_remaining_points(%{ 35 | metadata: %{"cost" => %{"throttleStatus" => %{"currentlyAvailable" => available}}} 36 | }) do 37 | available 38 | end 39 | 40 | # throttled queries return the HTTPoison.Response object 41 | def get_api_remaining_points(%{ 42 | body: %{ 43 | "extensions" => %{"cost" => %{"throttleStatus" => %{"currentlyAvailable" => available}}} 44 | } 45 | }) do 46 | available 47 | end 48 | 49 | def estimate_padding, do: @estimate_padding 50 | end 51 | -------------------------------------------------------------------------------- /lib/shopify_api/rate_limiting/graphql_tracker.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.RateLimiting.GraphQLTracker do 2 | @moduledoc """ 3 | Handles Tracking of GraphQL API throttling and when the API will be available for a request. 4 | """ 5 | 6 | alias ShopifyAPI.AuthToken 7 | alias ShopifyAPI.RateLimiting 8 | 9 | @behaviour RateLimiting.Tracker 10 | 11 | @name :shopify_api_graphql_availability_tracker 12 | 13 | @impl RateLimiting.Tracker 14 | def init, do: :ets.new(@name, [:named_table, :public]) 15 | 16 | @impl RateLimiting.Tracker 17 | def all, do: :ets.tab2list(@name) 18 | 19 | def clear_all, do: :ets.delete_all_objects(@name) 20 | 21 | @impl RateLimiting.Tracker 22 | def api_hit_limit(%AuthToken{} = token, http_response, _now \\ DateTime.utc_now()) do 23 | update_api_call_limit(token, http_response) 24 | end 25 | 26 | @impl RateLimiting.Tracker 27 | def update_api_call_limit(%AuthToken{} = token, http_response) do 28 | remaining = RateLimiting.GraphQLCallLimits.get_api_remaining_points(http_response) 29 | 30 | set(token, remaining, 0) 31 | end 32 | 33 | @impl RateLimiting.Tracker 34 | def get(%ShopifyAPI.AuthToken{} = token, now \\ DateTime.utc_now(), estimated_cost) do 35 | case :ets.lookup(@name, ShopifyAPI.AuthToken.create_key(token)) do 36 | [] -> 37 | {RateLimiting.GraphQL.request_bucket(token), 0} 38 | 39 | [{_key, points_available, _time} | _] when points_available > estimated_cost -> 40 | {points_available, 0} 41 | 42 | [{_key, points_available, _time} = value | _] -> 43 | wait_in_milliseconds = 44 | RateLimiting.GraphQLCallLimits.calculate_wait(value, token, estimated_cost, now) 45 | 46 | {points_available, wait_in_milliseconds} 47 | end 48 | end 49 | 50 | @impl RateLimiting.Tracker 51 | def set(token, points_available, _, now \\ DateTime.utc_now()) 52 | 53 | # Do nothing 54 | def set(_token, nil, _, _now), do: {0, 0} 55 | 56 | # Sets the current points available and time of the transaction (now) 57 | def set(%ShopifyAPI.AuthToken{} = token, points_available, _, now) do 58 | :ets.insert(@name, {ShopifyAPI.AuthToken.create_key(token), points_available, now}) 59 | {points_available, 0} 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/shopify_api/rate_limiting/rest.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.RateLimiting.REST do 2 | @plus_bucket 400 3 | @nonplus_bucket 40 4 | @plus_requests_per_second 20 5 | @nonplus_requests_per_second 2 6 | 7 | @over_limit_status_code 429 8 | 9 | def over_limit_status_code, do: @over_limit_status_code 10 | 11 | def request_bucket(%{plus: true}), do: @plus_bucket 12 | def request_bucket(%{plus: false}), do: @nonplus_bucket 13 | 14 | def requests_per_second(%{plus: true}), do: @plus_requests_per_second 15 | def requests_per_second(%{plus: false}), do: @nonplus_requests_per_second 16 | end 17 | -------------------------------------------------------------------------------- /lib/shopify_api/rate_limiting/rest_call_limits.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.RateLimiting.RESTCallLimits do 2 | @moduledoc """ 3 | Responsible for handling ShopifyAPI call limits in a HTTPoison.Response 4 | """ 5 | @shopify_call_limit_header "X-Shopify-Shop-Api-Call-Limit" 6 | @shopify_retry_after_header "Retry-After" 7 | @over_limit_status_code 429 8 | # API Overlimit error code 9 | @spec limit_header_or_status_code(any) :: nil | :over_limit 10 | def limit_header_or_status_code(%{status_code: @over_limit_status_code}), 11 | do: :over_limit 12 | 13 | def limit_header_or_status_code(%{headers: headers}), 14 | do: get_header(headers, @shopify_call_limit_header) 15 | 16 | def limit_header_or_status_code(_conn), do: nil 17 | 18 | def get_api_remaining_calls(nil), do: 0 19 | 20 | def get_api_remaining_calls(:over_limit), do: 0 21 | 22 | def get_api_remaining_calls(header_value) do 23 | # comes in the form "1/40": 1 taken of 40 24 | header_value 25 | |> String.split("/") 26 | |> Enum.map(&String.to_integer/1) 27 | |> calculate_available 28 | end 29 | 30 | def get_retry_after_header(%{headers: headers}), 31 | do: get_header(headers, @shopify_retry_after_header, "2.0") 32 | 33 | def get_retry_after_milliseconds(header_value) do 34 | {seconds, remainder} = Integer.parse(header_value) 35 | 36 | {milliseconds, ""} = 37 | remainder 38 | |> String.replace_prefix(".", "") 39 | |> String.pad_trailing(3, "0") 40 | |> Integer.parse() 41 | 42 | seconds * 1000 + milliseconds 43 | end 44 | 45 | def get_header(headers, header_name, default \\ nil) do 46 | Enum.find_value( 47 | headers, 48 | default, 49 | fn 50 | {^header_name, value} -> value 51 | _ -> nil 52 | end 53 | ) 54 | end 55 | 56 | defp calculate_available([used, total]), do: total - used 57 | end 58 | -------------------------------------------------------------------------------- /lib/shopify_api/rate_limiting/rest_tracker.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.RateLimiting.RESTTracker do 2 | @moduledoc """ 3 | Handles Tracking of API throttling and when the API will be available for a request. 4 | """ 5 | alias ShopifyAPI.RateLimiting 6 | 7 | @behaviour RateLimiting.Tracker 8 | 9 | @name :shopify_api_rest_availability_tracker 10 | 11 | @impl RateLimiting.Tracker 12 | def init, do: :ets.new(@name, [:named_table, :public]) 13 | 14 | @impl RateLimiting.Tracker 15 | def all, do: :ets.tab2list(@name) 16 | 17 | @impl RateLimiting.Tracker 18 | def api_hit_limit(%ShopifyAPI.AuthToken{} = token, http_response, now \\ DateTime.utc_now()) do 19 | available_modifier = 20 | http_response 21 | |> RateLimiting.RESTCallLimits.get_retry_after_header() 22 | |> RateLimiting.RESTCallLimits.get_retry_after_milliseconds() 23 | 24 | set(token, 0, available_modifier, now) 25 | end 26 | 27 | @impl RateLimiting.Tracker 28 | def update_api_call_limit(%ShopifyAPI.AuthToken{} = token, http_response) do 29 | http_response 30 | |> RateLimiting.RESTCallLimits.limit_header_or_status_code() 31 | |> RateLimiting.RESTCallLimits.get_api_remaining_calls() 32 | |> case do 33 | # Wait for a second to allow time for a bucket fill 34 | 0 -> set(token, 0, 1_000) 35 | remaining -> set(token, remaining, 0) 36 | end 37 | end 38 | 39 | @impl RateLimiting.Tracker 40 | def get(%ShopifyAPI.AuthToken{} = token, now \\ DateTime.utc_now(), _estimated_cost) do 41 | case :ets.lookup(@name, ShopifyAPI.AuthToken.create_key(token)) do 42 | [] -> 43 | {RateLimiting.REST.request_bucket(token), 0} 44 | 45 | [{_key, count, time} | _] -> 46 | diff = time |> DateTime.diff(now, :millisecond) |> max(0) 47 | 48 | {count, diff} 49 | end 50 | end 51 | 52 | @impl RateLimiting.Tracker 53 | def set(token, available_count, availability_delay, now \\ DateTime.utc_now()) 54 | 55 | # Do nothing 56 | def set(_token, nil, _availability_delay, _now), do: {0, 0} 57 | 58 | # Sets the current availability count and when the API will be available 59 | def set( 60 | %ShopifyAPI.AuthToken{} = token, 61 | available_count, 62 | availability_delay, 63 | now 64 | ) do 65 | available_at = DateTime.add(now, availability_delay, :millisecond) 66 | 67 | :ets.insert(@name, {ShopifyAPI.AuthToken.create_key(token), available_count, available_at}) 68 | {available_count, availability_delay} 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/shopify_api/rate_limiting/tracker.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.RateLimiting.Tracker do 2 | alias ShopifyAPI.AuthToken 3 | 4 | @type available_count :: integer() 5 | @type availability_delay :: integer() 6 | @type t :: {available_count(), availability_delay()} 7 | 8 | @callback init :: any() 9 | @callback all :: list() 10 | @callback api_hit_limit(AuthToken.t(), HTTPoison.Response.t(), DateTime.t()) :: t() 11 | @callback update_api_call_limit(AuthToken.t(), HTTPoison.Response.t()) :: t() 12 | @callback get(AuthToken.t(), DateTime.t(), integer()) :: t() 13 | @callback set(AuthToken.t(), available_count(), availability_delay(), DateTime.t()) :: t() 14 | end 15 | -------------------------------------------------------------------------------- /lib/shopify_api/rest.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.REST do 2 | @moduledoc """ 3 | Provides core REST actions for interacting with the Shopify API. 4 | Uses an `AuthToken` for authorization and request rate limiting. 5 | 6 | Please don't use this module directly. Instead prefer the higher-level modules 7 | implementing appropriate resource endpoints, such as `ShopifyAPI.REST.Product` 8 | """ 9 | 10 | alias ShopifyAPI.AuthToken 11 | alias ShopifyAPI.JSONSerializer 12 | alias ShopifyAPI.REST.Request 13 | 14 | @default_pagination Application.compile_env(:shopify_api, :pagination, :auto) 15 | 16 | @doc """ 17 | Underlying utility retrieval function. The options passed affect both the 18 | return value and, ultimately, the number of requests made to Shopify. 19 | 20 | ## Options 21 | 22 | `:pagination` - Can be `:none`, `:stream`, or `:auto`. Defaults to :auto 23 | `:auto` will block until all the pages have been retrieved and concatenated together. 24 | `:none` will only return the first page. You won't have access to the headers to manually 25 | paginate. 26 | `:stream` will return a `Stream`, prepopulated with the first page. 27 | """ 28 | @spec get(AuthToken.t(), path :: String.t(), keyword(), keyword()) :: 29 | {:ok, %{required(String.t()) => [map()]}} | Enumerable.t() 30 | def get(%AuthToken{} = auth, path, params \\ [], options \\ []) do 31 | {pagination, opts} = Keyword.pop(options, :pagination, @default_pagination) 32 | 33 | case pagination do 34 | :none -> 35 | with {:ok, response} <- Request.perform(auth, :get, path, "", params, opts) do 36 | {:ok, fetch_body(response)} 37 | end 38 | 39 | :stream -> 40 | Request.stream(auth, path, params, opts) 41 | 42 | :auto -> 43 | auth 44 | |> Request.stream(path, params, opts) 45 | |> collect_results() 46 | end 47 | end 48 | 49 | @spec collect_results(Enumerable.t()) :: 50 | {:ok, list()} | {:error, HTTPoison.Response.t() | any()} 51 | defp collect_results(stream) do 52 | stream 53 | |> Enum.reduce_while({:ok, []}, fn 54 | {:error, _} = error, {:ok, _acc} -> {:halt, error} 55 | result, {:ok, acc} -> {:cont, {:ok, [result | acc]}} 56 | end) 57 | |> case do 58 | {:ok, results} -> {:ok, Enum.reverse(results)} 59 | error -> error 60 | end 61 | end 62 | 63 | @doc false 64 | def post(%AuthToken{} = auth, path, object \\ %{}, options \\ []) do 65 | with {:ok, body} <- JSONSerializer.encode(object) do 66 | perform_request(auth, :post, path, body, options) 67 | end 68 | end 69 | 70 | @doc false 71 | def put(%AuthToken{} = auth, path, object, options \\ []) do 72 | with {:ok, body} <- JSONSerializer.encode(object) do 73 | perform_request(auth, :put, path, body, options) 74 | end 75 | end 76 | 77 | @doc false 78 | def delete(%AuthToken{} = auth, path), do: perform_request(auth, :delete, path) 79 | 80 | defp perform_request(auth, method, path, body \\ "", options \\ []) do 81 | with {:ok, response} <- Request.perform(auth, method, path, body, [], options), 82 | response_body <- fetch_body(response) do 83 | {:ok, response_body} 84 | end 85 | end 86 | 87 | defp fetch_body(http_response) do 88 | Map.fetch!(http_response, :body) 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /lib/shopify_api/rest/access_scope.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.REST.AccessScope do 2 | @moduledoc """ 3 | Shopify REST API Access Scope resources. 4 | """ 5 | 6 | alias ShopifyAPI.AuthToken 7 | alias ShopifyAPI.REST 8 | 9 | @doc """ 10 | Return a list of all access scopes associated with the access token. 11 | 12 | ## Example 13 | 14 | iex> ShopifyAPI.REST.AccessScope.get(auth) 15 | {:ok, [] = access_scopes} 16 | """ 17 | def get(%AuthToken{} = auth, params \\ [], options \\ []), 18 | do: 19 | REST.get( 20 | auth, 21 | "/admin/oauth/access_scopes.json", 22 | params, 23 | Keyword.merge([pagination: :none], options) 24 | ) 25 | end 26 | -------------------------------------------------------------------------------- /lib/shopify_api/rest/application_charge.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.REST.ApplicationCharge do 2 | @moduledoc """ 3 | ShopifyAPI REST API ApplicationCharge resource 4 | """ 5 | 6 | alias ShopifyAPI.AuthToken 7 | alias ShopifyAPI.REST 8 | 9 | @doc """ 10 | Create an application charge. 11 | 12 | ## Example 13 | 14 | iex> ShopifyAPI.REST.ApplicationCharge.create(auth, map) 15 | {:ok, { "application_charge" => %{} }} 16 | """ 17 | def create( 18 | %AuthToken{} = auth, 19 | %{application_charge: %{}} = application_charge 20 | ), 21 | do: REST.post(auth, "application_charges.json", application_charge) 22 | 23 | @doc """ 24 | Get a single application charge. 25 | 26 | ## Example 27 | 28 | iex> ShopifyAPI.REST.ApplicationCharge.get(auth, integer) 29 | {:ok, %{} = application_charge} 30 | """ 31 | def get(%AuthToken{} = auth, application_charge_id, params \\ [], options \\ []), 32 | do: 33 | REST.get( 34 | auth, 35 | "application_charges/#{application_charge_id}.json", 36 | params, 37 | Keyword.merge([pagination: :none], options) 38 | ) 39 | 40 | @doc """ 41 | Get a list of all application charges. 42 | 43 | ## Example 44 | 45 | iex> ShopifyAPI.REST.ApplicationCharge.all(auth) 46 | {:ok, [%{}, ...] = application_charges} 47 | """ 48 | def all(%AuthToken{} = auth, params \\ [], options \\ []), 49 | do: 50 | REST.get( 51 | auth, 52 | "application_charges.json", 53 | params, 54 | Keyword.merge([pagination: :none], options) 55 | ) 56 | 57 | @doc """ 58 | Active an application charge. 59 | 60 | ## Example 61 | 62 | iex> ShopifyAPI.REST.ApplicationCharge.activate(auth) 63 | {:ok, %{} = application_charge} 64 | """ 65 | def activate( 66 | %AuthToken{} = auth, 67 | %{application_charge: %{id: application_charge_id}} = application_charge 68 | ) do 69 | REST.post( 70 | auth, 71 | "application_charges/#{application_charge_id}/activate.json", 72 | application_charge 73 | ) 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /lib/shopify_api/rest/application_credit.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.REST.ApplicationCredit do 2 | @moduledoc """ 3 | ShopifyAPI REST API ApplicationCredit resource 4 | """ 5 | 6 | alias ShopifyAPI.AuthToken 7 | alias ShopifyAPI.REST 8 | 9 | @doc """ 10 | Create an application credit. 11 | 12 | ## Example 13 | 14 | iex> ShopifyAPI.REST.ApplicationCredit.create(auth, map) 15 | {:ok, { "application_credit" => %{} }} 16 | """ 17 | def create(%AuthToken{} = auth, %{application_credit: %{}} = application_credit), 18 | do: REST.post(auth, "application_credits.json", application_credit) 19 | 20 | @doc """ 21 | Get a single application credit. 22 | 23 | ## Example 24 | 25 | iex> ShopifyAPI.REST.ApplicationCredit.get(auth, integer) 26 | {:ok, %{} = application_credit} 27 | """ 28 | def get(%AuthToken{} = auth, application_credit_id, params \\ [], options \\ []), 29 | do: 30 | REST.get( 31 | auth, 32 | "application_credits/#{application_credit_id}.json", 33 | params, 34 | Keyword.merge([pagination: :none], options) 35 | ) 36 | 37 | @doc """ 38 | Get a list of all application credits. 39 | 40 | ## Example 41 | 42 | iex> ShopifyAPI.REST.ApplicationCredit.all(auth) 43 | {:ok, [%{}, ...] = application_credits} 44 | """ 45 | def all(%AuthToken{} = auth, params \\ [], options \\ []), 46 | do: 47 | REST.get( 48 | auth, 49 | "application_credits.json", 50 | params, 51 | Keyword.merge([pagination: :none], options) 52 | ) 53 | end 54 | -------------------------------------------------------------------------------- /lib/shopify_api/rest/asset.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.REST.Asset do 2 | @moduledoc """ 3 | ShopifyAPI REST API Theme Asset resource 4 | """ 5 | 6 | alias ShopifyAPI.AuthToken 7 | alias ShopifyAPI.REST 8 | 9 | @doc """ 10 | Get a single theme asset. 11 | 12 | ## Example 13 | 14 | iex> ShopifyAPI.REST.Asset.get(auth, integer, params) 15 | {:ok, %{} = asset} 16 | """ 17 | def get(%AuthToken{} = auth, theme_id, params \\ [], options \\ []), 18 | do: 19 | REST.get( 20 | auth, 21 | "themes/#{theme_id}/assets.json", 22 | params, 23 | Keyword.merge([pagination: :none], options) 24 | ) 25 | 26 | @doc """ 27 | Return a list of all theme assets. 28 | 29 | ## Example 30 | 31 | iex> ShopifyAPI.REST.Asset.all(auth, theme_id) 32 | {:ok, [%{}, ...] = assets} 33 | """ 34 | def all(%AuthToken{} = auth, theme_id, params \\ [], options \\ []), 35 | do: 36 | REST.get( 37 | auth, 38 | "themes/#{theme_id}/assets.json", 39 | params, 40 | Keyword.merge([pagination: :none], options) 41 | ) 42 | 43 | @doc """ 44 | Update a theme asset. 45 | 46 | ## Example 47 | 48 | iex> ShopifyAPI.REST.Asset.update(auth, theme_id, map) 49 | {:ok, %{ "asset" => %{} }} 50 | """ 51 | def update(%AuthToken{} = auth, theme_id, asset), 52 | do: REST.put(auth, "themes/#{theme_id}/assets.json", asset) 53 | 54 | @doc """ 55 | Delete a theme asset. 56 | 57 | ## Example 58 | 59 | iex> ShopifyAPI.REST.Asset.delete(auth, theme_id, map) 60 | {:ok, 200 } 61 | """ 62 | def delete(%AuthToken{} = auth, theme_id, params), 63 | do: REST.delete(auth, "themes/#{theme_id}/assets.json?" <> URI.encode_query(params)) 64 | 65 | @doc """ 66 | Create a new theme asset. 67 | 68 | ## Example 69 | 70 | iex> ShopifyAPI.REST.Theme.create(auth, theme_id, map) 71 | {:ok, %{ "asset" => %{} }} 72 | """ 73 | def create(%AuthToken{} = auth, theme_id, asset), 74 | do: update(auth, theme_id, asset) 75 | end 76 | -------------------------------------------------------------------------------- /lib/shopify_api/rest/carrier_service.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.REST.CarrierService do 2 | @moduledoc """ 3 | ShopifyAPI REST API CarrierService resource 4 | """ 5 | 6 | alias ShopifyAPI.AuthToken 7 | alias ShopifyAPI.REST 8 | 9 | @doc """ 10 | Return a list of all carrier services. 11 | 12 | ## Example 13 | 14 | iex> ShopifyAPI.REST.CarrierService.all(auth) 15 | {:ok, [%{}, ...] = carrier_services} 16 | """ 17 | def all(%AuthToken{} = auth, params \\ [], options \\ []), 18 | do: 19 | REST.get(auth, "carrier_services.json", params, Keyword.merge([pagination: :none], options)) 20 | 21 | @doc """ 22 | Get a single carrier service. 23 | 24 | ## Example 25 | 26 | iex> ShopifyAPI.REST.CarrierService.get(auth, string) 27 | {:ok, %{} = carrier_service} 28 | """ 29 | def get(%AuthToken{} = auth, carrier_service_id, params \\ [], options \\ []), 30 | do: 31 | REST.get( 32 | auth, 33 | "carrier_services/#{carrier_service_id}.json", 34 | params, 35 | Keyword.merge([pagination: :none], options) 36 | ) 37 | 38 | @doc """ 39 | Create a carrier service. 40 | 41 | ## Example 42 | 43 | iex> ShopifyAPI.REST.CarrierService.create(auth, map) 44 | {:ok, %{} = carrier_service} 45 | """ 46 | def create(%AuthToken{} = auth, %{carrier_service: %{}} = carrier_service), 47 | do: REST.post(auth, "carrier_services.json", carrier_service) 48 | 49 | @doc """ 50 | Update a carrier service. 51 | 52 | ## Example 53 | 54 | iex> ShopifyAPI.REST.CarrierService.update(auth) 55 | {:ok, %{} = carrier_service} 56 | """ 57 | def update( 58 | %AuthToken{} = auth, 59 | %{carrier_service: %{id: carrier_service_id}} = carrier_service 60 | ), 61 | do: REST.put(auth, "carrier_services/#{carrier_service_id}.json", carrier_service) 62 | 63 | @doc """ 64 | Delete a carrier service. 65 | 66 | ## Example 67 | 68 | iex> ShopifyAPI.REST.CarrierService.delete(auth, string) 69 | {:ok, 200 } 70 | """ 71 | def delete(%AuthToken{} = auth, carrier_service_id), 72 | do: REST.delete(auth, "carrier_services/#{carrier_service_id}.json") 73 | end 74 | -------------------------------------------------------------------------------- /lib/shopify_api/rest/checkout.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.REST.Checkout do 2 | @moduledoc """ 3 | Shopify REST API Checkout resources. 4 | """ 5 | 6 | require Logger 7 | alias ShopifyAPI.AuthToken 8 | alias ShopifyAPI.REST 9 | 10 | @doc """ 11 | Return a single checkout resource. 12 | 13 | ## Example 14 | 15 | iex> ShopifyAPI.REST.Checkout.get(auth, string) 16 | {:ok, %{} = checkout} 17 | """ 18 | def get(%AuthToken{} = auth, checkout_token, options \\ []) do 19 | REST.get( 20 | auth, 21 | "checkouts/#{checkout_token}.json", 22 | [], 23 | Keyword.merge([pagination: :none], options) 24 | ) 25 | end 26 | 27 | @doc """ 28 | Create a new checkout for a customer. 29 | 30 | ## Example 31 | 32 | iex> ShopifyAPI.REST.Checkout.create(auth, map) 33 | {:ok, %{} = checkout} 34 | """ 35 | def create(%AuthToken{} = auth, %{checkout: %{}} = checkout), 36 | do: REST.post(auth, "checkouts.json", checkout) 37 | 38 | @doc """ 39 | Update an existing checkout. 40 | 41 | ## Example 42 | 43 | iex> ShopifyAPI.REST.Checkout.update(auth, map) 44 | {:ok, %{} = checkout} 45 | """ 46 | def update(%AuthToken{} = auth, %{checkout: %{id: checkout_token}} = checkout) do 47 | REST.put(auth, "checkouts/#{checkout_token}.json", checkout) 48 | end 49 | 50 | @doc """ 51 | Completes an existing checkout. 52 | 53 | ## Example 54 | 55 | iex> ShopifyAPI.REST.Checkout.complete(auth, string) 56 | {:ok, %{} = checkout} 57 | """ 58 | def complete(%AuthToken{} = auth, checkout_token) do 59 | REST.post(auth, "checkouts/#{checkout_token}/complete.json", %{}) 60 | end 61 | 62 | @doc """ 63 | Gets shipping rates for a checkout. 64 | 65 | ## Example 66 | 67 | iex> ShopifyAPI.REST.Checkout.shipping_rates(auth, string) 68 | {:ok, %{"shipping_rates" => [] = shipping_rates}} 69 | """ 70 | def shipping_rates(%AuthToken{} = auth, checkout_token) do 71 | REST.get(auth, "checkouts/#{checkout_token}/shipping_rates.json") 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /lib/shopify_api/rest/collect.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.REST.Collect do 2 | @moduledoc """ 3 | ShopifyAPI REST API Collect resource 4 | """ 5 | 6 | alias ShopifyAPI.AuthToken 7 | alias ShopifyAPI.REST 8 | 9 | @doc """ 10 | Add a product to custom collection. 11 | 12 | ## Example 13 | 14 | iex> ShopifyAPI.REST.Collect.add(auth, %{collect: collect}) 15 | {:ok, %{} = collect} 16 | """ 17 | def add(%AuthToken{} = auth, %{collect: %{}} = collect), 18 | do: REST.post(auth, "collects.json", collect) 19 | 20 | @doc """ 21 | Remove a product from a custom collection. 22 | 23 | ## Example 24 | 25 | iex> ShopifyAPI.REST.Collect.delete(auth, collect_id) 26 | {:ok, 200} 27 | """ 28 | def delete(%AuthToken{} = auth, collect_id), 29 | do: REST.delete(auth, "collects/#{collect_id}.json") 30 | 31 | @doc """ 32 | Get list of all collects. 33 | 34 | ## Example 35 | 36 | iex> ShopifyAPI.REST.Collect.all(auth) 37 | {:ok, [%{}, ...] = collects} 38 | """ 39 | def all(%AuthToken{} = auth, params \\ [], options \\ []), 40 | do: REST.get(auth, "collects.json", params, options) 41 | 42 | @doc """ 43 | Get a count of collects. 44 | 45 | ## Example 46 | 47 | iex> ShopifyAPI.REST.Collect.count(auth) 48 | {:ok, 123 = count} 49 | """ 50 | def count(%AuthToken{} = auth, params \\ [], options \\ []), 51 | do: REST.get(auth, "collects/count.json", params, Keyword.merge([pagination: :none], options)) 52 | 53 | @doc """ 54 | Get a specific collect. 55 | 56 | ## Example 57 | 58 | iex> ShopifyAPI.REST.Collect.get(auth, id) 59 | {:ok, %{} = collect} 60 | """ 61 | def get(%AuthToken{} = auth, collect_id, params \\ [], options \\ []), 62 | do: 63 | REST.get( 64 | auth, 65 | "collects/#{collect_id}.json", 66 | params, 67 | Keyword.merge([pagination: :none], options) 68 | ) 69 | end 70 | -------------------------------------------------------------------------------- /lib/shopify_api/rest/custom_collection.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.REST.CustomCollection do 2 | @moduledoc """ 3 | Shopify REST API Custom Collection resources 4 | """ 5 | 6 | alias ShopifyAPI.AuthToken 7 | alias ShopifyAPI.REST 8 | 9 | @doc """ 10 | Get a list of all the custom collections. 11 | 12 | ## Example 13 | iex> ShopifyAPI.REST.CustomCollection.all(token) 14 | {:ok, [%{}, ...] = custom_collections} 15 | """ 16 | def all(%AuthToken{} = auth, params \\ [], options \\ []), 17 | do: REST.get(auth, "custom_collections.json", params, options) 18 | 19 | @doc """ 20 | Get a count of all custom collections. 21 | 22 | ## Example 23 | iex> ShopifyAPI.REST.CustomCollection.count(token) 24 | {:ok, integer} 25 | """ 26 | def count(%AuthToken{} = auth, params \\ [], options \\ []), 27 | do: 28 | REST.get( 29 | auth, 30 | "custom_collections/count.json", 31 | params, 32 | Keyword.merge([pagination: :none], options) 33 | ) 34 | 35 | @doc """ 36 | Return a single custom collection. 37 | 38 | ## Example 39 | iex> ShopifyAPI.REST.CustomCollection.get(auth, string) 40 | {:ok, %{} = custom_collections} 41 | """ 42 | def get(%AuthToken{} = auth, custom_collection_id, params \\ [], options \\ []), 43 | do: 44 | REST.get( 45 | auth, 46 | "custom_collections/#{custom_collection_id}.json", 47 | params, 48 | Keyword.merge([pagination: :none], options) 49 | ) 50 | 51 | @doc """ 52 | Create a custom collection. 53 | 54 | ## Example 55 | iex> ShopifyAPI.REST.CustomCollection.create(auth, map) 56 | {:ok, %{} = custom_collection} 57 | """ 58 | def create(%AuthToken{} = auth, %{custom_collection: %{}} = custom_collection), 59 | do: REST.post(auth, "custom_collections.json", custom_collection) 60 | 61 | @doc """ 62 | Update an existing custom collection. 63 | 64 | ## Example 65 | iex> ShopifyAPI.REST.CustomCollection.update(auth, string, map) 66 | {:ok, %{} = custom_collection} 67 | """ 68 | def update( 69 | %AuthToken{} = auth, 70 | %{custom_collection: %{id: custom_collection_id}} = custom_collection 71 | ), 72 | do: REST.put(auth, "custom_collections/#{custom_collection_id}.json", custom_collection) 73 | 74 | @doc """ 75 | Delete a custom collection. 76 | 77 | ## Example 78 | iex> ShopifyAPI.REST.CustomCollection.delete(auth, string) 79 | {:ok, %{ "response": 200 }} 80 | """ 81 | def delete(%AuthToken{} = auth, custom_collection_id), 82 | do: REST.delete(auth, "custom_collections/#{custom_collection_id}.json") 83 | end 84 | -------------------------------------------------------------------------------- /lib/shopify_api/rest/customer.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.REST.Customer do 2 | @moduledoc """ 3 | Shopify REST API Customer resource 4 | """ 5 | 6 | require Logger 7 | alias ShopifyAPI.AuthToken 8 | alias ShopifyAPI.REST 9 | 10 | @doc """ 11 | Returns all the customers. 12 | 13 | ## Example 14 | 15 | iex> ShopifyAPI.REST.Customer.all(auth) 16 | {:ok, [%{}, ...] = customers} 17 | """ 18 | def all(%AuthToken{} = auth, params \\ [], options \\ []), 19 | do: REST.get(auth, "customers.json", params, options) 20 | 21 | @doc """ 22 | Return a single customer. 23 | 24 | ## Example 25 | 26 | iex> ShopifyAPI.REST.Customer.get(auth, integer) 27 | {:ok, %{} = customer} 28 | """ 29 | def get(%AuthToken{} = auth, customer_id, params \\ [], options \\ []) do 30 | REST.get( 31 | auth, 32 | "customers/#{customer_id}.json", 33 | params, 34 | Keyword.merge([pagination: :none], options) 35 | ) 36 | end 37 | 38 | @doc """ 39 | Return a customers that match supplied query. 40 | 41 | NOTE: Not implemented. 42 | 43 | ## Example 44 | 45 | iex> ShopifyAPI.REST.Customer.getQuery() 46 | {:error, "Not implemented"} 47 | """ 48 | def get_query do 49 | Logger.warning("#{__MODULE__} error, resource not implemented.") 50 | {:error, "Not implemented"} 51 | end 52 | 53 | @doc """ 54 | Creates a customer. 55 | 56 | ## Example 57 | 58 | iex> ShopifyAPI.REST.Customer.create(auth, map) 59 | {:ok, %{} = customer} 60 | """ 61 | def create(%AuthToken{} = auth, %{customer: %{}} = customer), 62 | do: REST.post(auth, "customers.json", customer) 63 | 64 | @doc """ 65 | Updates a customer. 66 | 67 | ## Example 68 | 69 | iex> ShopifyAPI.REST.Customer.update(auth, map) 70 | {:ok, %{} = customer} 71 | """ 72 | def update(%AuthToken{} = auth, %{customer: %{id: customer_id}} = customer), 73 | do: REST.put(auth, "customers/#{customer_id}.json", customer) 74 | 75 | @doc """ 76 | Create an account activation URL. 77 | 78 | ## Example 79 | 80 | iex> ShopifyAPI.REST.Customer.CreateActivationUrl(auth, integer) 81 | {:ok, "" = account_activation_url} 82 | """ 83 | def create_activation_url( 84 | %AuthToken{} = auth, 85 | %{customer: %{id: customer_id}} = customer 86 | ), 87 | do: REST.post(auth, "customers/#{customer_id}/account_activation.json", customer) 88 | 89 | @doc """ 90 | Send an account invite to customer. 91 | 92 | ## Example 93 | 94 | iex> ShopifyAPI.REST.Customer.sendInvite(auth, integer) 95 | {:ok, %{} = customer_invite} 96 | """ 97 | def send_invite(%AuthToken{} = auth, customer_id), 98 | do: REST.post(auth, "customers/#{customer_id}/send_invite.json") 99 | 100 | @doc """ 101 | Delete a customer. 102 | 103 | ## Example 104 | 105 | iex> ShopifyAPI.REST.Customer.delete(auth, integer) 106 | {:ok, 200 } 107 | """ 108 | def delete(%AuthToken{} = auth, customer_id), 109 | do: REST.delete(auth, "customers/#{customer_id}") 110 | 111 | @doc """ 112 | Return a count of all customers. 113 | 114 | ## Example 115 | 116 | iex> ShopifyAPI.REST.Customer.count(auth) 117 | {:ok, integer} 118 | """ 119 | def count(%AuthToken{} = auth, params \\ [], options \\ []), 120 | do: 121 | REST.get(auth, "customers/count.json", params, Keyword.merge([pagination: :none], options)) 122 | 123 | @doc """ 124 | Return all orders from a customer. 125 | 126 | ## Example 127 | 128 | iex> ShopifyAPI.REST.Customer.GetOrder(auth, integer) 129 | {:ok, [%{}, ...] = orders} 130 | """ 131 | def get_orders(%AuthToken{} = auth, customer_id, params \\ [], options \\ []), 132 | do: 133 | REST.get( 134 | auth, 135 | "customers/#{customer_id}/orders.json", 136 | params, 137 | Keyword.merge([pagination: :none], options) 138 | ) 139 | 140 | @doc """ 141 | Search for customers that match a supplied query 142 | 143 | ## Example 144 | 145 | iex> ShopifyAPI.REST.Customer.search(auth, params) 146 | {:ok, [%{}, ...] = customers} 147 | 148 | The search params must be passed in as follows: 149 | %{"query" => "store::7020"} - returns all customers with a tag of store::7020 150 | %{"query" => "country:Canada"} - returns all customers with an address in Canada 151 | %{"query" => "Bob country:Canada"} - returns all customers with an address in Canada and the name "Bob" 152 | """ 153 | # TODO (BJ) - Consider refactoring to use a KW List of options params 154 | def search(%AuthToken{} = auth, params, options \\ []), 155 | do: REST.get(auth, "customers/search.json", params, options) 156 | end 157 | -------------------------------------------------------------------------------- /lib/shopify_api/rest/customer_address.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.REST.CustomerAddress do 2 | @moduledoc """ 3 | Shopify REST API Customer Address resources. 4 | """ 5 | 6 | require Logger 7 | alias ShopifyAPI.AuthToken 8 | alias ShopifyAPI.REST 9 | 10 | @doc """ 11 | Return a list of all addresses for a customer. 12 | 13 | ## Example 14 | 15 | iex> ShopifyAPI.REST.CustomerAddress.all(auth, string) 16 | {:ok, [%{}, ...] = addresses} 17 | """ 18 | def all(%AuthToken{} = auth, customer_id, params \\ [], options \\ []), 19 | do: REST.get(auth, "customers/#{customer_id}/addresses.json", params, options) 20 | 21 | @doc """ 22 | Return a single address for a customer. 23 | 24 | ## Example 25 | 26 | iex> ShopifyAPI.REST.CustomerAddress.get(auth, string, string) 27 | {:ok, %{} = customer_address} 28 | """ 29 | def get(%AuthToken{} = auth, customer_id, address_id, params \\ [], options \\ []), 30 | do: 31 | REST.get( 32 | auth, 33 | "customers/#{customer_id}/addresses/#{address_id}.json", 34 | params, 35 | Keyword.merge([pagination: :none], options) 36 | ) 37 | 38 | @doc """ 39 | Create a new address for a customer. 40 | 41 | ## Example 42 | 43 | iex> ShopifyAPI.REST.CustomerAddress.create(auth, string, map) 44 | {:ok, %{} = customer_address} 45 | """ 46 | def create(%AuthToken{} = auth, customer_id, %{address: %{}} = address), 47 | do: REST.post(auth, "customers/#{customer_id}/addresses.json", address) 48 | 49 | @doc """ 50 | Update an existing customer address. 51 | 52 | ## Example 53 | 54 | iex> ShopifyAPI.REST.CustomerAddress.update(auth, string, string) 55 | {:ok, %{} = customer_address} 56 | """ 57 | def update( 58 | %AuthToken{} = auth, 59 | customer_id, 60 | %{address: %{id: address_id}} = address 61 | ) do 62 | REST.put( 63 | auth, 64 | "customers/#{customer_id}/addresses/#{address_id}.json", 65 | address 66 | ) 67 | end 68 | 69 | @doc """ 70 | Delete an address from a customers address list. 71 | 72 | ## Example 73 | 74 | iex> ShopifyAPI.REST.CustomerAddress.delete(auth, string, string) 75 | {:ok, 200 } 76 | """ 77 | def delete(%AuthToken{} = auth, customer_id, address_id), 78 | do: REST.delete(auth, "customers/#{customer_id}/addresses/#{address_id}.json") 79 | 80 | @doc """ 81 | Perform bulk operations for multiple customer addresses. 82 | 83 | NOTE: Not implemented. 84 | 85 | ## Example 86 | 87 | iex> ShopifyAPI.REST.CustomerAddress.action() 88 | {:error, "Not implemented" } 89 | """ 90 | def action do 91 | Logger.warning("#{__MODULE__} error, resource not implemented.") 92 | {:error, "Not implemented"} 93 | end 94 | 95 | @doc """ 96 | Set the default address for a customer. 97 | 98 | ## Example 99 | 100 | iex> ShopifyAPI.REST.CustomerAddress.setDefault(auth, string, string) 101 | {:ok, %{} = customer_address} 102 | """ 103 | def set_default( 104 | %AuthToken{} = auth, 105 | customer_id, 106 | %{address: %{id: address_id}} = address 107 | ) do 108 | REST.put( 109 | auth, 110 | "customers/#{customer_id}/addresses/#{address_id}/default.json", 111 | address 112 | ) 113 | end 114 | end 115 | -------------------------------------------------------------------------------- /lib/shopify_api/rest/customer_saved_search.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.REST.CustomerSavedSearch do 2 | @moduledoc """ 3 | ShopifyAPI REST API CustomerSavedSearch resource 4 | """ 5 | 6 | require Logger 7 | 8 | @doc """ 9 | Get a list of all the customer saved searches. 10 | 11 | ## Example 12 | 13 | iex> ShopifyAPI.REST.CustomerSavedSearch.all() 14 | {:error, "Not implemented" } 15 | """ 16 | def all do 17 | Logger.warning("#{__MODULE__} error, resource not implemented.") 18 | {:error, "Not implemented"} 19 | end 20 | 21 | @doc """ 22 | Return a count of all the customer saved searches. 23 | 24 | ## Example 25 | 26 | iex> ShopifyAPI.REST.CustomerSavedSearch.count() 27 | {:error, "Not implemented" } 28 | """ 29 | def count do 30 | Logger.warning("#{__MODULE__} error, resource not implemented.") 31 | {:error, "Not implemented"} 32 | end 33 | 34 | @doc """ 35 | Get a single customer saved searches. 36 | 37 | ## Example 38 | 39 | iex> ShopifyAPI.REST.CustomerSavedSearch.get() 40 | {:error, "Not implemented" } 41 | """ 42 | def get do 43 | Logger.warning("#{__MODULE__} error, resource not implemented.") 44 | {:error, "Not implemented"} 45 | end 46 | 47 | @doc """ 48 | Retrieve all customers returned by a customer saved search. 49 | 50 | ## Example 51 | 52 | iex> ShopifyAPI.REST.CustomerSavedSearch.query() 53 | {:error, "Not implemented" } 54 | """ 55 | def query do 56 | Logger.warning("#{__MODULE__} error, resource not implemented.") 57 | {:error, "Not implemented"} 58 | end 59 | 60 | @doc """ 61 | Create a customer saved search. 62 | 63 | ## Example 64 | 65 | iex> ShopifyAPI.REST.CustomerSavedSearch.create() 66 | {:error, "Not implemented" } 67 | """ 68 | def create do 69 | Logger.warning("#{__MODULE__} error, resource not implemented.") 70 | {:error, "Not implemented"} 71 | end 72 | 73 | @doc """ 74 | Update a customer saved search. 75 | 76 | ## Example 77 | 78 | iex> ShopifyAPI.REST.CustomerSavedSearch.update() 79 | {:error, "Not implemented" } 80 | """ 81 | def update do 82 | Logger.warning("#{__MODULE__} error, resource not implemented.") 83 | {:error, "Not implemented"} 84 | end 85 | 86 | @doc """ 87 | Delete a customer saved search. 88 | 89 | ## Example 90 | 91 | iex> ShopifyAPI.REST.CustomerSavedSearch.delete() 92 | {:error, "Not implemented" } 93 | """ 94 | def delete do 95 | Logger.warning("#{__MODULE__} error, resource not implemented.") 96 | {:error, "Not implemented"} 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /lib/shopify_api/rest/discount_code.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.REST.DiscountCode do 2 | @moduledoc """ 3 | ShopifyAPI REST API DiscountCode resource 4 | """ 5 | 6 | alias ShopifyAPI.AuthToken 7 | alias ShopifyAPI.REST 8 | 9 | @doc """ 10 | Create a discount code. 11 | 12 | ## Example 13 | 14 | iex> ShopifyAPI.REST.DiscountCode.create(auth, map) 15 | {:ok, %{} = discount_code} 16 | """ 17 | def create( 18 | %AuthToken{} = auth, 19 | %{discount_code: %{price_rule_id: price_rule_id}} = discount_code 20 | ) do 21 | REST.post(auth, "price_rules/#{price_rule_id}/discount_codes.json", discount_code) 22 | end 23 | 24 | @doc """ 25 | Update an existing discount code. 26 | 27 | ## Example 28 | 29 | iex> ShopifyAPI.REST.DiscountCode.update(auth, integer, map) 30 | {:ok, %{} = discount_code} 31 | """ 32 | def update( 33 | %AuthToken{} = auth, 34 | price_rule_id, 35 | %{discount_code: %{id: discount_code_id}} = discount_code 36 | ) do 37 | REST.put( 38 | auth, 39 | "price_rules/#{price_rule_id}/discount_codes/#{discount_code_id}.json", 40 | discount_code 41 | ) 42 | end 43 | 44 | @doc """ 45 | Return all the discount codes. 46 | 47 | ## Example 48 | 49 | iex> ShopifyAPI.REST.DiscountCode.all(auth, integer) 50 | {:ok, [%{}, ...] = discount_codes} 51 | """ 52 | def all(%AuthToken{} = auth, price_rule_id, params \\ [], options \\ []) do 53 | REST.get(auth, "price_rules/#{price_rule_id}/discount_codes.json", params, options) 54 | end 55 | 56 | @doc """ 57 | Get a single discount code. 58 | 59 | ## Example 60 | 61 | iex> ShopifyAPI.REST.DiscountCode.get(auth, integer, integer) 62 | {:ok, %{} = discount_code} 63 | """ 64 | def get(%AuthToken{} = auth, price_rule_id, discount_code_id, params \\ [], options \\ []), 65 | do: 66 | REST.get( 67 | auth, 68 | "price_rules/#{price_rule_id}/discount_codes/#{discount_code_id}.json", 69 | params, 70 | Keyword.merge([pagination: :none], options) 71 | ) 72 | 73 | @doc """ 74 | Retrieve the location of a discount code. 75 | 76 | ## Example 77 | 78 | iex> ShopifyAPI.REST.DiscountCode.query(auth, string) 79 | {:ok, "" = location} 80 | """ 81 | def query(%AuthToken{} = auth, coupon_code, params \\ [], options \\ []), 82 | do: 83 | REST.get( 84 | auth, 85 | "discount_codes/lookup.json", 86 | Keyword.merge([code: coupon_code], params), 87 | Keyword.merge([pagination: :none], options) 88 | ) 89 | 90 | @doc """ 91 | Delete a discount code. 92 | 93 | ## Example 94 | 95 | iex> ShopifyAPI.REST.DiscountCode.delete(auth, integer, integer) 96 | {:ok, 204 }} 97 | """ 98 | def delete(%AuthToken{} = auth, price_rule_id, discount_code_id), 99 | do: REST.delete(auth, "price_rules/#{price_rule_id}/discount_codes/#{discount_code_id}.json") 100 | 101 | @doc """ 102 | Creates a discount code creation job. 103 | 104 | ## Example 105 | 106 | iex> ShopifyAPI.REST.DiscountCode.createBatch(auth, list) 107 | {:ok, [%{}, ...] = discount_codes} 108 | """ 109 | def create_batch(auth, price_rule_id, %{discount_codes: []} = discount_codes), 110 | do: REST.post(auth, "price_rules/#{price_rule_id}/batch.json", discount_codes) 111 | 112 | @doc """ 113 | Get a discount code creation job. 114 | 115 | ## Example 116 | 117 | iex> ShopifyAPI.REST.DiscountCode.get_batch(auth, integer, integer) 118 | {:ok, %{} = discount_code_creation} 119 | """ 120 | def get_batch(%AuthToken{} = auth, price_rule_id, batch_id, params \\ [], options \\ []), 121 | do: 122 | REST.get( 123 | auth, 124 | "price_rules/#{price_rule_id}/batch/#{batch_id}.json", 125 | params, 126 | Keyword.merge([pagination: :none], options) 127 | ) 128 | 129 | @doc """ 130 | Return a list of discount codes for a discount code creation job. 131 | 132 | ## Example 133 | 134 | iex> ShopifyAPI.REST.DiscountCode.all_batch(auth, integer, integer) 135 | {:ok, [] = discount_codes} 136 | """ 137 | def(all_batch(%AuthToken{} = auth, price_rule_id, batch_id, params \\ [], options \\ []), 138 | do: 139 | REST.get( 140 | auth, 141 | "price_rules/#{price_rule_id}/batch/#{batch_id}/discount_code.json", 142 | params, 143 | Keyword.merge([pagination: :none], options) 144 | ) 145 | ) 146 | end 147 | -------------------------------------------------------------------------------- /lib/shopify_api/rest/draft_order.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.REST.DraftOrder do 2 | @moduledoc """ 3 | ShopifyAPI REST API DraftOrder resource 4 | 5 | This resource contains methods for working with draft orders in Shopify 6 | """ 7 | alias ShopifyAPI.AuthToken 8 | alias ShopifyAPI.REST 9 | 10 | @doc """ 11 | Create a new draft order with the provided attributes 12 | 13 | ## Example 14 | 15 | iex> ShopifyAPI.REST.DraftOrder.create(auth_token, %{draft_order: %{}}) 16 | {:ok, %{"draft_order" => %{...}}} 17 | """ 18 | def create(%AuthToken{} = auth, %{draft_order: %{}} = draft_order), 19 | do: REST.post(auth, "draft_orders.json", draft_order) 20 | 21 | @doc """ 22 | Update a draft order 23 | 24 | ## Example 25 | 26 | iex> ShopifyAPI.REST.DraftOrder.update(auth_token, %{draft_order: %{}}) 27 | {:ok, %{...} = draft_order} 28 | 29 | To add a note to a draft order: 30 | %{ 31 | draft_order: %{ 32 | id: 994118539, 33 | note: "Customer contacted us about a custom engraving on the item" 34 | } 35 | } 36 | """ 37 | def update(%AuthToken{} = auth, %{draft_order: %{id: draft_order_id}} = draft_order), 38 | do: REST.put(auth, "draft_orders/#{draft_order_id}.json", draft_order) 39 | 40 | @doc """ 41 | Retrieve a list of all draft orders 42 | 43 | ## Example 44 | 45 | iex> ShopifyAPI.REST.DraftOrder.all(auth) 46 | {:ok, [%{}, ...] = draft_orders} 47 | """ 48 | def all(%AuthToken{} = auth, params \\ [], options \\ []), 49 | do: REST.get(auth, "draft_orders.json", params, options) 50 | 51 | @doc """ 52 | Retrieve a specific draft order 53 | 54 | ## Example 55 | 56 | iex> ShopifyAPI.REST.DraftOrder.get(auth, integer) 57 | {:ok, %{...} = draft_order} 58 | """ 59 | def get(%AuthToken{} = auth, draft_order_id, params \\ [], options \\ []), 60 | do: 61 | REST.get( 62 | auth, 63 | "draft_orders/#{draft_order_id}.json", 64 | params, 65 | Keyword.merge([pagination: :none], options) 66 | ) 67 | 68 | @doc """ 69 | Retrieve a count of all draft orders 70 | 71 | ## Example 72 | 73 | iex> ShopifyAPI.REST.DraftOrder.count(auth) 74 | {:ok, %{count: integer}} 75 | """ 76 | def count(%AuthToken{} = auth, params \\ [], options \\ []), 77 | do: 78 | REST.get( 79 | auth, 80 | "draft_orders/count.json", 81 | params, 82 | Keyword.merge([pagination: :none], options) 83 | ) 84 | 85 | @doc """ 86 | Send an invoice for a draft order 87 | 88 | ## Example 89 | 90 | iex> ShopifyAPI.REST.DraftOrder.send_invoice(auth, %{draft_order_invoice: %{}}) 91 | {:ok, %{...} = draft_order_invoice} 92 | 93 | To send the default invoice: 94 | %{ 95 | draft_order_invoice: %{} 96 | } 97 | 98 | To send a customized invoice: 99 | %{ 100 | draft_order_invoice: %{ 101 | to: "first@example.com", 102 | from: "steve@apple.com", 103 | bcc: [ 104 | "steve@apple.com" 105 | ], 106 | subject: "Apple Computer Invoice", 107 | custom_message: "Thank you for ordering!" 108 | } 109 | } 110 | """ 111 | def send_invoice( 112 | %AuthToken{} = auth, 113 | draft_order_id, 114 | %{draft_order_invoice: %{}} = draft_order_invoice 115 | ), 116 | do: 117 | REST.post( 118 | auth, 119 | "draft_orders/#{draft_order_id}/send_invoice.json", 120 | draft_order_invoice 121 | ) 122 | 123 | @doc """ 124 | Delete a draft order 125 | 126 | ## Example 127 | 128 | iex> ShopifyAPI.REST.DraftOrder.delete(auth, integer) 129 | {:ok, %{}} 130 | """ 131 | def delete(%AuthToken{} = auth, draft_order_id), 132 | do: REST.delete(auth, "draft_orders/#{draft_order_id}.json") 133 | 134 | @doc """ 135 | Complete a draft order 136 | 137 | ## Example 138 | 139 | To complete a draft order, marking it as paid: 140 | iex> ShopifyAPI.REST.DraftOrder.complete(auth, integer) 141 | 142 | To complete a draft order, marking it as pending: 143 | iex> ShopifyAPI.REST.DraftOrder.complete(auth, integer, %{"payment_pending" => true}) 144 | 145 | {:ok, %{...} = draft_order} 146 | """ 147 | def complete(%AuthToken{} = auth, draft_order_id, params \\ %{}), 148 | do: 149 | REST.put( 150 | auth, 151 | "draft_orders/#{draft_order_id}/complete.json?" <> URI.encode_query(params), 152 | %{} 153 | ) 154 | end 155 | -------------------------------------------------------------------------------- /lib/shopify_api/rest/event.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.REST.Event do 2 | @moduledoc """ 3 | ShopifyAPI REST API Event resource 4 | 5 | More via: https://help.shopify.com/api/reference/events/event#index 6 | """ 7 | 8 | alias ShopifyAPI.AuthToken 9 | alias ShopifyAPI.REST 10 | 11 | @doc """ 12 | Return a list of all Events. 13 | 14 | ## Example 15 | 16 | iex> ShopifyAPI.REST.Event.all(auth) 17 | {:ok, [%{}, ...] = events} 18 | """ 19 | def all(%AuthToken{} = auth, params \\ [], options \\ []), 20 | do: REST.get(auth, "events.json", params, options) 21 | 22 | @doc """ 23 | Get a single event. 24 | 25 | ## Example 26 | 27 | iex> ShopifyAPI.REST.Event.get(auth, integer) 28 | {:ok, %{} = event} 29 | """ 30 | def get(%AuthToken{} = auth, event_id, params \\ [], options \\ []), 31 | do: 32 | REST.get( 33 | auth, 34 | "events/#{event_id}.json", 35 | params, 36 | Keyword.merge([pagination: :none], options) 37 | ) 38 | 39 | @doc """ 40 | Get a count of all Events. 41 | 42 | ## Example 43 | 44 | iex> ShopifyAPI.REST.Event.count(auth) 45 | {:ok, integer = events} 46 | """ 47 | def count(%AuthToken{} = auth, params \\ [], options \\ []), 48 | do: REST.get(auth, "events/count.json", params, Keyword.merge([pagination: :none], options)) 49 | end 50 | -------------------------------------------------------------------------------- /lib/shopify_api/rest/fulfillment.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.REST.Fulfillment do 2 | @moduledoc """ 3 | ShopifyAPI REST API Fulfillment resource 4 | """ 5 | 6 | alias ShopifyAPI.AuthToken 7 | alias ShopifyAPI.REST 8 | 9 | @doc """ 10 | Return a list of all fulfillments. 11 | 12 | ## Example 13 | 14 | iex> ShopifyAPI.REST.Fulfillment.all(auth, string) 15 | {:ok, [%{}, ...] = fulfillments} 16 | """ 17 | def all(%AuthToken{} = auth, order_id, params \\ [], options \\ []), 18 | do: REST.get(auth, "orders/#{order_id}/fulfillments.json", params, options) 19 | 20 | @doc """ 21 | Return a count of all fulfillments. 22 | 23 | ## Example 24 | 25 | iex> ShopifyAPI.REST.Fulfillment.count(auth, string) 26 | {:ok, integer} 27 | """ 28 | def count(%AuthToken{} = auth, order_id, params \\ [], options \\ []), 29 | do: 30 | REST.get( 31 | auth, 32 | "orders/#{order_id}/fulfillments/count.json", 33 | params, 34 | Keyword.merge([pagination: :none], options) 35 | ) 36 | 37 | @doc """ 38 | Get a single fulfillment. 39 | 40 | ## Example 41 | 42 | iex> ShopifyAPI.REST.Fulfillment.get(auth, string, string) 43 | {:ok, %{} = fulfillment} 44 | """ 45 | def get(%AuthToken{} = auth, order_id, fulfillment_id, params \\ [], options \\ []), 46 | do: 47 | REST.get( 48 | auth, 49 | "orders/#{order_id}/fulfillments/#{fulfillment_id}.json", 50 | params, 51 | Keyword.merge([pagination: :none], options) 52 | ) 53 | 54 | @doc """ 55 | Create a new fulfillment. 56 | 57 | ## Example 58 | 59 | iex> ShopifyAPI.REST.Fulfillment.create(auth, string, map) 60 | {:ok, %{} = fulfillment} 61 | """ 62 | def create(%AuthToken{} = auth, order_id, %{fulfillment: %{}} = fulfillment, options \\ []) do 63 | REST.post(auth, "orders/#{order_id}/fulfillments.json", fulfillment, options) 64 | end 65 | 66 | @doc """ 67 | Update an existing fulfillment. 68 | 69 | ## Example 70 | 71 | iex> ShopifyAPI.REST.Fulfillment.update(auth, string, map) 72 | {:ok, %{} = fulfillment} 73 | """ 74 | def update( 75 | %AuthToken{} = auth, 76 | order_id, 77 | %{fulfillment: %{id: fulfillment_id}} = fulfillment 78 | ), 79 | do: REST.put(auth, "orders/#{order_id}/fulfillments/#{fulfillment_id}.json", fulfillment) 80 | 81 | @doc """ 82 | Complete a fulfillment. 83 | 84 | ## Example 85 | 86 | iex> ShopifyAPI.REST.Fulfillment.complete(auth, string, map) 87 | {:ok, %{} = fulfillment} 88 | """ 89 | def complete(%AuthToken{} = auth, order_id, %{fulfillment: %{id: fulfillment_id}} = fulfillment) do 90 | REST.post( 91 | auth, 92 | "orders/#{order_id}/fulfillments/#{fulfillment_id}/complete.json", 93 | fulfillment 94 | ) 95 | end 96 | 97 | @doc """ 98 | Open a fulfillment. 99 | 100 | ## Example 101 | 102 | iex> ShopifyAPI.REST.Fulfillment.open(auth, string) 103 | {:ok, %{} = fulfillment} 104 | """ 105 | def open(%AuthToken{} = auth, order_id, %{fulfillment: %{id: fulfillment_id}} = fulfillment) do 106 | REST.post( 107 | auth, 108 | "orders/#{order_id}/fulfillments/#{fulfillment_id}/open.json", 109 | fulfillment 110 | ) 111 | end 112 | 113 | @doc """ 114 | Cancel a fulfillment. 115 | 116 | ## Example 117 | 118 | iex> ShopifyAPI.REST.Fulfillment.cancel(auth, string) 119 | {:ok, %{} = fulfillment} 120 | """ 121 | def cancel(%AuthToken{} = auth, order_id, %{fulfillment: %{id: fulfillment_id}} = fulfillment) do 122 | REST.post( 123 | auth, 124 | "orders/#{order_id}/fulfillments/#{fulfillment_id}/cancel.json", 125 | fulfillment 126 | ) 127 | end 128 | end 129 | -------------------------------------------------------------------------------- /lib/shopify_api/rest/fulfillment_event.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.REST.FulfillmentEvent do 2 | @moduledoc """ 3 | ShopifyAPI REST API FulfillmentEvent resource 4 | """ 5 | 6 | alias ShopifyAPI.AuthToken 7 | alias ShopifyAPI.REST 8 | 9 | @doc """ 10 | Return a list of all fulfillment events. 11 | 12 | ## Example 13 | 14 | iex> ShopifyAPI.REST.FulfillmentEvent.all(auth, string, string) 15 | {:ok, [] = fulfillment_events} 16 | """ 17 | def all(%AuthToken{} = auth, order_id, fulfillment_id, params \\ [], options \\ []), 18 | do: 19 | REST.get( 20 | auth, 21 | "orders/#{order_id}/fulfillments/#{fulfillment_id}/events.json", 22 | params, 23 | Keyword.merge([pagination: :none], options) 24 | ) 25 | 26 | @doc """ 27 | Get a single fulfillment event. 28 | 29 | ## Example 30 | 31 | iex> ShopifyAPI.REST.FulfillmentEvent.get(auth, string, string, string) 32 | {:ok, %{} = fulfillment_event} 33 | """ 34 | def get(%AuthToken{} = auth, order_id, fulfillment_id, event_id, params \\ [], options \\ []), 35 | do: 36 | REST.get( 37 | auth, 38 | "orders/#{order_id}/fulfillments/#{fulfillment_id}/events/#{event_id}.json", 39 | params, 40 | Keyword.merge([pagination: :none], options) 41 | ) 42 | 43 | @doc """ 44 | Create a new fulfillment event. 45 | 46 | ## Example 47 | 48 | iex> ShopifyAPI.REST.FulfillmentEvent.post(auth, map) 49 | {:ok, %{} = fulfillment_event} 50 | """ 51 | def post( 52 | %AuthToken{} = auth, 53 | order_id, 54 | %{fulfillment_event: %{id: fulfillment_id}} = fulfillment_event 55 | ) do 56 | REST.post( 57 | auth, 58 | "orders/#{order_id}/fulfillments/#{fulfillment_id}/events.json", 59 | fulfillment_event 60 | ) 61 | end 62 | 63 | @doc """ 64 | Delete a fulfillment event. 65 | 66 | ## Example 67 | 68 | iex> ShopifyAPI.REST.FulfillmentEvent.delete(auth, string, string, string) 69 | {:ok, 200 } 70 | """ 71 | def delete(%AuthToken{} = auth, order_id, fulfillment_id, event_id) do 72 | REST.delete( 73 | auth, 74 | "orders/#{order_id}/fulfillments/#{fulfillment_id}/events/#{event_id}.json" 75 | ) 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /lib/shopify_api/rest/fulfillment_service.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.REST.FulfillmentService do 2 | @moduledoc """ 3 | ShopifyAPI REST API FulfillmentService resource 4 | """ 5 | 6 | alias ShopifyAPI.AuthToken 7 | alias ShopifyAPI.REST 8 | 9 | @doc """ 10 | Return a list of all the fulfillment services. 11 | 12 | ## Example 13 | 14 | iex> ShopifyAPI.REST.FulfillmentService.all(auth) 15 | {:ok, [] = fulfillment_services} 16 | """ 17 | def all(%AuthToken{} = auth, params \\ [], options \\ []), 18 | do: 19 | REST.get( 20 | auth, 21 | "fulfillment_services.json", 22 | params, 23 | Keyword.merge([pagination: :none], options) 24 | ) 25 | 26 | @doc """ 27 | Get a single fulfillment service. 28 | 29 | ## Example 30 | 31 | iex> ShopifyAPI.REST.FulfillmentService.get(auth, string) 32 | {:ok, %{} = fulfillment_service} 33 | """ 34 | def get(%AuthToken{} = auth, fulfillment_service_id, params \\ [], options \\ []), 35 | do: 36 | REST.get( 37 | auth, 38 | "fulfillment_services/#{fulfillment_service_id}.json", 39 | params, 40 | Keyword.merge([pagination: :none], options) 41 | ) 42 | 43 | @doc """ 44 | Create a new fulfillment service. 45 | 46 | ## Example 47 | 48 | iex> ShopifyAPI.REST.FulfillmentService.create(auth) 49 | {:ok, %{} = fulfillment_service} 50 | """ 51 | def create(%AuthToken{} = auth, %{fulfillment_service: %{}} = fulfillment_service), 52 | do: REST.post(auth, "fulfillment_services.json", fulfillment_service) 53 | 54 | @doc """ 55 | Update an existing fulfillment service. 56 | 57 | ## Example 58 | 59 | iex> ShopifyAPI.REST.FulfillmentService.update(auth) 60 | {:ok, %{} = fulfillment_service} 61 | """ 62 | def update( 63 | %AuthToken{} = auth, 64 | %{fulfillment_service: %{id: fulfillment_service_id}} = fulfillment_service 65 | ) do 66 | REST.put(auth, "fulfillment_services/#{fulfillment_service_id}.json", fulfillment_service) 67 | end 68 | 69 | @doc """ 70 | Delete a fulfillment service. 71 | 72 | ## Example 73 | 74 | iex> ShopifyAPI.REST.FulfillmentService.delete(auth, string) 75 | {:ok, 200 } 76 | """ 77 | def delete(%AuthToken{} = auth, fulfillment_service_id), 78 | do: REST.delete(auth, "fulfillment_services/#{fulfillment_service_id}.json") 79 | end 80 | -------------------------------------------------------------------------------- /lib/shopify_api/rest/inventory_item.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.REST.InventoryItem do 2 | @moduledoc """ 3 | ShopifyAPI REST API InventoryItem resource 4 | """ 5 | require Logger 6 | alias ShopifyAPI.AuthToken 7 | alias ShopifyAPI.REST 8 | 9 | @doc """ 10 | Return a list of inventory items. 11 | 12 | NOTE: Not implemented. 13 | 14 | ## Example 15 | 16 | iex> ShopifyAPI.REST.InventoryItem.all(auth) 17 | {:error, "Not implemented" } 18 | """ 19 | def all do 20 | Logger.warning("#{__MODULE__} error, resource not implemented.") 21 | {:error, "Not implemented"} 22 | end 23 | 24 | @doc """ 25 | Get a single inventory item by its ID. 26 | 27 | ## Example 28 | 29 | iex> ShopifyAPI.REST.InventoryItem.get(auth, integer) 30 | {:ok, %{} = inventory_item} 31 | """ 32 | def get(%AuthToken{} = auth, inventory_item_id, params \\ [], options \\ []), 33 | do: 34 | REST.get( 35 | auth, 36 | "inventory_items/#{inventory_item_id}.json", 37 | params, 38 | Keyword.merge([pagination: :none], options) 39 | ) 40 | 41 | @doc """ 42 | Update an existing inventory item. 43 | 44 | ## Example 45 | 46 | iex> ShopifyAPI.REST.InventoryItem.update(auth, map) 47 | {:ok, %{} = inventory_item} 48 | """ 49 | def update(%AuthToken{} = auth, %{inventory_item: %{id: inventory_item_id}} = inventory_item), 50 | do: REST.put(auth, "inventory_items/#{inventory_item_id}.json", inventory_item) 51 | end 52 | -------------------------------------------------------------------------------- /lib/shopify_api/rest/inventory_level.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.REST.InventoryLevel do 2 | @moduledoc """ 3 | ShopifyAPI REST API InventoryLevel resource 4 | """ 5 | alias ShopifyAPI.AuthToken 6 | alias ShopifyAPI.REST 7 | 8 | @doc """ 9 | Return a list of inventory levels. 10 | 11 | ## Example 12 | 13 | iex> ShopifyAPI.REST.InventoryLevel.all(auth) 14 | {:ok, [%{}, ...] = inventory_level} 15 | """ 16 | def all(%AuthToken{} = auth, params \\ [], options \\ []), 17 | do: REST.get(auth, "inventory_levels.json", params, options) 18 | 19 | @doc """ 20 | Sets the inventory level for an inventory item at a location. 21 | 22 | ## Example 23 | 24 | iex> ShopifyAPI.REST.InventoryLevel.set(auth, %{ inventory_level: %{inventory_item_id: integer, location_id: integer, available: integer}}) 25 | {:ok, [] = inventory_level} 26 | """ 27 | def set( 28 | %AuthToken{} = auth, 29 | %{ 30 | inventory_level: %{inventory_item_id: _, location_id: _, available: _} = inventory_level 31 | } 32 | ) do 33 | REST.post(auth, "inventory_levels/set.json", inventory_level) 34 | end 35 | 36 | @doc """ 37 | Delete an inventory level of an inventory item at a location. 38 | 39 | ## Example 40 | 41 | iex> ShopifyAPI.REST.InventoryLevel.delete(auth, integer, integer) 42 | {:ok, 200 }} 43 | """ 44 | def delete(%AuthToken{} = auth, inventory_item_id, location_id) do 45 | REST.delete( 46 | auth, 47 | "inventory_levels.json?inventory_item_id=#{inventory_item_id}&location_id=#{location_id}" 48 | ) 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/shopify_api/rest/location.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.REST.Location do 2 | @moduledoc """ 3 | ShopifyAPI REST API Location resource 4 | """ 5 | require Logger 6 | alias ShopifyAPI.AuthToken 7 | alias ShopifyAPI.REST 8 | 9 | @doc """ 10 | Return a list of locations. 11 | 12 | ## Example 13 | 14 | iex> ShopifyAPI.REST.Location.all(auth) 15 | {:ok, [] = locations} 16 | """ 17 | def all(%AuthToken{} = auth, params \\ [], options \\ []), 18 | do: REST.get(auth, "locations.json", params, Keyword.merge([pagination: :none], options)) 19 | 20 | @doc """ 21 | Return a single location. 22 | 23 | ## Example 24 | 25 | iex> ShopifyAPI.REST.Location.get(auth, integer) 26 | {:ok, %{} = location} 27 | """ 28 | def get(%AuthToken{} = auth, location_id, params \\ [], options \\ []), 29 | do: 30 | REST.get( 31 | auth, 32 | "locations/#{location_id}.json", 33 | params, 34 | Keyword.merge([pagination: :none], options) 35 | ) 36 | 37 | @doc """ 38 | Return a count of locations. 39 | 40 | ## Example 41 | 42 | iex> ShopifyAPI.REST.Location.count(auth) 43 | {:ok, integer = count} 44 | """ 45 | def count(%AuthToken{} = auth, params \\ []), do: REST.get(auth, "locations/count.json", params) 46 | 47 | @doc """ 48 | Returns a list of inventory levels for a location. 49 | 50 | ## Example 51 | 52 | iex> ShopifyAPI.REST.Location.inventory_levels(auth, integer) 53 | {:ok, %{} = inventory_levels} 54 | """ 55 | def inventory_levels(%AuthToken{} = auth, location_id, params \\ [], options \\ []), 56 | do: REST.get(auth, "locations/#{location_id}/inventory_levels.json", params, options) 57 | end 58 | -------------------------------------------------------------------------------- /lib/shopify_api/rest/marketing_event.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.REST.MarketingEvent do 2 | @moduledoc """ 3 | ShopifyAPI REST API MarketingEvent resource 4 | """ 5 | 6 | require Logger 7 | alias ShopifyAPI.AuthToken 8 | alias ShopifyAPI.REST 9 | 10 | @doc """ 11 | Return a list of all marketing events. 12 | 13 | ## Example 14 | 15 | iex> ShopifyAPI.REST.MarketingEvent.all(auth) 16 | {:ok, [] = marketing_events} 17 | """ 18 | def all(%AuthToken{} = auth, params \\ [], options \\ []), 19 | do: REST.get(auth, "marketing_events.json", params, options) 20 | 21 | @doc """ 22 | Get a count of all marketing events. 23 | 24 | ## Example 25 | 26 | iex> ShopifyAPI.REST.MarketingEvent.count(auth) 27 | {:ok, integer = count} 28 | """ 29 | def count(%AuthToken{} = auth, params \\ [], options \\ []), 30 | do: 31 | REST.get( 32 | auth, 33 | "marketing_events/count.json", 34 | params, 35 | Keyword.merge([pagination: :none], options) 36 | ) 37 | 38 | @doc """ 39 | Get a single marketing event. 40 | 41 | ## Example 42 | 43 | iex> ShopifyAPI.REST.MarketingEvent.get(auth, integer) 44 | {:ok, %{} = marketing_event} 45 | """ 46 | def get(%AuthToken{} = auth, marketing_event_id, params \\ [], options \\ []), 47 | do: 48 | REST.get( 49 | auth, 50 | "marketing_events/#{marketing_event_id}.json", 51 | params, 52 | Keyword.merge([pagination: :none], options) 53 | ) 54 | 55 | @doc """ 56 | Create a marketing event. 57 | 58 | ## Example 59 | 60 | iex> ShopifyAPI.REST.MarketingEvent.create(auth, map) 61 | {:ok, %{} = marketing_event} 62 | """ 63 | def create( 64 | %AuthToken{} = auth, 65 | %{marketing_event: %{id: marketing_event_id}} = marketing_event 66 | ), 67 | do: REST.post(auth, "marketing_events/#{marketing_event_id}.json", marketing_event) 68 | 69 | @doc """ 70 | Update a marketing event. 71 | 72 | ## Example 73 | 74 | iex> ShopifyAPI.REST.MarketingEvent.update(auth, map) 75 | {:ok, %{} = marketing_event} 76 | """ 77 | def update( 78 | %AuthToken{} = auth, 79 | %{marketing_event: %{id: marketing_event_id}} = marketing_event 80 | ), 81 | do: REST.put(auth, "marketing_events/#{marketing_event_id}.json", marketing_event) 82 | 83 | @doc """ 84 | Delete a marketing event. 85 | 86 | ## Example 87 | 88 | iex> ShopifyAPI.REST.MarketingEvent.delete(auth) 89 | {:ok, 200 } 90 | """ 91 | def delete(%AuthToken{} = auth, marketing_event_id), 92 | do: REST.delete(auth, "marketing_events/#{marketing_event_id}.json") 93 | 94 | @doc """ 95 | Creates a marketing engagements on a marketing event. 96 | 97 | NOTE: Not implemented. 98 | 99 | ## Example 100 | 101 | iex> ShopifyAPI.REST.MarketingEvent.create_engagement() 102 | {:error, "Not implemented" } 103 | """ 104 | def create_engagement do 105 | Logger.warning("#{__MODULE__} error, resource not implemented.") 106 | {:error, "Not implemented"} 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /lib/shopify_api/rest/order.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.REST.Order do 2 | @moduledoc """ 3 | """ 4 | alias ShopifyAPI.AuthToken 5 | alias ShopifyAPI.REST 6 | 7 | @shopify_per_page_max 250 8 | 9 | @doc """ 10 | Return a single Order. 11 | """ 12 | def get(%AuthToken{} = auth, order_id, params \\ [], options \\ []), 13 | do: 14 | REST.get( 15 | auth, 16 | "orders/#{order_id}.json", 17 | params, 18 | Keyword.merge([pagination: :none], options) 19 | ) 20 | 21 | @doc """ 22 | Return all of a shops Orders filtered by query parameters. 23 | 24 | iex> ShopifyAPI.REST.Order.all(token) 25 | iex> ShopifyAPI.REST.Order.all(auth, [param1: "value", param2: "value2"]) 26 | """ 27 | def all(%AuthToken{} = auth, params \\ [], options \\ []), 28 | do: REST.get(auth, "orders.json", params, options) 29 | 30 | @doc """ 31 | Delete an Order. 32 | 33 | iex> ShopifyAPI.REST.Order.delete(auth, order_id) 34 | """ 35 | def delete(%AuthToken{} = auth, order_id), do: REST.delete(auth, "orders/#{order_id}.json") 36 | 37 | @doc """ 38 | Create a new Order. 39 | 40 | iex> ShopifyAPI.REST.Order.create(auth, %Order{}) 41 | """ 42 | def create(%AuthToken{} = auth, %{order: %{}} = order), 43 | do: REST.post(auth, "orders.json", order) 44 | 45 | @doc """ 46 | Update an Order. 47 | 48 | iex> ShopifyAPI.REST.Order.update(auth, order_id) 49 | """ 50 | def update(%AuthToken{} = auth, %{order: %{id: order_id}} = order), 51 | do: REST.put(auth, "orders/#{order_id}.json", order) 52 | 53 | @doc """ 54 | Return a count of all Orders. 55 | 56 | iex> ShopifyAPI.REST.Order.get(token) 57 | {:ok, integer = count} 58 | """ 59 | def count(%AuthToken{} = auth, params \\ [], options \\ []), 60 | do: REST.get(auth, "orders/count.json", params, Keyword.merge([pagination: :none], options)) 61 | 62 | @doc """ 63 | Close an Order. 64 | 65 | iex> ShopifyAPI.REST.Order.close(auth, order_id) 66 | """ 67 | def close(%AuthToken{} = auth, order_id), 68 | do: REST.post(auth, "orders/#{order_id}/close.json") 69 | 70 | @doc """ 71 | Re-open a closed Order. 72 | 73 | iex> ShopifyAPI.REST.Order.open(auth, order_id) 74 | """ 75 | def open(%AuthToken{} = auth, order_id), do: REST.post(auth, "orders/#{order_id}/open.json") 76 | 77 | @doc """ 78 | Cancel an Order. 79 | 80 | iex> ShopifyAPI.REST.Order.cancel(auth, order_id) 81 | """ 82 | def cancel(%AuthToken{} = auth, order_id), 83 | do: REST.post(auth, "orders/#{order_id}/cancel.json") 84 | 85 | def max_per_page, do: @shopify_per_page_max 86 | end 87 | -------------------------------------------------------------------------------- /lib/shopify_api/rest/price_rule.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.REST.PriceRule do 2 | @moduledoc """ 3 | ShopifyAPI REST API PriceRule resource 4 | """ 5 | 6 | alias ShopifyAPI.AuthToken 7 | alias ShopifyAPI.REST 8 | 9 | @doc """ 10 | Create a price rule. 11 | 12 | ## Example 13 | 14 | iex> ShopifyAPI.REST.PriceRule.create(auth, map) 15 | {:ok, %{} = price_rule} 16 | """ 17 | def create(%AuthToken{} = auth, %{price_rule: %{}} = price_rule), 18 | do: REST.post(auth, "price_rules.json", price_rule) 19 | 20 | @doc """ 21 | Update an existing price rule. 22 | 23 | ## Example 24 | 25 | iex> ShopifyAPI.REST.PriceRule.update(auth, map) 26 | {:ok, %{} = price_rule} 27 | """ 28 | def update(%AuthToken{} = auth, %{price_rule: %{id: price_rule_id}} = price_rule), 29 | do: REST.put(auth, "price_rules/#{price_rule_id}.json", price_rule) 30 | 31 | @doc """ 32 | Return a list of all price rules. 33 | 34 | ## Example 35 | 36 | iex> ShopifyAPI.REST.PriceRule.all(auth) 37 | {:ok, [] = price_rules} 38 | """ 39 | def all(%AuthToken{} = auth, params \\ [], options \\ []), 40 | do: REST.get(auth, "price_rules.json", params, options) 41 | 42 | @doc """ 43 | Get a single price rule. 44 | 45 | ## Example 46 | 47 | iex> ShopifyAPI.REST.PriceRule.get(auth, integer) 48 | {:ok, %{} = price_rule} 49 | """ 50 | def get(%AuthToken{} = auth, price_rule_id, params \\ [], options \\ []), 51 | do: 52 | REST.get( 53 | auth, 54 | "price_rules/#{price_rule_id}.json", 55 | params, 56 | Keyword.merge([pagination: :none], options) 57 | ) 58 | 59 | @doc """ 60 | Delete a price rule. 61 | 62 | ## Example 63 | 64 | iex> ShopifyAPI.REST.PriceRule.delete(auth, string) 65 | {:ok, 204 }} 66 | """ 67 | def delete(%AuthToken{} = auth, price_rule_id), 68 | do: REST.delete(auth, "price_rules/#{price_rule_id}.json") 69 | end 70 | -------------------------------------------------------------------------------- /lib/shopify_api/rest/product.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.REST.Product do 2 | @moduledoc """ 3 | Shopify REST API Product resources 4 | """ 5 | 6 | alias ShopifyAPI.AuthToken 7 | alias ShopifyAPI.REST 8 | 9 | @doc """ 10 | Get a list of all the products. 11 | 12 | ## Example 13 | 14 | iex> ShopifyAPI.REST.Product.all(auth) 15 | {:ok, [] = products} 16 | """ 17 | def all(%AuthToken{} = auth, params \\ [], options \\ []), 18 | do: REST.get(auth, "products.json", params, options) 19 | 20 | @doc """ 21 | Return a single product. 22 | 23 | ## Example 24 | 25 | iex> ShopifyAPI.REST.Product.get(auth, integer) 26 | {:ok, %{} = product} 27 | """ 28 | def get(%AuthToken{} = auth, product_id, params \\ [], options \\ []), 29 | do: 30 | REST.get( 31 | auth, 32 | "products/#{product_id}.json", 33 | params, 34 | Keyword.merge([pagination: :none], options) 35 | ) 36 | 37 | @doc """ 38 | Return a count of products. 39 | 40 | ## Example 41 | 42 | iex> ShopifyAPI.REST.Product.count(auth) 43 | {:ok, integer = count} 44 | """ 45 | def count(%AuthToken{} = auth, params \\ [], options \\ []), 46 | do: REST.get(auth, "products/count.json", params, Keyword.merge([pagination: :none], options)) 47 | 48 | @doc """ 49 | Update a product. 50 | 51 | ## Example 52 | 53 | iex> ShopifyAPI.REST.Product.update(auth, map) 54 | {:ok, %{} = product} 55 | """ 56 | def update(%AuthToken{} = auth, %{"product" => %{"id" => product_id} = product}), 57 | do: update(auth, %{product: Map.put(product, :id, product_id)}) 58 | 59 | def update(%AuthToken{} = auth, %{product: %{id: product_id}} = product), 60 | do: REST.put(auth, "products/#{product_id}.json", product) 61 | 62 | @doc """ 63 | Delete a product. 64 | 65 | ## Example 66 | 67 | iex> ShopifyAPI.REST.Product.delete(auth, integer) 68 | {:ok, 200 } 69 | """ 70 | def delete(%AuthToken{} = auth, product_id), 71 | do: REST.delete(auth, "products/#{product_id}.json") 72 | 73 | @doc """ 74 | Create a new product. 75 | 76 | ## Example 77 | 78 | iex> ShopifyAPI.REST.Product.create(auth, map) 79 | {:ok, %{} = product} 80 | """ 81 | def create(%AuthToken{} = auth, %{"product" => %{} = product}), 82 | do: create(auth, %{product: product}) 83 | 84 | def create(%AuthToken{} = auth, %{product: %{}} = product), 85 | do: REST.post(auth, "products.json", product) 86 | end 87 | -------------------------------------------------------------------------------- /lib/shopify_api/rest/product_image.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.REST.ProductImage do 2 | @moduledoc """ 3 | ShopifyApi REST API Product Image resource 4 | """ 5 | 6 | alias ShopifyAPI.AuthToken 7 | alias ShopifyAPI.REST 8 | 9 | @doc """ 10 | Return a list of all products images. 11 | 12 | ## Example 13 | 14 | iex> ShopifyApi.Rest.ProductImage.all(auth, integer) 15 | {:ok, [] = images} 16 | """ 17 | def all(%AuthToken{} = auth, product_id, params \\ [], options \\ []), 18 | do: 19 | REST.get( 20 | auth, 21 | "products/#{product_id}/images.json", 22 | params, 23 | Keyword.merge([pagination: :none], options) 24 | ) 25 | 26 | @doc """ 27 | Get a count of all product images. 28 | 29 | ## Example 30 | 31 | iex> ShopifyApi.Rest.ProductImage.count(auth) 32 | {:ok, integer = count} 33 | """ 34 | def count(%AuthToken{} = auth, product_id, params \\ [], options \\ []), 35 | do: 36 | REST.get( 37 | auth, 38 | "products/#{product_id}/images/count.json", 39 | params, 40 | Keyword.merge([pagination: :none], options) 41 | ) 42 | 43 | @doc """ 44 | Get all images for a single product. 45 | 46 | ## Example 47 | 48 | iex> ShopifyApi.Rest.ProductImage.get(auth, map) 49 | {:ok, %{} = image} 50 | """ 51 | @deprecated "Duplicate of all/2" 52 | def get(%AuthToken{} = auth, product_id), 53 | do: REST.get(auth, "products/#{product_id}/images.json") 54 | 55 | @doc """ 56 | Get a single product image. 57 | 58 | ## Example 59 | 60 | iex> ShopifyApi.Rest.ProductImage.get(auth, map) 61 | {:ok, %{} = image} 62 | """ 63 | def get(%AuthToken{} = auth, product_id, image_id, params \\ [], options \\ []), 64 | do: 65 | REST.get( 66 | auth, 67 | "products/#{product_id}/images/#{image_id}.json", 68 | params, 69 | Keyword.merge([pagination: :none], options) 70 | ) 71 | 72 | @doc """ 73 | Create a new product image. 74 | 75 | ## Example 76 | 77 | iex> ShopifyApi.Rest.ProductImage.create(auth, integer, map) 78 | {:ok, %{} = image} 79 | """ 80 | def create(%AuthToken{} = auth, product_id, %{image: %{}} = image), 81 | do: REST.post(auth, "products/#{product_id}/images.json", image) 82 | 83 | @doc """ 84 | Update an existing product image. 85 | 86 | ## Example 87 | 88 | iex> ShopifyApi.Rest.ProductImage.update(auth, integer, map) 89 | {:ok, %{} = image} 90 | """ 91 | def update(%AuthToken{} = auth, product_id, %{image: %{id: image_id}} = image), 92 | do: REST.put(auth, "products/#{product_id}/images/#{image_id}.json", image) 93 | 94 | @doc """ 95 | Delete a product image. 96 | 97 | ## Example 98 | 99 | iex> ShopifyApi.Rest.ProductImage.delete(auth, integer, integer) 100 | {:ok, 200 }} 101 | """ 102 | def delete(%AuthToken{} = auth, product_id, image_id), 103 | do: REST.delete(auth, "products/#{product_id}/images/#{image_id}.json") 104 | end 105 | -------------------------------------------------------------------------------- /lib/shopify_api/rest/recurring_application_charge.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.REST.RecurringApplicationCharge do 2 | @moduledoc """ 3 | ShopifyAPI REST API Recurring Application Charge resource 4 | """ 5 | 6 | require Logger 7 | alias ShopifyAPI.AuthToken 8 | alias ShopifyAPI.REST 9 | 10 | @doc """ 11 | Create a recurring application charge. 12 | 13 | ## Example 14 | 15 | iex> ShopifyAPI.REST.RecurringApplicationCharge.create(auth, map) 16 | {:ok, %{} = recurring_application_charge} 17 | """ 18 | def create( 19 | %AuthToken{} = auth, 20 | %{recurring_application_charge: %{}} = recurring_application_charge 21 | ) do 22 | REST.post(auth, "recurring_application_charges.json", recurring_application_charge) 23 | end 24 | 25 | @doc """ 26 | Get a single charge. 27 | 28 | ## Example 29 | 30 | iex> ShopifyAPI.REST.RecurringApplicationCharge.get(auth, integer) 31 | {:ok, %{} = recurring_application_charge} 32 | """ 33 | def get(%AuthToken{} = auth, recurring_application_charge_id, params \\ [], options \\ []) do 34 | REST.get( 35 | auth, 36 | "recurring_application_charges/#{recurring_application_charge_id}.json", 37 | params, 38 | Keyword.merge([pagination: :none], options) 39 | ) 40 | end 41 | 42 | @doc """ 43 | Get a list of all recurring application charges. 44 | 45 | ## Example 46 | 47 | iex> ShopifyAPI.REST.RecurringApplicationCharge.all(auth) 48 | {:ok, [] = recurring_application_charges} 49 | """ 50 | def all(%AuthToken{} = auth, params \\ [], options \\ []), 51 | do: 52 | REST.get( 53 | auth, 54 | "recurring_application_charges.json", 55 | params, 56 | Keyword.merge([pagination: :none], options) 57 | ) 58 | 59 | @doc """ 60 | Activates a recurring application charge. 61 | 62 | ## Example 63 | 64 | iex> ShopifyAPI.REST.RecurringApplicationCharge.activate(auth, integer) 65 | {:ok, %{} = recurring_application_charge} 66 | """ 67 | def activate(%AuthToken{} = auth, recurring_application_charge_id) do 68 | REST.post( 69 | auth, 70 | "recurring_application_charges/#{recurring_application_charge_id}/activate.json" 71 | ) 72 | end 73 | 74 | @doc """ 75 | Cancels a recurring application charge. 76 | 77 | ## Example 78 | 79 | iex> ShopifyAPI.REST.RecurringApplicationCharge.cancel(auth, integer) 80 | {:ok, 200 } 81 | """ 82 | def cancel(%AuthToken{} = auth, recurring_application_charge_id), 83 | do: 84 | REST.delete( 85 | auth, 86 | "recurring_application_charges/#{recurring_application_charge_id}.json" 87 | ) 88 | 89 | @doc """ 90 | Updates a capped amount of recurring application charge. 91 | 92 | ## Example 93 | 94 | iex> ShopifyAPI.REST.RecurringApplicationCharge.update() 95 | {:error, "Not implemented" } 96 | """ 97 | def update do 98 | Logger.warning("#{__MODULE__} error, resource not implemented.") 99 | {:error, "Not implemented"} 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /lib/shopify_api/rest/redirect.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.REST.Redirect do 2 | @moduledoc """ 3 | Shopify REST API Redirect resources 4 | """ 5 | 6 | alias ShopifyAPI.AuthToken 7 | alias ShopifyAPI.REST 8 | 9 | @doc """ 10 | Get a list of all the redirects. 11 | 12 | ## Example 13 | 14 | iex> ShopifyAPI.REST.Redirect.all(auth) 15 | {:ok, [] = redirects} 16 | """ 17 | def all(%AuthToken{} = auth, params \\ [], options \\ []), 18 | do: REST.get(auth, "redirects.json", params, options) 19 | 20 | @doc """ 21 | Return a single redirect. 22 | 23 | ## Example 24 | 25 | iex> ShopifyAPI.REST.Redirect.get(auth, integer) 26 | {:ok, %{} = redirect} 27 | """ 28 | def get(%AuthToken{} = auth, redirect_id, params \\ [], options \\ []), 29 | do: 30 | REST.get( 31 | auth, 32 | "redirects/#{redirect_id}.json", 33 | params, 34 | Keyword.merge([pagination: :none], options) 35 | ) 36 | 37 | @doc """ 38 | Return a count of redirects. 39 | 40 | ## Example 41 | 42 | iex> ShopifyAPI.REST.Redirect.count(auth) 43 | {:ok, integer = count} 44 | """ 45 | def count(%AuthToken{} = auth, params \\ [], options \\ []), 46 | do: 47 | REST.get(auth, "redirects/count.json", params, Keyword.merge([pagination: :none], options)) 48 | 49 | @doc """ 50 | Update a redirect. 51 | 52 | ## Expected Shape 53 | 54 | ### Request Redirect Map Shape Example 55 | %{ 56 | id: 668809255, 57 | path: "/tiger" 58 | } 59 | 60 | ### Response Map Shape Example 61 | %{ 62 | "id": 668809255, 63 | "path": "/tiger", 64 | "target": "/pages/macosx" 65 | } 66 | 67 | ## Example 68 | 69 | iex> ShopifyAPI.REST.Redirect.update(auth, map) 70 | {:ok, %{} = redirect} 71 | """ 72 | def update(%AuthToken{} = auth, %{"redirect" => %{"id" => redirect_id} = redirect}), 73 | do: update(auth, %{redirect: Map.put(redirect, :id, redirect_id)}) 74 | 75 | def update(%AuthToken{} = auth, %{redirect: %{id: redirect_id}} = redirect), 76 | do: REST.put(auth, "redirects/#{redirect_id}.json", redirect) 77 | 78 | @doc """ 79 | Delete a redirect. 80 | 81 | ## Example 82 | 83 | iex> ShopifyAPI.REST.Redirect.delete(auth, integer) 84 | {:ok, 200 } 85 | """ 86 | def delete(%AuthToken{} = auth, redirect_id), 87 | do: REST.delete(auth, "redirects/#{redirect_id}.json") 88 | 89 | @doc """ 90 | Create a new redirect. 91 | 92 | ## Expected Shape 93 | 94 | ###Request Redirect Map Shape Example 95 | %{ 96 | path: "/ipod", 97 | target: "/pages/itunes" 98 | } 99 | 100 | ### Response Map Shape Example 101 | %{ 102 | "id": 979034144, 103 | "path": "/ipod", 104 | "target": "/pages/itunes" 105 | } 106 | 107 | ## Example 108 | 109 | iex> ShopifyAPI.REST.Redirect.create(auth, map) 110 | {:ok, %{} = redirect} 111 | """ 112 | def create(%AuthToken{} = auth, %{"redirect" => %{} = redirect}), 113 | do: create(auth, %{redirect: redirect}) 114 | 115 | def create(%AuthToken{} = auth, %{redirect: %{}} = redirect), 116 | do: REST.post(auth, "redirects.json", redirect) 117 | end 118 | -------------------------------------------------------------------------------- /lib/shopify_api/rest/refund.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.REST.Refund do 2 | @moduledoc """ 3 | ShopifyAPI REST API Refund resource 4 | """ 5 | 6 | alias ShopifyAPI.AuthToken 7 | alias ShopifyAPI.REST 8 | 9 | @doc """ 10 | Return a list of all refunds for an order. 11 | 12 | ## Example 13 | 14 | iex> ShopifyAPI.REST.Refund.all(auth, string) 15 | {:ok, [] = refunds} 16 | """ 17 | def all(%AuthToken{} = auth, order_id, params \\ [], options \\ []), 18 | do: REST.get(auth, "orders/#{order_id}/refunds.json", params, options) 19 | 20 | @doc """ 21 | Get a specific refund. 22 | 23 | ## Example 24 | 25 | iex> ShopifyAPI.REST.Refund.get(auth, string, string) 26 | {:ok, %{} = refund} 27 | """ 28 | def get(%AuthToken{} = auth, order_id, refund_id, params \\ [], options \\ []), 29 | do: 30 | REST.get( 31 | auth, 32 | "orders/#{order_id}/refunds/#{refund_id}.json", 33 | params, 34 | Keyword.merge([pagination: :none], options) 35 | ) 36 | 37 | @doc """ 38 | Calculate a refund. 39 | 40 | ## Example 41 | 42 | iex> ShopifyAPI.REST.Refund.calculate(auth, integer, map) 43 | {:ok, %{} = refund} 44 | """ 45 | def calculate(%AuthToken{} = auth, order_id, %{refund: %{}} = refund), 46 | do: REST.post(auth, "orders/#{order_id}/refunds/calculate.json", refund) 47 | 48 | @doc """ 49 | Create a refund. 50 | 51 | ## Example 52 | 53 | iex> ShopifyAPI.REST.Refund.create(auth, integer, map) 54 | {:ok, %{} = refund} 55 | """ 56 | def create(%AuthToken{} = auth, order_id, %{refund: %{}} = refund), 57 | do: REST.post(auth, "orders/#{order_id}/refunds.json", refund) 58 | end 59 | -------------------------------------------------------------------------------- /lib/shopify_api/rest/report.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.REST.Report do 2 | @moduledoc """ 3 | ShopifyAPI REST API Report resource 4 | """ 5 | 6 | alias ShopifyAPI.AuthToken 7 | alias ShopifyAPI.REST 8 | 9 | @doc """ 10 | Get a list of all reports. 11 | 12 | ## Example 13 | 14 | iex> ShopifyAPI.REST.Report.all(auth) 15 | {:ok, [] = reports} 16 | """ 17 | def all(%AuthToken{} = auth, params \\ [], options \\ []), 18 | do: REST.get(auth, "reports.json", params, options) 19 | 20 | @doc """ 21 | Return a single report. 22 | 23 | ## Example 24 | 25 | iex> ShopifyAPI.REST.Report.get(auth, integer) 26 | {:ok, %{} = report} 27 | """ 28 | def get(%AuthToken{} = auth, report_id, params \\ [], options \\ []), 29 | do: 30 | REST.get( 31 | auth, 32 | "reports/#{report_id}.json", 33 | params, 34 | Keyword.merge([pagination: :none], options) 35 | ) 36 | 37 | @doc """ 38 | Create a new report. 39 | 40 | ## Example 41 | 42 | iex> ShopifyAPI.REST.Report.create(auth, map) 43 | {:ok, %{} = report} 44 | """ 45 | def create(%AuthToken{} = auth, %{report: %{}} = report), 46 | do: REST.post(auth, "reports.json", report) 47 | 48 | @doc """ 49 | Update a report. 50 | 51 | ## Example 52 | 53 | iex> ShopifyAPI.REST.Report.update(auth, map) 54 | {:ok, %{} = report} 55 | """ 56 | def update(%AuthToken{} = auth, %{report: %{id: report_id}} = report), 57 | do: REST.put(auth, "reports/#{report_id}.json", report) 58 | 59 | @doc """ 60 | Delete. 61 | 62 | ## Example 63 | 64 | iex> ShopifyAPI.REST.Report.Delete(auth) 65 | {:ok, 200 }} 66 | """ 67 | def delete(%AuthToken{} = auth, report_id), 68 | do: REST.delete(auth, "reports/#{report_id}.json") 69 | end 70 | -------------------------------------------------------------------------------- /lib/shopify_api/rest/shop.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.REST.Shop do 2 | @moduledoc """ 3 | ShopifyAPI REST API Shop resource 4 | """ 5 | 6 | alias ShopifyAPI.AuthToken 7 | alias ShopifyAPI.REST 8 | 9 | @doc """ 10 | Get a shop's configuration. 11 | 12 | ## Example 13 | 14 | iex> ShopifyAPI.REST.Shop.get(auth) 15 | {:ok, %{} = shop} 16 | """ 17 | def get(%AuthToken{} = auth, params \\ [], options \\ []), 18 | do: REST.get(auth, "shop.json", params, Keyword.merge([pagination: :none], options)) 19 | end 20 | -------------------------------------------------------------------------------- /lib/shopify_api/rest/smart_collection.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.REST.SmartCollection do 2 | @moduledoc """ 3 | Shopify REST API Smart Collection resources 4 | """ 5 | 6 | alias ShopifyAPI.AuthToken 7 | alias ShopifyAPI.REST 8 | 9 | @doc """ 10 | Get a list of all SmartCollections. 11 | 12 | ## Example 13 | iex> ShopifyAPI.REST.SmartCollection.all(token) 14 | {:ok, [] = smart_collections} 15 | """ 16 | def all(%AuthToken{} = auth, params \\ [], options \\ []), 17 | do: REST.get(auth, "smart_collections.json", params, options) 18 | 19 | @doc """ 20 | Get a count of all SmartCollections. 21 | 22 | ## Example 23 | iex> ShopifyAPI.REST.SmartCollection.count(token) 24 | {:ok,teger = count} 25 | """ 26 | def count(%AuthToken{} = auth, params \\ [], options \\ []), 27 | do: 28 | REST.get( 29 | auth, 30 | "smart_collections/count.json", 31 | params, 32 | Keyword.merge([pagination: :none], options) 33 | ) 34 | 35 | @doc """ 36 | Return a single SmartCollection. 37 | 38 | ## Example 39 | iex> ShopifyAPI.REST.SmartCollection.get(auth, string) 40 | {:ok, %{} = smart_collection} 41 | """ 42 | def get(%AuthToken{} = auth, smart_collection_id, params \\ [], options \\ []), 43 | do: 44 | REST.get( 45 | auth, 46 | "smart_collections/#{smart_collection_id}.json", 47 | params, 48 | Keyword.merge([pagination: :none], options) 49 | ) 50 | 51 | @doc """ 52 | Create a SmartCollection. 53 | 54 | ## Example 55 | iex> ShopifyAPI.REST.SmartCollection.create(auth, map) 56 | {:ok, %{} = smart_collection} 57 | """ 58 | def create(%AuthToken{} = auth, %{smart_collection: %{}} = smart_collection), 59 | do: REST.post(auth, "smart_collections.json", smart_collection) 60 | 61 | @doc """ 62 | Update an existing SmartCollection. 63 | 64 | ## Example 65 | iex> ShopifyAPI.REST.SmartCollection.update(auth, map) 66 | {:ok, %{} = smart_collection} 67 | """ 68 | def update( 69 | %AuthToken{} = auth, 70 | %{smart_collection: %{id: smart_collection_id}} = smart_collection 71 | ), 72 | do: REST.put(auth, "smart_collections/#{smart_collection_id}.json", smart_collection) 73 | 74 | @doc """ 75 | Delete a SmartCollection. 76 | 77 | ## Example 78 | iex> ShopifyAPI.REST.SmartCollection.delete(auth, string) 79 | {:ok, %{ "response": 200 }} 80 | """ 81 | def delete(%AuthToken{} = auth, smart_collection_id), 82 | do: REST.delete(auth, "smart_collections/#{smart_collection_id}.json") 83 | end 84 | -------------------------------------------------------------------------------- /lib/shopify_api/rest/tender_transaction.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.REST.TenderTransaction do 2 | @moduledoc """ 3 | """ 4 | alias ShopifyAPI.AuthToken 5 | alias ShopifyAPI.REST 6 | 7 | @shopify_per_page_max 250 8 | 9 | @doc """ 10 | Return all the Tender Transactions. 11 | """ 12 | def all(%AuthToken{} = auth, params \\ [], options \\ []), 13 | do: REST.get(auth, "tender_transactions.json", params, options) 14 | 15 | def max_per_page, do: @shopify_per_page_max 16 | end 17 | -------------------------------------------------------------------------------- /lib/shopify_api/rest/theme.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.REST.Theme do 2 | @moduledoc """ 3 | ShopifyAPI REST API Theme resource 4 | """ 5 | 6 | alias ShopifyAPI.AuthToken 7 | alias ShopifyAPI.REST 8 | 9 | @doc """ 10 | Get a single theme. 11 | 12 | ## Example 13 | 14 | iex> ShopifyAPI.REST.Theme.get(auth, integer) 15 | {:ok, %{} = theme} 16 | """ 17 | def get(%AuthToken{} = auth, theme_id, params \\ [], options \\ []), 18 | do: 19 | REST.get( 20 | auth, 21 | "themes/#{theme_id}.json", 22 | params, 23 | Keyword.merge([pagination: :none], options) 24 | ) 25 | 26 | @doc """ 27 | Return a list of all themes. 28 | 29 | ## Example 30 | 31 | iex> ShopifyAPI.REST.Theme.all(auth) 32 | {:ok, [] = themes} 33 | """ 34 | def all(%AuthToken{} = auth, params \\ [], options \\ []), 35 | do: REST.get(auth, "themes.json", params, Keyword.merge([pagination: :none], options)) 36 | 37 | @doc """ 38 | Update a theme. 39 | 40 | ## Example 41 | 42 | iex> ShopifyAPI.REST.Theme.update(auth, map) 43 | {:ok, %{} = theme} 44 | """ 45 | def update(%AuthToken{} = auth, %{"theme" => %{"id" => theme_id} = theme}), 46 | do: update(auth, %{theme: Map.put(theme, :id, theme_id)}) 47 | 48 | def update(%AuthToken{} = auth, %{theme: %{id: theme_id}} = theme), 49 | do: REST.put(auth, "themes/#{theme_id}.json", theme) 50 | 51 | @doc """ 52 | Delete a theme. 53 | 54 | ## Example 55 | 56 | iex> ShopifyAPI.REST.Theme.delete(auth, integer) 57 | {:ok, 200 } 58 | """ 59 | def delete(%AuthToken{} = auth, theme_id), 60 | do: REST.delete(auth, "themes/#{theme_id}.json") 61 | 62 | @doc """ 63 | Create a new theme. 64 | 65 | ## Example 66 | 67 | iex> ShopifyAPI.REST.Theme.create(auth, map) 68 | {:ok, %{} = theme} 69 | """ 70 | def create(%AuthToken{} = auth, %{"theme" => %{} = theme}), 71 | do: create(auth, %{theme: theme}) 72 | 73 | def create(%AuthToken{} = auth, %{theme: %{}} = theme), 74 | do: REST.post(auth, "themes.json", theme) 75 | end 76 | -------------------------------------------------------------------------------- /lib/shopify_api/rest/transaction.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.REST.Transaction do 2 | @moduledoc """ 3 | """ 4 | alias ShopifyAPI.AuthToken 5 | alias ShopifyAPI.REST 6 | 7 | @doc """ 8 | Return all the Transactions for an Order. 9 | """ 10 | def all(%AuthToken{} = auth, order_id, params \\ [], options \\ []), 11 | do: REST.get(auth, "orders/#{order_id}/transactions.json", params, options) 12 | 13 | def create(%AuthToken{} = auth, %{transaction: %{order_id: order_id}} = transaction), 14 | do: REST.post(auth, "orders/#{order_id}/transactions.json", transaction) 15 | end 16 | -------------------------------------------------------------------------------- /lib/shopify_api/rest/usage_charge.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.REST.UsageCharge do 2 | @moduledoc """ 3 | ShopifyAPI REST API UsageCharge resource 4 | """ 5 | 6 | alias ShopifyAPI.AuthToken 7 | alias ShopifyAPI.REST 8 | 9 | @doc """ 10 | Create a usage charge. 11 | 12 | ## Example 13 | 14 | iex> ShopifyAPI.REST.UsageCharge.create(auth, integer, map) 15 | {:ok, %{} = usage_charge} 16 | """ 17 | def create( 18 | %AuthToken{} = auth, 19 | recurring_application_charge_id, 20 | usage_charge 21 | ) do 22 | REST.post( 23 | auth, 24 | "recurring_application_charges/#{recurring_application_charge_id}/usage_charges.json", 25 | usage_charge 26 | ) 27 | end 28 | 29 | @doc """ 30 | Retrieve a single charge. 31 | 32 | ## Example 33 | 34 | iex> ShopifyAPI.REST.UsageCharge.get(auth, integer, %{usage_charge: %{id: integer}}) 35 | {:ok, %{} = usage_charge} 36 | """ 37 | def get( 38 | %AuthToken{} = auth, 39 | recurring_application_charge_id, 40 | %{usage_charge: %{id: usage_charge_id}}, 41 | params \\ [], 42 | options \\ [] 43 | ) do 44 | REST.get( 45 | auth, 46 | "recurring_application_charges/#{recurring_application_charge_id}/usage_charges/#{usage_charge_id}.json", 47 | params, 48 | Keyword.merge([pagination: :none], options) 49 | ) 50 | end 51 | 52 | @doc """ 53 | Get of all the usage charges. 54 | 55 | ## Example 56 | 57 | iex> ShopifyAPI.REST.UsageCharge.all(auth, integer) 58 | {:ok, [] = usage_charges} 59 | """ 60 | def all(%AuthToken{} = auth, recurring_application_charge_id, params \\ [], options \\ []) do 61 | REST.get( 62 | auth, 63 | "recurring_application_charges/#{recurring_application_charge_id}/usage_charges.json", 64 | params, 65 | Keyword.merge([pagination: :none], options) 66 | ) 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /lib/shopify_api/rest/user.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.REST.User do 2 | @moduledoc """ 3 | ShopifyAPI REST API User resource 4 | """ 5 | 6 | alias ShopifyAPI.AuthToken 7 | alias ShopifyAPI.REST 8 | 9 | @doc """ 10 | Get a single user. 11 | 12 | ## Example 13 | 14 | iex> ShopifyAPI.REST.User.get(auth, integer) 15 | {:ok, %{} = user} 16 | """ 17 | def get(%AuthToken{} = auth, user_id, params \\ [], options \\ []), 18 | do: 19 | REST.get(auth, "users/#{user_id}.json", params, Keyword.merge([pagination: :none], options)) 20 | 21 | @doc """ 22 | Return a list of all users. 23 | 24 | ## Example 25 | 26 | iex> ShopifyAPI.REST.User.all(auth) 27 | {:ok, [] = users} 28 | """ 29 | def all(%AuthToken{} = auth, params \\ [], options \\ []), 30 | do: REST.get(auth, "users.json", params, Keyword.merge([pagination: :none], options)) 31 | 32 | @doc """ 33 | Get the currently logged-in user. 34 | 35 | ## Example 36 | 37 | iex> ShopifyAPI.REST.User.current(auth) 38 | {:ok, %{} = user} 39 | """ 40 | def current(%AuthToken{} = auth, params \\ [], options \\ []), 41 | do: REST.get(auth, "users/current.json", params, Keyword.merge([pagination: :none], options)) 42 | end 43 | -------------------------------------------------------------------------------- /lib/shopify_api/rest/variant.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.REST.Variant do 2 | @moduledoc """ 3 | """ 4 | alias ShopifyAPI.AuthToken 5 | alias ShopifyAPI.REST 6 | 7 | @doc """ 8 | Return a single Product Variant 9 | """ 10 | def get(%AuthToken{} = auth, variant_id, params \\ [], options \\ []), 11 | do: 12 | REST.get( 13 | auth, 14 | "variants/#{variant_id}.json", 15 | params, 16 | Keyword.merge([pagination: :none], options) 17 | ) 18 | 19 | @doc """ 20 | Return all of a Product's Variants. 21 | 22 | iex> ShopifyAPI.REST.Variant.get(auth, product_id) 23 | 24 | """ 25 | def all(%AuthToken{} = auth, product_id, params \\ [], options \\ []), 26 | do: 27 | REST.get( 28 | auth, 29 | "products/#{product_id}/variants.json", 30 | params, 31 | Keyword.merge([pagination: :none], options) 32 | ) 33 | 34 | @doc """ 35 | Return a count of all Product Variants. 36 | 37 | iex> ShopifyAPI.REST.Variant.get(auth, product_id) 38 | {:ok, integer = count} 39 | """ 40 | 41 | def count(%AuthToken{} = auth, product_id, params \\ [], options \\ []), 42 | do: 43 | REST.get( 44 | auth, 45 | "products/#{product_id}/variants/count.json", 46 | params, 47 | Keyword.merge([pagination: :none], options) 48 | ) 49 | 50 | @doc """ 51 | Delete a Product Variant. 52 | 53 | iex> ShopifyAPI.REST.Variant.delete(auth, product_id, variant_id) 54 | {:ok, %{}} 55 | """ 56 | def delete(%AuthToken{} = auth, product_id, variant_id), 57 | do: REST.delete(auth, "products/#{product_id}/variants/#{variant_id}.json") 58 | 59 | @doc """ 60 | Create a new Product Variant. 61 | 62 | iex> ShopifyAPI.REST.Variant.create(auth, product_id, %{variant: %{body_html: "Testing variant create", title: "Testing Create Product Variant"}}) 63 | """ 64 | def create(%AuthToken{} = auth, product_id, %{variant: %{}} = variant), 65 | do: REST.post(auth, "products/#{product_id}/variants.json", variant) 66 | 67 | @doc """ 68 | Update a Product Variant. 69 | """ 70 | def update(%AuthToken{} = auth, %{variant: %{id: variant_id}} = variant), 71 | do: REST.put(auth, "variants/#{variant_id}.json", variant) 72 | end 73 | -------------------------------------------------------------------------------- /lib/shopify_api/rest/webhook.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.REST.Webhook do 2 | @moduledoc """ 3 | """ 4 | alias ShopifyAPI.AuthToken 5 | alias ShopifyAPI.REST 6 | 7 | @doc """ 8 | ## Helper method to generate the callback URI this server responds to. 9 | 10 | ## Example 11 | 12 | iex> ShopifyAPI.REST.Webhook.webhook_uri(%AuthToken{app_name: "some-shopify-app"}) 13 | "https://shopifyapi-server.example.com/shop/webhook/some-shopify-app" 14 | """ 15 | def webhook_uri(%AuthToken{app_name: app}) do 16 | # TODO this is brittle, we need to leverage URI and build correct paths here 17 | Application.get_env(:shopify_api, ShopifyAPI.Webhook)[:uri] <> "/#{app}" 18 | end 19 | 20 | @doc """ 21 | ## Example 22 | 23 | iex> ShopifyAPI.REST.Webhook.all(auth) 24 | {:ok, [%{"webhook_id" => "_", "address" => "https://example.com"}] = webhooks} 25 | """ 26 | def all(%AuthToken{} = auth, params \\ [], options \\ []), 27 | do: REST.get(auth, "webhooks.json", params, options) 28 | 29 | def get(%AuthToken{} = auth, webhook_id, params \\ [], options \\ []), 30 | do: 31 | REST.get( 32 | auth, 33 | "webhooks/#{webhook_id}.json", 34 | params, 35 | Keyword.merge([pagination: :none], options) 36 | ) 37 | 38 | def update(%AuthToken{} = auth, %{webhook: %{webhook_id: webhook_id}} = webhook), 39 | do: REST.put(auth, "webhooks/#{webhook_id}.json", webhook) 40 | 41 | @doc """ 42 | ## Example 43 | 44 | iex> ShopifyAPI.REST.Webhook.delete(auth, webhook_id) 45 | {:ok, %{}} 46 | """ 47 | def delete(%AuthToken{} = auth, webhook_id), 48 | do: REST.delete(auth, "webhooks/#{webhook_id}.json") 49 | 50 | @doc """ 51 | ## Example 52 | 53 | iex> ShopifyAPI.REST.Webhook.create(auth, %{webhook: %{address: "https://example.com"}}) 54 | {:ok, %{"webhook_id" => "_", "address" => "https://example.com"} = webhook} 55 | """ 56 | def create(%AuthToken{} = auth, %{webhook: %{}} = webhook), 57 | do: REST.post(auth, "webhooks.json", webhook) 58 | end 59 | -------------------------------------------------------------------------------- /lib/shopify_api/security.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.Security do 2 | def base16_sha256_hmac(text, secret) do 3 | :sha256 4 | |> hmac(secret, text) 5 | |> Base.encode16() 6 | |> String.downcase() 7 | end 8 | 9 | def base64_sha256_hmac(text, secret) do 10 | :sha256 11 | |> hmac(secret, text) 12 | |> Base.encode64() 13 | end 14 | 15 | # TODO: remove when we require OTP 22 16 | if System.otp_release() >= "22" do 17 | defp hmac(digest, key, data), do: :crypto.mac(:hmac, digest, key, data) 18 | else 19 | defp hmac(digest, key, data), do: :crypto.hmac(digest, key, data) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/shopify_api/shop.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.Shop do 2 | @derive {Jason.Encoder, only: [:domain]} 3 | defstruct domain: "" 4 | 5 | @typedoc """ 6 | Type that represents a Shopify Shop with 7 | 8 | - domain corresponding to the full myshopify hostname for the shop 9 | """ 10 | @type t :: %__MODULE__{domain: String.t()} 11 | 12 | @shopify_domain "myshopify.com" 13 | 14 | @spec post_login(ShopifyAPI.AuthToken.t() | ShopifyAPI.UserToken.t()) :: any() 15 | def post_login(%ShopifyAPI.AuthToken{} = token) do 16 | :post_login |> shop_config() |> call_post_login(token) 17 | # @deprecated 18 | :post_install |> shop_config() |> call_post_login(token) 19 | end 20 | 21 | def post_login(%ShopifyAPI.UserToken{} = token) do 22 | :post_login |> shop_config() |> call_post_login(token) 23 | end 24 | 25 | @spec domain_from_slug(String.t()) :: String.t() 26 | def domain_from_slug(slug), do: "#{slug}.#{@shopify_domain}" 27 | 28 | @spec slug_from_domain(String.t()) :: String.t() 29 | def slug_from_domain(domain), do: String.replace(domain, "." <> @shopify_domain, "") 30 | 31 | @spec to_uri(String.t()) :: URI.t() 32 | @spec to_uri(t()) :: URI.t() 33 | def to_uri(%_{domain: domain} = shop) when is_struct(shop, __MODULE__), do: to_uri(domain) 34 | 35 | # define custom to_uri for testing and dev so we can have shops that point back to ByPass URIs. 36 | if Mix.env() == :test or Mix.env() == :dev do 37 | def to_uri(myshopify_domain) do 38 | {domain, port} = 39 | if String.match?(myshopify_domain, ~r/.*:.*/) do 40 | [domain, str_port] = String.split(myshopify_domain, ":") 41 | {domain, String.to_integer(str_port)} 42 | else 43 | {myshopify_domain, 443} 44 | end 45 | 46 | %URI{scheme: ShopifyAPI.transport(), port: port, host: domain} 47 | end 48 | else 49 | def to_uri(myshopify_domain), 50 | do: %URI{scheme: ShopifyAPI.transport(), port: ShopifyAPI.port(), host: myshopify_domain} 51 | end 52 | 53 | defp shop_config(key), 54 | do: Application.get_env(:shopify_api, ShopifyAPI.Shop)[key] 55 | 56 | defp call_post_login({module, function, _}, token), do: apply(module, function, [token]) 57 | defp call_post_login(nil, _token), do: nil 58 | end 59 | -------------------------------------------------------------------------------- /lib/shopify_api/shop_server.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.ShopServer do 2 | @moduledoc """ 3 | Write-through cache for Shop structs. 4 | """ 5 | 6 | use GenServer 7 | 8 | alias ShopifyAPI.Config 9 | alias ShopifyAPI.Shop 10 | 11 | @table __MODULE__ 12 | 13 | def all do 14 | @table 15 | |> :ets.tab2list() 16 | |> Map.new() 17 | end 18 | 19 | @spec count() :: integer() 20 | def count, do: :ets.info(@table, :size) 21 | 22 | @spec set(Shop.t(), boolean()) :: :ok 23 | def set(%Shop{domain: domain} = shop, should_persist \\ false) do 24 | :ets.insert(@table, {domain, shop}) 25 | if should_persist, do: do_persist(shop) 26 | :ok 27 | end 28 | 29 | @spec get(String.t()) :: {:ok, Shop.t()} | :error 30 | def get(domain) do 31 | case :ets.lookup(@table, domain) do 32 | [{^domain, shop}] -> {:ok, shop} 33 | [] -> :error 34 | end 35 | end 36 | 37 | @spec get_or_create(String.t(), boolean()) :: {:ok, Shop.t()} 38 | def get_or_create(domain, should_persist \\ true) do 39 | case get(domain) do 40 | {:ok, _} = resp -> 41 | resp 42 | 43 | :error -> 44 | shop = %Shop{domain: domain} 45 | set(shop, should_persist) 46 | {:ok, shop} 47 | end 48 | end 49 | 50 | @spec delete(String.t()) :: :ok 51 | def delete(domain) do 52 | true = :ets.delete(@table, domain) 53 | :ok 54 | end 55 | 56 | ## GenServer Callbacks 57 | 58 | def start_link(_opts), do: GenServer.start_link(__MODULE__, :ok, name: __MODULE__) 59 | 60 | @impl GenServer 61 | def init(:ok) do 62 | create_table!() 63 | for shop when is_struct(shop, Shop) <- do_initialize(), do: set(shop, false) 64 | {:ok, :no_state} 65 | end 66 | 67 | ## Private Helpers 68 | 69 | defp create_table! do 70 | :ets.new(@table, [ 71 | :set, 72 | :public, 73 | :named_table, 74 | read_concurrency: true 75 | ]) 76 | end 77 | 78 | # Calls a configured initializer to obtain a list of Shops. 79 | defp do_initialize do 80 | case Config.lookup(__MODULE__, :initializer) do 81 | {module, function, args} -> apply(module, function, args) 82 | {module, function} -> apply(module, function, []) 83 | _ -> [] 84 | end 85 | end 86 | 87 | # Attempts to persist a Shop if a persistence callback is configured 88 | defp do_persist(%Shop{domain: domain} = shop) do 89 | case Config.lookup(__MODULE__, :persistence) do 90 | {module, function, args} -> apply(module, function, [domain, shop | args]) 91 | {module, function} -> apply(module, function, [domain, shop]) 92 | _ -> nil 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /lib/shopify_api/shopify_id.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.ShopifyId do 2 | @moduledoc """ 3 | Holds A Shopify Id or Global Id. 4 | 5 | Sometimes shopify gives you an Id that looks like 6 | `"gid://shopify/Order/1234567890000"` from one place, and `"1234567890000"` from another, and `1234567890000` from another, to reference the same object. 7 | 8 | https://shopify.dev/docs/api/usage/gids 9 | 10 | This aims to reduce the confusion, around shopify ids and allow for convenient typspecs and guards. 11 | 12 | ## With Ecto 13 | 14 | ShopifyId can be used in a schema with: 15 | ```elixir 16 | field :order_id, ShopifyAPI.ShopifyId, type: :order 17 | ``` 18 | 19 | ## With Absinthe 20 | 21 | ShopifyId can be used as an Absinthe custom type with: 22 | ```elixir 23 | defmodule MyAppGraphQL.Schema.CustomTypes do 24 | use Absinthe.Schema.Notation 25 | 26 | alias Wishlist.ShopifyAPI.ShopifyId 27 | alias Absinthe.Blueprint.Input 28 | 29 | scalar :shopify_customer_id, name: "ShopifyCustomerId" do 30 | description("The `CustomerId` scalar type represents a shopify customer id.") 31 | 32 | serialize(&ShopifyId.stringify/1) 33 | parse(&parse_shopify_customer_id/1) 34 | end 35 | 36 | @spec parse_shopify_customer_id(Input.String.t()) :: {:ok, ShopifyId.t(:customer)} | :error 37 | @spec parse_shopify_customer_id(Input.Integer.t()) :: {:ok, ShopifyId.t(:customer)} | :error 38 | @spec parse_shopify_customer_id(Input.Null.t()) :: {:ok, nil} 39 | defp parse_shopify_customer_id(%Input.String{value: value}), do: ShopifyId.new(value, :customer) 40 | 41 | defp parse_shopify_customer_id(%Input.Integer{value: value}), 42 | do: ShopifyId.new(value, :customer) 43 | 44 | defp parse_shopify_customer_id(%Input.Null{}), do: {:ok, nil} 45 | end 46 | ``` 47 | 48 | """ 49 | 50 | @type t(object_type) :: %__MODULE__{ 51 | object_type: object_type, 52 | id: String.t() 53 | } 54 | 55 | @type t() :: t(atom()) 56 | 57 | @enforce_keys [:object_type, :id] 58 | defstruct @enforce_keys 59 | 60 | @spec new(String.t() | integer(), object_type) :: {:ok, t(object_type)} | :error 61 | when object_type: atom() 62 | def new("gid://shopify/" <> rest, type) when is_atom(type) do 63 | with [object_type, id] <- String.split(rest, "/"), 64 | ^type <- atomize_type(object_type) do 65 | new(id, type) 66 | else 67 | _ -> :error 68 | end 69 | end 70 | 71 | def new(id, type) when is_integer(id) and is_atom(type), 72 | do: id |> Integer.to_string() |> new(type) 73 | 74 | def new(id, type) when is_binary(id) and is_atom(type), 75 | do: {:ok, %__MODULE__{object_type: type, id: id}} 76 | 77 | @spec new!(String.t() | integer(), object_type) :: t(object_type) when object_type: atom() 78 | def new!(id, type) do 79 | case new(id, type) do 80 | {:ok, shopify_id} -> shopify_id 81 | :error -> raise ArgumentError, message: "type does not match shopify id" 82 | end 83 | end 84 | 85 | def atomize_type(object_type) when is_binary(object_type), 86 | do: object_type |> Macro.underscore() |> String.to_existing_atom() 87 | 88 | def deatomize_type(type) when is_atom(type), do: type |> Atom.to_string() |> Macro.camelize() 89 | 90 | def stringify(%__MODULE__{object_type: object_type, id: id}), 91 | do: "gid://shopify/" <> deatomize_type(object_type) <> "/" <> id 92 | 93 | ################### 94 | # Implementations 95 | ################### 96 | 97 | defimpl Jason.Encoder do 98 | alias ShopifyAPI.ShopifyId 99 | 100 | def encode(shopify_id, opts), 101 | do: shopify_id |> ShopifyId.stringify() |> Jason.Encode.string(opts) 102 | end 103 | 104 | # TODO remove if check once JSON is generally available. 105 | if Code.ensure_loaded?(JSON) do 106 | defimpl JSON.Encoder do 107 | def encode(shopify_id, encoder), 108 | do: shopify_id |> ShopifyAPI.ShopifyId.stringify() |> JSON.encode!(encoder) 109 | end 110 | end 111 | 112 | ################### 113 | # Ecto ParameterizedType Callbacks 114 | # 115 | # Only used if Ecto is a dependency. 116 | ################### 117 | 118 | use ShopifyAPI.ShopifyId.Ecto 119 | 120 | @impl true 121 | def type(_params), do: :string 122 | 123 | @impl true 124 | def init(opts) do 125 | type = Keyword.fetch!(opts, :type) 126 | %{type: type} 127 | end 128 | 129 | @impl true 130 | def cast(gid, %{type: type}) when is_binary(gid), do: new(gid, type) 131 | def cast(%__MODULE__{object_type: type} = shopify_id, %{type: type}), do: {:ok, shopify_id} 132 | def cast(nil, _params), do: {:ok, nil} 133 | def cast(_, _params), do: :error 134 | 135 | @impl true 136 | def load(data, _loader, %{type: type}) when is_binary(data), do: new(data, type) 137 | def load(nil, _loader, _params), do: {:ok, nil} 138 | 139 | @impl true 140 | def dump(shopify_id, _dumper, _params) when is_struct(shopify_id, __MODULE__), 141 | do: {:ok, stringify(shopify_id)} 142 | 143 | def dump(nil, _dumper, _params), do: {:ok, nil} 144 | def dump(_, _dumper, _params), do: :error 145 | end 146 | -------------------------------------------------------------------------------- /lib/shopify_api/shopify_id/ecto.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(Ecto.ParameterizedType) do 2 | defmodule ShopifyAPI.ShopifyId.Ecto do 3 | @moduledoc """ 4 | When Ecto is a dependency in the project, ShopifyId can be used as an Ecto parameterized type in a schema. 5 | 6 | ```elixir 7 | field :order_id, ShopifyAPI.ShopifyId, type: :order 8 | ``` 9 | """ 10 | defmacro __using__(_opts) do 11 | quote do 12 | use Ecto.ParameterizedType 13 | end 14 | end 15 | end 16 | else 17 | defmodule ShopifyAPI.ShopifyId.Ecto do 18 | @moduledoc """ 19 | When Ecto is a dependency in the project, ShopifyId can be used as an Ecto parameterized type in a schema. 20 | 21 | When Ecto is not a dependency, this is idnored. 22 | """ 23 | defmacro __using__(_opts), do: nil 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/shopify_api/supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.Supervisor do 2 | @moduledoc """ 3 | This Supervisor maintains a set of ShopifyAPI servers, used for caching commonly-used data such as Shop, App, and AuthToken structs. 4 | 5 | ## Using 6 | 7 | Include the Supervisor in your applications supervision tree after any dependencies (such as Ecto): 8 | 9 | def start(_type, _args) do 10 | children = [ 11 | MyApp.Repo, 12 | ShopifyAPI.Supervisor 13 | ] 14 | 15 | Supervisor.start_link(children, strategy: :one_for_one) 16 | end 17 | """ 18 | 19 | use Supervisor 20 | 21 | alias ShopifyAPI.AppServer 22 | alias ShopifyAPI.AuthTokenServer 23 | alias ShopifyAPI.ShopServer 24 | alias ShopifyAPI.UserTokenServer 25 | 26 | def start_link(_opts) do 27 | Supervisor.start_link(__MODULE__, :ok, name: __MODULE__) 28 | end 29 | 30 | @impl Supervisor 31 | def init(:ok) do 32 | children = [AppServer, AuthTokenServer, ShopServer, UserTokenServer] 33 | Supervisor.init(children, strategy: :one_for_one) 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/shopify_api/user_token.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.UserToken do 2 | @moduledoc """ 3 | Represents the auth token for individual users, Shopify documentation for the auth process 4 | is here https://shopify.dev/docs/apps/auth/oauth/getting-started#online-access-mode 5 | """ 6 | 7 | @derive {Jason.Encoder, 8 | only: [ 9 | :code, 10 | :app_name, 11 | :shop_name, 12 | :token, 13 | :timestamp, 14 | :plus, 15 | :scope, 16 | :expires_in, 17 | :associated_user_scope, 18 | :associated_user, 19 | :associated_user_id 20 | ]} 21 | defstruct code: "", 22 | app_name: "", 23 | shop_name: "", 24 | token: "", 25 | timestamp: 0, 26 | plus: false, 27 | scope: "", 28 | expires_in: 0, 29 | associated_user_scope: "", 30 | associated_user: %ShopifyAPI.AssociatedUser{}, 31 | associated_user_id: 0 32 | 33 | @typedoc """ 34 | Type that represents a Shopify Online Access Mode Auth Token with 35 | 36 | - app_name corresponding to %ShopifyAPI.App{name: app_name} 37 | - shop_name corresponding to %ShopifyAPI.Shop{domain: shop_name} 38 | """ 39 | @type t :: %__MODULE__{ 40 | code: String.t(), 41 | app_name: String.t(), 42 | shop_name: String.t(), 43 | token: String.t(), 44 | timestamp: integer(), 45 | plus: boolean(), 46 | scope: String.t(), 47 | expires_in: integer(), 48 | associated_user_scope: String.t(), 49 | associated_user: ShopifyAPI.AssociatedUser.t(), 50 | associated_user_id: integer() 51 | } 52 | @type ok_t :: {:ok, t()} 53 | @type key :: String.t() 54 | 55 | alias ShopifyAPI.App 56 | alias ShopifyAPI.AssociatedUser 57 | 58 | @spec create_key(t()) :: key() 59 | def create_key(token) when is_struct(token, __MODULE__), 60 | do: create_key(token.shop_name, token.app_name, token.associated_user_id) 61 | 62 | @spec create_key(String.t(), String.t(), integer()) :: key() 63 | def create_key(shop, app, associated_user_id), do: "#{shop}:#{app}:#{associated_user_id}" 64 | 65 | @spec from_auth_request(App.t(), String.t(), String.t(), map()) :: t() 66 | def from_auth_request(app, myshopify_domain, auth_code \\ "", attrs) when is_struct(app, App) do 67 | user = AssociatedUser.from_auth_request(attrs["associated_user"]) 68 | 69 | struct(__MODULE__, 70 | associated_user: user, 71 | associated_user_id: user.id, 72 | associated_user_scope: attrs["associated_user_scope"], 73 | app_name: app.name, 74 | shop_name: myshopify_domain, 75 | code: auth_code, 76 | token: attrs["access_token"], 77 | timestamp: DateTime.to_unix(DateTime.utc_now()), 78 | expires_in: attrs["expires_in"], 79 | scope: attrs["scope"] 80 | ) 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /lib/shopify_api/user_token_server.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.UserTokenServer do 2 | @moduledoc """ 3 | Write-through cache for UserToken structs. 4 | """ 5 | 6 | use GenServer 7 | 8 | alias ShopifyAPI.Config 9 | alias ShopifyAPI.UserToken 10 | 11 | @table __MODULE__ 12 | @type t :: UserToken.t() 13 | @type ok_t :: {:ok, t()} 14 | @type error_not_found :: {:error, :user_token_not_found} 15 | 16 | def all do 17 | @table 18 | |> :ets.tab2list() 19 | |> Map.new() 20 | end 21 | 22 | @spec count() :: integer() 23 | def count, do: :ets.info(@table, :size) 24 | 25 | @spec set(UserToken.t()) :: :ok 26 | @spec set(UserToken.t(), boolean()) :: :ok 27 | def set(token, should_persist \\ true) when is_struct(token, UserToken) do 28 | :ets.insert(@table, {{token.shop_name, token.app_name, token.associated_user_id}, token}) 29 | if should_persist, do: do_persist(token) 30 | :ok 31 | end 32 | 33 | @spec get(String.t(), String.t(), integer()) :: ok_t() | error_not_found() 34 | def get(myshopify_domain, app_name, user_id) 35 | when is_binary(myshopify_domain) and is_binary(app_name) and is_number(user_id) do 36 | case :ets.lookup(@table, {myshopify_domain, app_name, user_id}) do 37 | [{_key, token}] -> {:ok, token} 38 | [] -> {:error, :user_token_not_found} 39 | end 40 | end 41 | 42 | @spec get_valid(String.t(), String.t(), integer()) :: ok_t() | {:error, :invalid_user_token} 43 | def get_valid(myshopify_domain, app_name, user_id), 44 | do: myshopify_domain |> get(app_name, user_id) |> validate() 45 | 46 | @spec validate(ok_t() | error_not_found()) :: ok_t() | {:error, :invalid_user_token} 47 | def validate({:ok, user_token}) do 48 | now = DateTime.to_unix(DateTime.utc_now()) 49 | 50 | if user_token.timestamp + user_token.expires_in >= now do 51 | {:ok, user_token} 52 | else 53 | {:error, :invalid_user_token} 54 | end 55 | end 56 | 57 | def validate(_), do: {:error, :invalid_user_token} 58 | 59 | def get_for_shop(myshopify_domain) when is_binary(myshopify_domain) do 60 | match_spec = [{{{myshopify_domain, :_, :_}, :"$1"}, [], [:"$1"]}] 61 | :ets.select(@table, match_spec) 62 | end 63 | 64 | def get_for_app(app) when is_binary(app) do 65 | match_spec = [{{{:_, app, :_}, :"$1"}, [], [:"$1"]}] 66 | :ets.select(@table, match_spec) 67 | end 68 | 69 | @spec delete(UserToken.t()) :: :ok 70 | def delete(token) do 71 | :ets.delete(@table, {token.shop_name, token.app_name, token.associated_user_id}) 72 | :ok 73 | end 74 | 75 | @spec delete_for_shop(String.t()) :: :ok 76 | def delete_for_shop(myshopify_domain) when is_binary(myshopify_domain) do 77 | myshopify_domain |> get_for_shop() |> Enum.each(&delete/1) 78 | :ok 79 | end 80 | 81 | def drop_all, do: :ets.delete_all_objects(@table) 82 | 83 | ## GenServer Callbacks 84 | 85 | def start_link(_opts), do: GenServer.start_link(__MODULE__, :ok, name: __MODULE__) 86 | 87 | @impl GenServer 88 | def init(:ok) do 89 | create_table!() 90 | for token when is_struct(token, UserToken) <- do_initialize(), do: set(token, false) 91 | {:ok, :no_state} 92 | end 93 | 94 | ## Private Helpers 95 | 96 | defp create_table! do 97 | :ets.new(@table, [ 98 | :set, 99 | :public, 100 | :named_table, 101 | read_concurrency: true 102 | ]) 103 | end 104 | 105 | # Calls a configured initializer to obtain a list of AuthTokens. 106 | defp do_initialize do 107 | case Config.lookup(__MODULE__, :initializer) do 108 | {module, function, args} -> apply(module, function, args) 109 | {module, function} -> apply(module, function, []) 110 | _ -> [] 111 | end 112 | end 113 | 114 | # Attempts to persist an UserToken if a persistence callback is configured 115 | defp do_persist(token) when is_struct(token, UserToken) do 116 | key = UserToken.create_key(token) 117 | 118 | case Config.lookup(__MODULE__, :persistence) do 119 | {module, function, args} -> apply(module, function, [key, token | args]) 120 | {module, function} -> apply(module, function, [key, token]) 121 | _ -> nil 122 | end 123 | end 124 | end 125 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Plug.ShopifyAPI.MixProject do 2 | use Mix.Project 3 | 4 | @version "0.16.2" 5 | 6 | def project do 7 | [ 8 | app: :shopify_api, 9 | version: @version, 10 | elixir: "~> 1.17", 11 | start_permanent: Mix.env() == :prod, 12 | deps: deps(), 13 | dialyzer: [ 14 | plt_add_apps: [:mix, :ex_unit, :ecto], 15 | plt_file: {:no_warn, "priv/plts/dialyzer.plt"} 16 | ], 17 | package: package(), 18 | elixirc_paths: elixirc_paths(Mix.env()), 19 | 20 | # Ex_Doc configuration 21 | name: "Shopify API", 22 | source_url: "https://github.com/pixelunion/elixir-shopifyapi", 23 | docs: [ 24 | main: "ShopifyAPI.App", 25 | extras: ["README.md"] 26 | ] 27 | ] 28 | end 29 | 30 | # Run "mix help compile.app" to learn about applications. 31 | def application do 32 | [ 33 | mod: {ShopifyAPI.Application, []}, 34 | extra_applications: [:logger] 35 | ] 36 | end 37 | 38 | # Run "mix help deps" to learn about dependencies. 39 | defp deps do 40 | [ 41 | # Dev and Test 42 | {:bypass, "~> 2.1", only: :test}, 43 | {:credo, "~> 1.0", only: [:dev, :test]}, 44 | {:dialyxir, "~> 1.4.1", only: [:dev, :test], runtime: false}, 45 | {:ex_doc, "~> 0.37.1", only: [:dev], runtime: false}, 46 | {:ex_machina, "~> 2.8.0", only: :test}, 47 | {:faker, "~> 0.17", only: :test}, 48 | {:stream_data, "~> 1.2.0", only: :test}, 49 | # Everything else 50 | {:ecto_sql, "~> 3.6", optional: true}, 51 | {:gen_stage, "~> 1.0"}, 52 | {:httpoison, "~> 2.0"}, 53 | {:jason, "~> 1.0"}, 54 | {:jose, "~> 1.11.2"}, 55 | {:plug, "~> 1.0"}, 56 | {:telemetry, "~> 0.4 or ~> 1.0"} 57 | ] 58 | end 59 | 60 | defp package do 61 | [ 62 | maintainers: [ 63 | "Pixel Union", 64 | "Hez Ronningen" 65 | ], 66 | links: %{github: "https://github.com/pixelunion/elixir-shopifyapi"}, 67 | licenses: ["Apache2.0"], 68 | files: ~w(lib test) ++ ~w(CHANGELOG.md LICENSE mix.exs README.md .formatter.exs) 69 | ] 70 | end 71 | 72 | # This makes sure your factory and any other modules in test/support are compiled 73 | # when in the test environment. 74 | defp elixirc_paths(:test), do: ["lib", "test/support"] 75 | defp elixirc_paths(_), do: ["lib"] 76 | end 77 | -------------------------------------------------------------------------------- /priv/plts/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orbit-apps/elixir-shopifyapi/65174ad92f4212d5a02ac6bc5847b15d597a5de3/priv/plts/.gitkeep -------------------------------------------------------------------------------- /test/shopify_api/bulk/telemetry_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.Bulk.TelemetryTest do 2 | use ExUnit.Case 3 | 4 | alias ShopifyAPI.AuthToken 5 | alias ShopifyAPI.Bulk.Telemetry 6 | 7 | @module "module" 8 | @bulk_op_id "gid//test" 9 | 10 | setup do 11 | token = %AuthToken{ 12 | token: "1234", 13 | shop_name: "localhost" 14 | } 15 | 16 | {:ok, %{auth_token: token}} 17 | end 18 | 19 | describe "Telemetry send/4" do 20 | test "when bulk op is succesful", %{auth_token: token} do 21 | assert :ok == Telemetry.send(@module, token, {:success, :test}) 22 | end 23 | 24 | test "when bulk op errors", %{auth_token: token} do 25 | assert :ok == Telemetry.send(@module, token, {:error, :test, "error"}, @bulk_op_id) 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /test/shopify_api/graphql/telemetry_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.GraphQL.TelemetryTest do 2 | use ExUnit.Case 3 | 4 | alias HTTPoison.Error 5 | alias ShopifyAPI.AuthToken 6 | alias ShopifyAPI.GraphQL.{Response, Telemetry} 7 | 8 | @module "module" 9 | @time 1202 10 | 11 | setup _context do 12 | token = %AuthToken{ 13 | token: "1234", 14 | shop_name: "localhost" 15 | } 16 | 17 | {:ok, %{auth_token: token}} 18 | end 19 | 20 | describe "Telemetry send/4" do 21 | test "when graphql response is succesful", %{auth_token: token} do 22 | assert :ok == Telemetry.send(@module, token, @time, {:ok, %Response{response: "response"}}) 23 | end 24 | 25 | test "when graphql request fails", %{auth_token: token} do 26 | assert :ok == 27 | Telemetry.send(@module, token, @time, {:error, %Response{response: "response"}}) 28 | end 29 | 30 | test "when graphql request errors out", %{auth_token: token} do 31 | assert :ok == Telemetry.send(@module, token, @time, {:error, %Error{reason: "reason"}}) 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /test/shopify_api/plugs/admin_authenticator_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.Plugs.AdminAuthenticatorTest do 2 | use ExUnit.Case, async: true 3 | 4 | import Plug.Test 5 | import ShopifyAPI.Factory 6 | import ShopifyAPI.SessionTokenSetup 7 | 8 | alias Plug.Conn 9 | alias ShopifyAPI.Plugs.AdminAuthenticator 10 | alias ShopifyAPI.ShopifyValidationSetup 11 | alias ShopifyAPI.{AppServer, ShopServer} 12 | 13 | @uninstalled_shop "uninstalled.myshopify.com" 14 | 15 | setup_all do 16 | app = build(:app) 17 | shop = build(:shop) 18 | [offline_token: offline_token] = offline_token(%{shop: shop}) 19 | [online_token: online_token] = online_token(%{shop: shop}) 20 | 21 | [jwt_session_token: jwt_session_token] = 22 | jwt_session_token(%{app: app, shop: shop, online_token: online_token}) 23 | 24 | ShopServer.set(shop) 25 | AppServer.set(app) 26 | 27 | params = %{ 28 | test: "test", 29 | shop: shop.domain, 30 | id_token: jwt_session_token 31 | } 32 | 33 | [ 34 | app: app, 35 | shop: shop, 36 | offline_token: offline_token, 37 | online_token: online_token, 38 | jwt_session_token: jwt_session_token, 39 | params: ShopifyValidationSetup.params_append_hmac(app, params) 40 | ] 41 | end 42 | 43 | describe "with an invalid hmac" do 44 | test "responds with 401 and halts", %{app: app, params: params} do 45 | params = %{params | hmac: "invalid"} 46 | # Create a test connection 47 | conn = 48 | :get 49 | |> conn("/admin/#{app.name}?" <> URI.encode_query(params)) 50 | |> init_test_session(%{}) 51 | |> Conn.fetch_query_params() 52 | |> AdminAuthenticator.call([]) 53 | 54 | assert conn.state == :set 55 | assert conn.status == 401 56 | assert conn.resp_body == "Not Authorized." 57 | end 58 | end 59 | 60 | describe "with a valid hmac" do 61 | setup(%{app: app, params: params}) do 62 | # Create a test connection 63 | conn = 64 | :get 65 | |> conn("/admin/#{app.name}?" <> URI.encode_query(params)) 66 | |> init_test_session(%{}) 67 | |> Conn.fetch_query_params() 68 | 69 | [conn: conn] 70 | end 71 | 72 | test "assigns the app, shop, and authtoken", %{ 73 | conn: conn, 74 | app: app, 75 | shop: shop, 76 | online_token: online_token, 77 | offline_token: offline_token 78 | } do 79 | conn = AdminAuthenticator.call(conn, []) 80 | 81 | assert conn.assigns.app == app 82 | assert conn.assigns.shop == shop 83 | assert conn.assigns.auth_token == offline_token 84 | assert conn.assigns.user_token == online_token 85 | end 86 | end 87 | 88 | describe "without an hmac" do 89 | setup(%{app: app}) do 90 | params = %{shop: @uninstalled_shop} 91 | 92 | # Create a test connection 93 | conn = 94 | :get 95 | |> conn("/admin/#{app.name}?" <> URI.encode_query(params)) 96 | |> init_test_session(%{}) 97 | |> Conn.fetch_query_params() 98 | 99 | [conn: conn, params: params] 100 | end 101 | 102 | test "plug does not halt", %{conn: conn} do 103 | conn = AdminAuthenticator.call(conn, shopify_mount_path: "/shop") 104 | 105 | refute conn.halted 106 | end 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /test/shopify_api/plugs/customer_authenticator_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.Plugs.CustomerAuthenticatorTest do 2 | use ExUnit.Case, async: true 3 | 4 | import Plug.Test 5 | 6 | alias ShopifyAPI.Plugs.CustomerAuthenticator 7 | alias ShopifyAPI.Security 8 | 9 | @secret "new_secret" 10 | 11 | describe "Valid Customer Auth" do 12 | test "assigns auth_payload to conn" do 13 | payload = auth_payload() 14 | signature = Security.base16_sha256_hmac(payload, @secret) 15 | 16 | conn = post(payload, signature) 17 | 18 | assert %{ 19 | "email" => "email@example.com", 20 | "expiry" => _, 21 | "id" => "12345" 22 | } = conn.assigns.auth_payload 23 | end 24 | 25 | test "validates on second secret as well" do 26 | payload = auth_payload() 27 | signature = Security.base16_sha256_hmac(payload, "old_secret") 28 | 29 | conn = post(payload, signature) 30 | 31 | assert %{ 32 | "email" => "email@example.com", 33 | "expiry" => _, 34 | "id" => "12345" 35 | } = conn.assigns.auth_payload 36 | end 37 | end 38 | 39 | describe "Invalid Customer Auth" do 40 | test "payload expired" do 41 | payload = DateTime.utc_now() |> add_seconds(-3600) |> auth_payload() 42 | signature = Security.base16_sha256_hmac(payload, "new_secret") 43 | 44 | conn = post(payload, signature) 45 | 46 | assert_unauthorized(conn, "auth_payload has expired") 47 | end 48 | 49 | test "malformed payload" do 50 | payload = "this payload is invalid" 51 | signature = Security.base16_sha256_hmac(payload, "new_secret") 52 | 53 | conn = post(payload, signature) 54 | 55 | assert_unauthorized(conn, "Could not parse auth_payload") 56 | end 57 | 58 | test "wrong secret" do 59 | payload = auth_payload() 60 | signature = Security.base16_sha256_hmac(payload, "wrong_secret") 61 | 62 | conn = post(payload, signature) 63 | 64 | assert_unauthorized(conn, "Authorization failed") 65 | end 66 | 67 | test "no payload" do 68 | payload = auth_payload() 69 | signature = Security.base16_sha256_hmac(payload, "new_secret") 70 | 71 | conn = 72 | :post 73 | |> conn("/", %{auth_signature: signature}) 74 | |> CustomerAuthenticator.call([]) 75 | 76 | assert_unauthorized(conn, "Authorization failed") 77 | end 78 | 79 | test "no signature" do 80 | payload = auth_payload() 81 | 82 | conn = 83 | :post 84 | |> conn("/", %{auth_payload: payload}) 85 | |> CustomerAuthenticator.call([]) 86 | 87 | assert_unauthorized(conn, "Authorization failed") 88 | end 89 | 90 | test "empty payload" do 91 | payload = "" 92 | signature = Security.base16_sha256_hmac(payload, "new_secret") 93 | 94 | conn = post(payload, signature) 95 | 96 | assert_unauthorized(conn, "Could not parse auth_payload") 97 | end 98 | 99 | test "empty expiry" do 100 | payload = ~s({"email":"email@example.com","id":"12345","expiry":""}) 101 | signature = Security.base16_sha256_hmac(payload, "new_secret") 102 | 103 | conn = post(payload, signature) 104 | 105 | assert_unauthorized(conn, "A valid ISO8601 expiry must be included in auth_payload") 106 | end 107 | 108 | test "malformed expiry" do 109 | payload = ~s({"email":"email@example.com","id":"12345","expiry":"baddate"}) 110 | signature = Security.base16_sha256_hmac(payload, "new_secret") 111 | 112 | conn = post(payload, signature) 113 | 114 | assert_unauthorized(conn, "A valid ISO8601 expiry must be included in auth_payload") 115 | end 116 | 117 | test "empty signature" do 118 | payload = auth_payload() 119 | signature = Security.base16_sha256_hmac(payload, "") 120 | 121 | conn = post(payload, signature) 122 | 123 | assert_unauthorized(conn, "Authorization failed") 124 | end 125 | end 126 | 127 | defp post(payload, signature) do 128 | :post 129 | |> conn("/", %{auth_payload: payload, auth_signature: signature}) 130 | |> CustomerAuthenticator.call([]) 131 | end 132 | 133 | defp assert_unauthorized(conn, message) do 134 | assert conn.status == 401 135 | assert conn.resp_body == message 136 | assert conn.assigns == %{} 137 | end 138 | 139 | defp auth_payload, do: DateTime.utc_now() |> add_seconds(360) |> auth_payload() 140 | 141 | defp auth_payload(expiry), 142 | do: ~s({"email":"email@example.com","id":"12345","expiry":"#{DateTime.to_iso8601(expiry)}"}) 143 | 144 | defp add_seconds(date_time, seconds) do 145 | date_time 146 | |> DateTime.to_naive() 147 | |> NaiveDateTime.add(seconds) 148 | |> DateTime.from_naive!("Etc/UTC") 149 | end 150 | end 151 | -------------------------------------------------------------------------------- /test/shopify_api/rate_limiting/graphql_call_limits_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.RateLimiting.GraphQLCallLimitsTest do 2 | use ExUnit.Case 3 | use ExUnitProperties 4 | 5 | alias ShopifyAPI.RateLimiting.GraphQLCallLimits 6 | 7 | setup do 8 | token = %ShopifyAPI.AuthToken{} 9 | now = ~U[2020-01-01 12:00:00.000000Z] 10 | %{now: now, token: token} 11 | end 12 | 13 | describe "calculate_wait/3" do 14 | property "larger points available than cost", %{now: now, token: token} do 15 | check all( 16 | int1 <- positive_integer(), 17 | int2 <- positive_integer(), 18 | estimated_cost = int1, 19 | points_available = int1 + int2, 20 | points_available > estimated_cost 21 | ) do 22 | assert GraphQLCallLimits.calculate_wait( 23 | {"key", points_available + GraphQLCallLimits.estimate_padding(), now}, 24 | token, 25 | estimated_cost, 26 | now 27 | ) == 0 28 | end 29 | end 30 | 31 | property "larger cost than points available", %{now: now, token: token} do 32 | check all( 33 | int1 <- positive_integer(), 34 | int2 <- positive_integer(), 35 | points_available = int1, 36 | estimated_cost = int1 + int2, 37 | estimated_cost > points_available 38 | ) do 39 | assert GraphQLCallLimits.calculate_wait( 40 | {"key", points_available, now}, 41 | token, 42 | estimated_cost, 43 | now 44 | ) > 0 45 | end 46 | end 47 | 48 | property "enough elapsed time to refill the bucket", %{now: now, token: token} do 49 | check all( 50 | int <- positive_integer(), 51 | date = DateTime.add(now, -int * 60 * 60) 52 | ) do 53 | assert GraphQLCallLimits.calculate_wait( 54 | {"key", 0, date}, 55 | token, 56 | 500, 57 | now 58 | ) == 0 59 | end 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /test/shopify_api/rate_limiting/graphql_tracker_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.RateLimiting.GraphQLTrackerTest do 2 | use ExUnit.Case 3 | 4 | alias ShopifyAPI.RateLimiting.{GraphQL, GraphQLTracker} 5 | 6 | setup do 7 | GraphQLTracker.clear_all() 8 | token = %ShopifyAPI.AuthToken{} 9 | now = ~U[2020-01-01 12:00:00.000000Z] 10 | 11 | %{now: now, token: token} 12 | end 13 | 14 | describe "update_api_call_limit" do 15 | test "returns available from GraphQL.Response", %{token: token} do 16 | available = 250 17 | 18 | response = %ShopifyAPI.GraphQL.Response{ 19 | metadata: %{"cost" => %{"throttleStatus" => %{"currentlyAvailable" => available}}} 20 | } 21 | 22 | assert {^available, 0} = GraphQLTracker.update_api_call_limit(token, response) 23 | end 24 | 25 | test "returns available from HTTPoison.Response", %{token: token} do 26 | available = 250 27 | 28 | response = %HTTPoison.Response{ 29 | body: %{ 30 | "extensions" => %{"cost" => %{"throttleStatus" => %{"currentlyAvailable" => available}}} 31 | } 32 | } 33 | 34 | assert {^available, 0} = GraphQLTracker.update_api_call_limit(token, response) 35 | end 36 | end 37 | 38 | describe "get/3" do 39 | test "handles get without having set", %{now: now, token: token} do 40 | default = GraphQL.request_bucket(token) 41 | assert {^default, 0} = GraphQLTracker.get(token, now, 1) 42 | end 43 | 44 | test "returns set points available", %{now: now, token: token} do 45 | points = 500 46 | GraphQLTracker.set(token, points, now) 47 | assert {^points, 0} = GraphQLTracker.get(token, now, 1) 48 | end 49 | 50 | test "returns a wait when estimate exceeds available points", %{now: now, token: token} do 51 | GraphQLTracker.set(token, 500, now) 52 | {_, wait} = GraphQLTracker.get(token, now, 600) 53 | assert wait > 0 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /test/shopify_api/rate_limiting/rest_call_limits_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.RateLimiting.RESTCallLimitsTest do 2 | use ExUnit.Case 3 | 4 | alias ShopifyAPI.RateLimiting.RESTCallLimits 5 | 6 | alias HTTPoison.{Error, Response} 7 | 8 | describe "limit_header_or_status_code/1" do 9 | test "pulls the call_limit header out of response headers" do 10 | call_limit_header = {"X-Shopify-Shop-Api-Call-Limit", "32/50"} 11 | response = %Response{headers: [{"foo", "bar"}, call_limit_header, {"bat", "biz"}]} 12 | 13 | assert RESTCallLimits.limit_header_or_status_code(response) == "32/50" 14 | end 15 | 16 | test "returns :over_limit on status code 429" do 17 | call_limit_header = {"X-Shopify-Shop-Api-Call-Limit", "32/50"} 18 | 19 | response = %Response{ 20 | status_code: 429, 21 | headers: [{"foo", "bar"}, call_limit_header, {"bat", "biz"}] 22 | } 23 | 24 | assert RESTCallLimits.limit_header_or_status_code(response) == :over_limit 25 | end 26 | 27 | test "returns nil if no call limit header" do 28 | response = %Response{headers: [{"foo", "bar"}]} 29 | 30 | assert RESTCallLimits.limit_header_or_status_code(response) == nil 31 | end 32 | 33 | test "returns nil on error" do 34 | assert RESTCallLimits.limit_header_or_status_code(%Error{}) == nil 35 | end 36 | end 37 | 38 | describe "get_api_remaining_calls/1" do 39 | test "calculates the call limit from the header" do 40 | header = "32/50" 41 | 42 | assert RESTCallLimits.get_api_remaining_calls(header) == 18 43 | end 44 | 45 | test "returns 0 for :over_limit" do 46 | assert RESTCallLimits.get_api_remaining_calls(:over_limit) == 0 47 | end 48 | 49 | test "nil passes through" do 50 | assert RESTCallLimits.get_api_remaining_calls(nil) == 0 51 | end 52 | end 53 | 54 | describe "get_retry_after_header/1" do 55 | test "pulls out the value " do 56 | retry_after_header = {"Retry-After", "1.0"} 57 | response = %Response{headers: [retry_after_header, {"foo", "bar"}, {"bat", "biz"}]} 58 | 59 | assert RESTCallLimits.get_retry_after_header(response) == "1.0" 60 | end 61 | 62 | test "defaults to 2.0" do 63 | response = %Response{headers: [{"foo", "bar"}, {"bat", "biz"}]} 64 | 65 | assert RESTCallLimits.get_retry_after_header(response) == "2.0" 66 | end 67 | end 68 | 69 | describe "get_retry_after_milliseconds/1" do 70 | test "Parses the expected value" do 71 | assert RESTCallLimits.get_retry_after_milliseconds("2.0") == 2000 72 | end 73 | 74 | test "parses decimal value" do 75 | assert RESTCallLimits.get_retry_after_milliseconds("2.3") == 2300 76 | assert RESTCallLimits.get_retry_after_milliseconds("2.003") == 2003 77 | end 78 | 79 | test "handles zero leading values" do 80 | assert RESTCallLimits.get_retry_after_milliseconds("0.2") == 200 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /test/shopify_api/rate_limiting/rest_tracker_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.RateLimiting.RESTTrackerTest do 2 | use ExUnit.Case 3 | 4 | alias HTTPoison.Response 5 | alias ShopifyAPI.AuthToken 6 | alias ShopifyAPI.RateLimiting.RESTTracker 7 | 8 | describe "api_hit_limit/2" do 9 | test "returns a 2000 ms delay" do 10 | call_limit_header = {"X-Shopify-Shop-Api-Call-Limit", "50/50"} 11 | retry_after_header = {"Retry-After", "2.0"} 12 | 13 | response = %Response{headers: [{"foo", "bar"}, call_limit_header, retry_after_header]} 14 | token = %AuthToken{app_name: "test", shop_name: "shop"} 15 | 16 | assert {0, 2000} == RESTTracker.api_hit_limit(token, response) 17 | end 18 | end 19 | 20 | describe "update_api_call_limit/2" do 21 | test "back off for 1000 ms when 0 is left" do 22 | call_limit_header = {"X-Shopify-Shop-Api-Call-Limit", "50/50"} 23 | 24 | response = %Response{headers: [{"foo", "bar"}, call_limit_header]} 25 | token = %AuthToken{app_name: "test", shop_name: "shop"} 26 | 27 | assert {0, 1000} == RESTTracker.update_api_call_limit(token, response) 28 | end 29 | 30 | test "does not back off if there is a limit left" do 31 | call_limit_header = {"X-Shopify-Shop-Api-Call-Limit", "40/50"} 32 | 33 | response = %Response{headers: [{"foo", "bar"}, call_limit_header]} 34 | token = %AuthToken{app_name: "test", shop_name: "shop"} 35 | 36 | assert {10, 0} == RESTTracker.update_api_call_limit(token, response) 37 | end 38 | end 39 | 40 | describe "get/1" do 41 | test "handles get without having set" do 42 | now = ~U[2020-01-01 12:00:00.000000Z] 43 | token = %AuthToken{app_name: "empty", shop_name: "empty"} 44 | 45 | assert {40, 0} == RESTTracker.get(token, now, 1) 46 | end 47 | 48 | test "returns with a sleep after hitting limit" do 49 | call_limit_header = {"X-Shopify-Shop-Api-Call-Limit", "50/50"} 50 | retry_after_header = {"Retry-After", "2.0"} 51 | response = %Response{headers: [{"foo", "bar"}, call_limit_header, retry_after_header]} 52 | token = %AuthToken{app_name: "hit_limit", shop_name: "hit_limit"} 53 | 54 | hit_time = ~U[2020-01-01 12:00:00.000000Z] 55 | 56 | assert {0, 2000} == RESTTracker.api_hit_limit(token, response, hit_time) 57 | 58 | assert {0, 2000} == RESTTracker.get(token, hit_time, 1) 59 | 60 | wait_a_second = ~U[2020-01-01 12:00:01.100000Z] 61 | 62 | assert {0, 900} == RESTTracker.get(token, wait_a_second, 1) 63 | 64 | wait_two_seconds = ~U[2020-01-01 12:00:02.100000Z] 65 | 66 | assert {0, 0} == RESTTracker.get(token, wait_two_seconds, 1) 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /test/shopify_api/rest/product_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.REST.ProductTest do 2 | use ExUnit.Case 3 | 4 | alias Plug.Conn 5 | alias ShopifyAPI.{AuthToken, JSONSerializer, Shop} 6 | alias ShopifyAPI.REST.{Product, Request} 7 | 8 | setup _context do 9 | bypass = Bypass.open() 10 | 11 | token = %AuthToken{ 12 | token: "token", 13 | shop_name: "localhost:#{bypass.port}" 14 | } 15 | 16 | shop = %Shop{domain: "localhost:#{bypass.port}"} 17 | 18 | {:ok, %{shop: shop, auth_token: token, bypass: bypass}} 19 | end 20 | 21 | test "", %{bypass: bypass, shop: _shop, auth_token: token} do 22 | product = %{"product_id" => "_", "title" => "Testing Create Product"} 23 | 24 | Bypass.expect_once(bypass, "GET", "/admin/api/#{Request.version()}/products.json", fn conn -> 25 | {:ok, body} = JSONSerializer.encode(%{products: [product]}) 26 | 27 | Conn.resp(conn, 200, body) 28 | end) 29 | 30 | assert {:ok, [^product]} = Product.all(token) 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /test/shopify_api/rest/redirect_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.REST.RedirectTest do 2 | use ExUnit.Case 3 | 4 | alias Plug.Conn 5 | alias ShopifyAPI.{AuthToken, JSONSerializer, Shop} 6 | alias ShopifyAPI.REST.{Redirect, Request} 7 | 8 | setup _context do 9 | bypass = Bypass.open() 10 | 11 | token = %AuthToken{ 12 | token: "token", 13 | shop_name: "localhost:#{bypass.port}" 14 | } 15 | 16 | shop = %Shop{domain: "localhost:#{bypass.port}"} 17 | 18 | redirect = %{ 19 | "id" => 979_034_144, 20 | "path" => "/ipod", 21 | "target" => "/pages/itunes" 22 | } 23 | 24 | {:ok, %{shop: shop, auth_token: token, bypass: bypass, redirect: redirect}} 25 | end 26 | 27 | test "client can request all redirects", %{ 28 | bypass: bypass, 29 | shop: _shop, 30 | auth_token: token, 31 | redirect: redirect 32 | } do 33 | Bypass.expect_once(bypass, "GET", "/admin/api/#{Request.version()}/redirects.json", fn conn -> 34 | {:ok, body} = JSONSerializer.encode(%{redirects: [redirect]}) 35 | 36 | Conn.resp(conn, 200, body) 37 | end) 38 | 39 | assert {:ok, [redirect]} == Redirect.all(token) 40 | end 41 | 42 | test "client can request a single redirect", %{ 43 | bypass: bypass, 44 | shop: _shop, 45 | auth_token: token, 46 | redirect: redirect 47 | } do 48 | Bypass.expect_once( 49 | bypass, 50 | "GET", 51 | "/admin/api/#{Request.version()}/redirects/#{redirect["id"]}.json", 52 | fn conn -> 53 | {:ok, body} = JSONSerializer.encode(%{redirect: redirect}) 54 | 55 | Conn.resp(conn, 200, body) 56 | end 57 | ) 58 | 59 | assert {:ok, redirect} == Redirect.get(token, redirect["id"]) 60 | end 61 | 62 | test "client can request a redirect count", %{ 63 | bypass: bypass, 64 | shop: _shop, 65 | auth_token: token, 66 | redirect: _redirect 67 | } do 68 | count = 1234 69 | 70 | Bypass.expect_once( 71 | bypass, 72 | "GET", 73 | "/admin/api/#{Request.version()}/redirects/count.json", 74 | fn conn -> 75 | {:ok, body} = JSONSerializer.encode(%{redirects: count}) 76 | 77 | Conn.resp(conn, 200, body) 78 | end 79 | ) 80 | 81 | assert {:ok, count} == Redirect.count(token) 82 | end 83 | 84 | test "client can request to create a redirect", %{ 85 | bypass: bypass, 86 | shop: _shop, 87 | auth_token: token, 88 | redirect: redirect 89 | } do 90 | Bypass.expect_once( 91 | bypass, 92 | "POST", 93 | "/admin/api/#{Request.version()}/redirects.json", 94 | fn conn -> 95 | {:ok, body} = JSONSerializer.encode(%{redirect: redirect}) 96 | 97 | Conn.resp(conn, 200, body) 98 | end 99 | ) 100 | 101 | assert {:ok, redirect} == Redirect.create(token, %{redirect: redirect}) 102 | end 103 | 104 | test "client can request to update an redirect", %{ 105 | bypass: bypass, 106 | shop: _shop, 107 | auth_token: token, 108 | redirect: redirect 109 | } do 110 | Bypass.expect_once( 111 | bypass, 112 | "PUT", 113 | "/admin/api/#{Request.version()}/redirects/#{redirect["id"]}.json", 114 | fn conn -> 115 | {:ok, body} = JSONSerializer.encode(%{redirect: redirect}) 116 | 117 | Conn.resp(conn, 200, body) 118 | end 119 | ) 120 | 121 | assert {:ok, redirect} == Redirect.update(token, %{"redirect" => redirect}) 122 | end 123 | 124 | test "client can request to delete an redirect", %{ 125 | bypass: bypass, 126 | shop: _shop, 127 | auth_token: token, 128 | redirect: redirect 129 | } do 130 | Bypass.expect_once( 131 | bypass, 132 | "DELETE", 133 | "/admin/api/#{Request.version()}/redirects/#{redirect["id"]}.json", 134 | fn conn -> 135 | {:ok, body} = JSONSerializer.encode([]) 136 | 137 | Conn.resp(conn, 200, body) 138 | end 139 | ) 140 | 141 | assert {:ok, {:ok, []}} == Redirect.delete(token, redirect["id"]) 142 | end 143 | end 144 | -------------------------------------------------------------------------------- /test/shopify_api/rest_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.RESTTest do 2 | use ExUnit.Case 3 | 4 | import Bypass, only: [expect_once: 4] 5 | 6 | alias ShopifyAPI.AuthToken 7 | alias ShopifyAPI.REST 8 | alias ShopifyAPI.REST.Request 9 | 10 | defmodule MockAPIResponses do 11 | import Plug.Conn 12 | 13 | # Used to test that the Shopify auth header is set 14 | def assert_auth_header_set(%{req_headers: req_headers} = conn) do 15 | headers = Enum.into(req_headers, %{}) 16 | assert headers["x-shopify-access-token"] == "token" 17 | resp(conn, 200, "{}") 18 | end 19 | 20 | def success(status \\ 200, body \\ "{}"), 21 | do: generate_response(status, body) 22 | 23 | def failure(status \\ 500, body \\ "{}"), 24 | do: generate_response(status, body) 25 | 26 | defp generate_response(status, body) when is_integer(status) and is_binary(body) do 27 | fn conn -> resp(conn, status, body) end 28 | end 29 | end 30 | 31 | setup do 32 | bypass = Bypass.open() 33 | token = %AuthToken{token: "token", shop_name: "localhost:#{bypass.port}"} 34 | {:ok, %{token: token, bypass: bypass}} 35 | end 36 | 37 | test "adds API auth header to outgoing requests", %{bypass: bypass, token: token} do 38 | expect_once( 39 | bypass, 40 | "GET", 41 | "/admin/api/#{Request.version()}/example", 42 | &MockAPIResponses.assert_auth_header_set/1 43 | ) 44 | 45 | assert {:ok, _} = REST.get(token, "example") 46 | end 47 | 48 | describe "GET" do 49 | test "returns ok when returned status code is 200", %{bypass: bypass, token: token} do 50 | expect_once( 51 | bypass, 52 | "GET", 53 | "/admin/api/#{Request.version()}/example", 54 | MockAPIResponses.success() 55 | ) 56 | 57 | assert {:ok, _} = REST.get(token, "example") 58 | end 59 | 60 | test "returns errors from API on non-200 responses", %{bypass: bypass, token: token} do 61 | expect_once( 62 | bypass, 63 | "GET", 64 | "/admin/api/#{Request.version()}/example", 65 | MockAPIResponses.failure() 66 | ) 67 | 68 | assert {:error, %{status_code: 500}} = REST.get(token, "example") 69 | end 70 | end 71 | 72 | describe "POST" do 73 | test "returns ok when returned status code is 201", %{bypass: bypass, token: token} do 74 | expect_once( 75 | bypass, 76 | "POST", 77 | "/admin/api/#{Request.version()}/example", 78 | MockAPIResponses.success(201) 79 | ) 80 | 81 | assert {:ok, _} = REST.post(token, "example", %{}) 82 | end 83 | 84 | test "returns errors from API on non-200 responses", %{bypass: bypass, token: token} do 85 | expect_once( 86 | bypass, 87 | "POST", 88 | "/admin/api/#{Request.version()}/example", 89 | MockAPIResponses.failure(422) 90 | ) 91 | 92 | assert {:error, %{status_code: 422}} = REST.post(token, "example", "") 93 | end 94 | end 95 | 96 | describe "DELETE" do 97 | test "is successful when API returns a 2XX status", %{bypass: bypass, token: token} do 98 | expect_once( 99 | bypass, 100 | "DELETE", 101 | "/admin/api/#{Request.version()}/example", 102 | MockAPIResponses.success(200) 103 | ) 104 | 105 | assert {:ok, _} = REST.delete(token, "example") 106 | end 107 | 108 | test "returns errors from API on non-200 responses", %{bypass: bypass, token: token} do 109 | expect_once( 110 | bypass, 111 | "DELETE", 112 | "/admin/api/#{Request.version()}/example", 113 | MockAPIResponses.failure(404) 114 | ) 115 | 116 | assert {:error, %{status_code: 404}} = REST.delete(token, "example") 117 | end 118 | end 119 | 120 | describe "extract_results_and_next_link" do 121 | test "returns next link if found" do 122 | headers = [{"Link", "; rel=\"next\""}] 123 | response = %HTTPoison.Response{body: "", headers: headers} 124 | {_, next_link} = Request.extract_results_and_next_link(response) 125 | assert next_link == "https://example.com?page=2" 126 | end 127 | 128 | test "returns nil if next link not found" do 129 | headers = [{"Link", "; rel=\"prev\""}] 130 | response = %HTTPoison.Response{body: "", headers: headers} 131 | {_, next_link} = Request.extract_results_and_next_link(response) 132 | assert next_link == nil 133 | end 134 | 135 | test "returns nil if headers are empty" do 136 | response = %HTTPoison.Response{body: "", headers: []} 137 | {_, next_link} = Request.extract_results_and_next_link(response) 138 | assert next_link == nil 139 | end 140 | 141 | test "returns nil if 'Link' header is missing" do 142 | headers = [{"Content-Type", "application/json"}] 143 | response = %HTTPoison.Response{body: "", headers: headers} 144 | {_, next_link} = Request.extract_results_and_next_link(response) 145 | assert next_link == nil 146 | end 147 | end 148 | end 149 | -------------------------------------------------------------------------------- /test/shopify_api/router_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Test.ShopifyAPI.RouterTest do 2 | use ExUnit.Case 3 | 4 | import Plug.Test 5 | 6 | alias ShopifyAPI.{App, Shop} 7 | alias ShopifyAPI.{AppServer, AuthTokenServer, ShopServer} 8 | alias ShopifyAPI.{JSONSerializer, Router, Security} 9 | 10 | alias Plug.{Conn, Parsers} 11 | 12 | @moduletag :capture_log 13 | 14 | def parse(conn, opts \\ []) do 15 | opts = Keyword.put_new(opts, :parsers, [Plug.Parsers.URLENCODED, Plug.Parsers.MULTIPART]) 16 | Parsers.call(conn, Parsers.init(opts)) 17 | end 18 | 19 | @app_name "test" 20 | @client_secret "test" 21 | @nonce "testing" 22 | @redirect_uri "example.com" 23 | @shop_domain "shop.example.com" 24 | 25 | setup_all do 26 | AppServer.set(@app_name, %App{ 27 | auth_redirect_uri: @redirect_uri, 28 | client_secret: @client_secret, 29 | name: @app_name, 30 | nonce: @nonce, 31 | scope: "nothing" 32 | }) 33 | 34 | ShopServer.set(%Shop{domain: @shop_domain}) 35 | :ok 36 | end 37 | 38 | describe "/install" do 39 | test "with a valid app it redirects" do 40 | conn = 41 | :get 42 | |> conn("/install?app=#{@app_name}&shop=#{@shop_domain}") 43 | |> parse() 44 | |> Router.call(%{}) 45 | 46 | assert conn.status == 302 47 | 48 | {"location", redirect_uri} = 49 | Enum.find(conn.resp_headers, fn h -> elem(h, 0) == "location" end) 50 | 51 | assert URI.parse(redirect_uri).host == @shop_domain 52 | end 53 | 54 | test "without a valid app it errors" do 55 | conn = 56 | :get 57 | |> conn("/install?app=not-an-app&shop=#{@shop_domain}") 58 | |> parse() 59 | |> Router.call(%{}) 60 | 61 | assert conn.status == 404 62 | end 63 | end 64 | 65 | describe "/authorized" do 66 | @code "testing" 67 | @token %{access_token: "test-token"} 68 | 69 | setup _contxt do 70 | bypass = Bypass.open() 71 | shop_domain = "localhost:#{bypass.port}" 72 | ShopServer.set(%Shop{domain: shop_domain}) 73 | 74 | {:ok, %{bypass: bypass, shop_domain: shop_domain}} 75 | end 76 | 77 | test "fails with invalid hmac", %{bypass: _bypass, shop_domain: shop_domain} do 78 | conn = 79 | :get 80 | |> conn( 81 | "/authorized/#{@app_name}?shop=#{shop_domain}&code=#{@code}×tamp=1234&hmac=invalid" 82 | ) 83 | |> parse() 84 | |> Router.call(%{}) 85 | 86 | assert conn.status == 404 87 | end 88 | 89 | test "fetches the token", %{bypass: bypass, shop_domain: shop_domain} do 90 | Bypass.expect_once(bypass, "POST", "/admin/oauth/access_token", fn conn -> 91 | {:ok, body} = JSONSerializer.encode(@token) 92 | Conn.resp(conn, 200, body) 93 | end) 94 | 95 | conn = 96 | :get 97 | |> conn( 98 | "/authorized/#{@app_name}?" <> 99 | add_hmac_to_params("code=#{@code}&shop=#{shop_domain}&state=#{@nonce}×tamp=1234") 100 | ) 101 | |> parse() 102 | |> Router.call(%{}) 103 | 104 | assert conn.status == 302 105 | {:ok, %{token: auth_token}} = AuthTokenServer.get(shop_domain, @app_name) 106 | assert auth_token == @token.access_token 107 | end 108 | 109 | test "fails without a valid nonce", %{bypass: _bypass, shop_domain: shop_domain} do 110 | conn = 111 | :get 112 | |> conn( 113 | "/authorized/invalid-app?" <> 114 | add_hmac_to_params("code=#{@code}&shop=#{shop_domain}&state=invalid×tamp=1234") 115 | ) 116 | |> parse() 117 | |> Router.call(%{}) 118 | 119 | assert conn.status == 404 120 | end 121 | 122 | test "fails without a valid app", %{bypass: _bypass, shop_domain: shop_domain} do 123 | conn = 124 | :get 125 | |> conn( 126 | "/authorized/invalid-app?" <> 127 | add_hmac_to_params("code=#{@code}&shop=#{shop_domain}&state=#{@nonce}×tamp=1234") 128 | ) 129 | |> parse() 130 | |> Router.call(%{}) 131 | 132 | assert conn.status == 404 133 | end 134 | 135 | test "fails without a valid shop", %{bypass: _bypass} do 136 | conn = 137 | :get 138 | |> conn( 139 | "/authorized/#{@app_name}?" <> 140 | add_hmac_to_params("code=#{@code}&shop=invalid-shop&state=#{@nonce}×tamp=1234") 141 | ) 142 | |> parse() 143 | |> Router.call(%{}) 144 | 145 | assert conn.status == 404 146 | end 147 | 148 | def add_hmac_to_params(params) do 149 | params <> "&hmac=" <> Security.base16_sha256_hmac(params, @client_secret) 150 | end 151 | end 152 | end 153 | -------------------------------------------------------------------------------- /test/shopify_api/shopify_id_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.ShopifyIdTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias ShopifyAPI.ShopifyId 5 | 6 | @id_types [ 7 | :collection, 8 | :customer, 9 | :delivery_carrier_service, 10 | :delivery_location_group, 11 | :delivery_profile, 12 | :delivery_zone, 13 | :draft_order, 14 | :draft_order_line_item, 15 | :email_template, 16 | :fulfillment, 17 | :fulfillment_event, 18 | :fulfillment_service, 19 | :inventory_item, 20 | :line_item, 21 | :location, 22 | :marketing_event, 23 | :media_image, 24 | :metafield, 25 | :online_store_article, 26 | :online_store_blog, 27 | :online_store_page, 28 | :order, 29 | :order_transaction, 30 | :product, 31 | :product_image, 32 | :product_variant, 33 | :refund, 34 | :shop, 35 | :staff_member, 36 | :theme 37 | ] 38 | 39 | @id Enum.random(1_000_000_000_000..9_999_999_999_999) 40 | @sid Integer.to_string(@id) 41 | @id_type Enum.random(@id_types) 42 | @id_type_string ShopifyId.deatomize_type(@id_type) 43 | @struct_id ShopifyId.new!(@sid, @id_type) 44 | @string_id "gid://shopify/#{@id_type_string}/#{@sid}" 45 | 46 | describe "new/2" do 47 | test "creates a ShopifyId" do 48 | assert {:ok, %ShopifyId{object_type: @id_type, id: @sid}} = ShopifyId.new(@id, @id_type) 49 | assert {:ok, %ShopifyId{object_type: @id_type, id: @sid}} = ShopifyId.new(@sid, @id_type) 50 | 51 | assert {:ok, %ShopifyId{object_type: @id_type, id: @sid}} = 52 | ShopifyId.new(@string_id, @id_type) 53 | end 54 | 55 | test "does NOT create a ShopifyId with mismatched type" do 56 | gid = "gid://shopify/Order/12345" 57 | 58 | assert :error = ShopifyId.new(gid, :customer) 59 | end 60 | end 61 | 62 | describe "new!/2" do 63 | test "creates a ShopifyId" do 64 | assert %ShopifyId{object_type: @id_type, id: @sid} = ShopifyId.new!(@id, @id_type) 65 | assert %ShopifyId{object_type: @id_type, id: @sid} = ShopifyId.new!(@sid, @id_type) 66 | assert %ShopifyId{object_type: @id_type, id: @sid} = ShopifyId.new!(@string_id, @id_type) 67 | end 68 | 69 | test "does NOT create a ShopifyId with mismatched type" do 70 | gid = "gid://shopify/Order/12345" 71 | 72 | assert_raise ArgumentError, fn -> 73 | ShopifyId.new!(gid, :customer) 74 | end 75 | end 76 | end 77 | 78 | describe "stringify/1" do 79 | test "returns a string representation of a ShopifyId" do 80 | assert ShopifyId.stringify(@struct_id) == @string_id 81 | end 82 | end 83 | 84 | describe "Jason" do 85 | test "ShopifyId serializes to json" do 86 | assert Jason.encode!(%{customer_id: @struct_id}) == 87 | ~s({"customer_id":"#{@string_id}"}) 88 | end 89 | end 90 | 91 | defmodule Schema do 92 | use Ecto.Schema 93 | 94 | @primary_key {:id, :binary_id, autogenerate: true} 95 | schema "" do 96 | field(:order_id, ShopifyId, type: :order) 97 | end 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /test/shopify_api/throttled_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Test.ShopifyAPI.ThrottledTest do 2 | use ExUnit.Case 3 | 4 | alias ShopifyAPI.{AuthToken, RateLimiting, Throttled} 5 | 6 | @token %AuthToken{app_name: "test", shop_name: "throttled", plus: false} 7 | 8 | setup do 9 | RateLimiting.RESTTracker.set(@token, 10, 0) 10 | 11 | :ok 12 | end 13 | 14 | def func, do: send(self(), :func_called) 15 | def sleep_impl(_), do: send(self(), :sleep_called) 16 | 17 | defmodule TrackerMock do 18 | def get(_, _), do: {100, 0} 19 | def api_hit_limit(_, _), do: {100, 0} 20 | 21 | def update_api_call_limit(_, _) do 22 | send(self(), :update_api_call_limit_called) 23 | {100, 0} 24 | end 25 | end 26 | 27 | describe "request/6" do 28 | test "recurses when func returns over limit status code" do 29 | func = fn -> 30 | send(self(), :func_called) 31 | {:ok, %{status_code: RateLimiting.REST.over_limit_status_code()}} 32 | end 33 | 34 | max_tries = 2 35 | Throttled.request(func, @token, max_tries, TrackerMock) 36 | for _ <- 1..max_tries, do: assert_received(:func_called) 37 | end 38 | 39 | test "recurses when func returns graphql throttled response" do 40 | func = fn -> 41 | send(self(), :func_called) 42 | {:error, %{body: %{"errors" => [%{"message" => "Throttled"}]}, status_code: 200}} 43 | end 44 | 45 | max_tries = 2 46 | Throttled.request(func, @token, max_tries, TrackerMock) 47 | for _ <- 1..max_tries, do: assert_received(:func_called) 48 | end 49 | 50 | test "updates api call limit and does not recurse when func returns success" do 51 | func = fn -> 52 | send(self(), :func_called) 53 | {:ok, %{status_code: 200}} 54 | end 55 | 56 | Throttled.request(func, @token, TrackerMock) 57 | 58 | assert_receive :update_api_call_limit_called 59 | assert_receive :func_called 60 | refute_receive :func_called 61 | end 62 | end 63 | 64 | describe "make_request/3" do 65 | test "if limit has room call right away" do 66 | Throttled.make_request({1, 0}, &func/0, &sleep_impl/1) 67 | assert_receive :func_called 68 | end 69 | 70 | test "does not sleep if there limit set 0 and there is no availability_delay" do 71 | Throttled.make_request({0, 0}, &func/0, &sleep_impl/1) 72 | refute_receive :sleep_called 73 | end 74 | 75 | test "sleeps if there limit set 0 and there is a availability_delay" do 76 | Throttled.make_request({0, 2_001}, &func/0, &sleep_impl/1) 77 | assert_receive :sleep_called, 2_001 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /test/support/factory.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.Factory do 2 | use ExMachina 3 | 4 | @shopify_app_name "testapp" 5 | @shopify_app_secret "new_secret" 6 | 7 | def shopify_gid(type), do: sequence(type, &"gid://shopify/#{type}/#{&1}") 8 | 9 | def shopify_int_id(type) do 10 | {:ok, shopify_id} = type |> shopify_gid() |> ShopifyAPI.ShopifyId.new(type) 11 | shopify_id.id 12 | end 13 | 14 | def shopify_app_name, do: @shopify_app_name 15 | def shopify_app_secret, do: @shopify_app_secret 16 | 17 | def myshopify_domain, do: Faker.Internet.slug() <> ".myshopify.com" 18 | 19 | def shop_factory do 20 | domain = myshopify_domain() 21 | %ShopifyAPI.Shop{domain: domain} 22 | end 23 | 24 | def app_factory do 25 | %ShopifyAPI.App{ 26 | name: shopify_app_name(), 27 | client_id: "#{__MODULE__}.id", 28 | client_secret: shopify_app_secret() 29 | } 30 | end 31 | 32 | def auth_token_factory(params) do 33 | app_name = Map.get(params, :app_name, shopify_app_name()) 34 | shop_name = Map.get(params, :shop_name, myshopify_domain()) 35 | %ShopifyAPI.AuthToken{app_name: app_name, shop_name: shop_name, token: "test"} 36 | end 37 | 38 | def user_token_factory(params) do 39 | shop_name = Map.get(params, :shop_name, myshopify_domain()) 40 | associated_user_id = String.to_integer(shopify_int_id(:user)) 41 | 42 | %ShopifyAPI.UserToken{ 43 | code: "ef91136f6d56c06c7339664dc51ee24f", 44 | app_name: shopify_app_name(), 45 | shop_name: shop_name, 46 | token: "shpua_8a2deac8ba1176ad2e3ec31652200d19", 47 | timestamp: DateTime.to_unix(DateTime.utc_now()), 48 | plus: false, 49 | scope: "write_customers,write_discounts,read_products", 50 | expires_in: 86_399, 51 | associated_user_scope: "write_customers,write_discounts,read_products", 52 | associated_user: %ShopifyAPI.AssociatedUser{ 53 | id: associated_user_id, 54 | first_name: Faker.Person.first_name(), 55 | last_name: Faker.Person.last_name(), 56 | email: Faker.Internet.email(), 57 | email_verified: true, 58 | account_owner: true, 59 | locale: "en-CA", 60 | collaborator: false 61 | }, 62 | associated_user_id: associated_user_id 63 | } 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /test/support/session_token_setup.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.SessionTokenSetup do 2 | import ShopifyAPI.Factory 3 | 4 | def offline_token(%{shop: %ShopifyAPI.Shop{} = shop}) do 5 | token = build(:auth_token, %{shop_name: shop.domain}) 6 | ShopifyAPI.AuthTokenServer.set(token) 7 | [offline_token: token] 8 | end 9 | 10 | def online_token(%{shop: %ShopifyAPI.Shop{} = shop}) do 11 | token = build(:user_token, %{shop_name: shop.domain}) 12 | ShopifyAPI.UserTokenServer.set(token) 13 | [online_token: token] 14 | end 15 | 16 | def jwt_session_token(%{app: app, shop: shop, online_token: online_token}) do 17 | payload = %{ 18 | "aud" => app.client_id, 19 | "dest" => "http://#{shop.domain}", 20 | "sub" => "#{online_token.associated_user_id}" 21 | } 22 | 23 | jwk = JOSE.JWK.from_oct(app.client_secret) 24 | {_, jwt} = jwk |> JOSE.JWT.sign(%{"alg" => "HS256"}, payload) |> JOSE.JWS.compact() 25 | 26 | [jwt_session_token: jwt] 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /test/support/shopify_validation_setup.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAPI.ShopifyValidationSetup do 2 | @rejected_hmac_keys ["hmac", :hmac, "signature", :signature] 3 | 4 | def params_append_hmac(%ShopifyAPI.App{} = app, %{} = params) do 5 | hmac = 6 | params 7 | |> Enum.reject(fn {key, _} -> Enum.member?(@rejected_hmac_keys, key) end) 8 | |> Enum.sort_by(&elem(&1, 0)) 9 | |> Enum.map_join("&", fn {key, value} -> to_string(key) <> "=" <> value end) 10 | |> ShopifyAPI.Security.base16_sha256_hmac(app.client_secret) 11 | 12 | Map.put(params, :hmac, hmac) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | {:ok, _} = ShopifyAPI.Supervisor.start_link([]) 3 | {:ok, _} = Application.ensure_all_started(:bypass) 4 | {:ok, _} = Application.ensure_all_started(:ex_machina) 5 | --------------------------------------------------------------------------------