PhoenixKitEcommerce.Shopify.Source (PhoenixKitEcommerce v0.4.2)

Copy Markdown View Source

Picks where a sync reads Shopify product data from: the Admin API (primary, full-fidelity) or the public storefront JSON endpoint (fallback, price-only — see PhoenixKitEcommerce.Shopify.StorefrontClient for why it is deliberately narrow).

Split into a pure decision core (decide/2) and a thin I/O shell (fetch/2). That split is not, on its own, a testability necessity in this repo — test/test_helper.exs excludes DataCase tests only when no test database is reachable, and one normally is (createdb phoenix_kit_ecommerce_test; see that file for details). It earns its place anyway: the fallback/abort decision — which is the one property worth being certain about, since getting it wrong either silently narrows a sync to price-only or leaves it stuck on a dead token — is fully expressible without a domain lookup, an HTTP call, or a database, so keeping it in a pure function makes every branch a plain data-in, data-out assertion instead of an HTTP-stub scenario.

Falling back to the storefront is only correct when the Admin API failure is about the credential — a token that's missing, wrong, or no longer authorized. Anything else (a rate limit, a 5xx, a timeout) is a transient problem with the Admin API itself; falling back on those would silently narrow the sync to price-only and report "no text changes" when the truth is "we could not check". Those abort instead.

Summary

Functions

Decides how to source products, given the Admin API's fetch result and an (already looked-up) shop domain, and what field set the resulting products carry.

Fetches Shopify products for integration_uuid, preferring the Admin API and falling back to the public storefront per decide/2.

Types

result()

@type result() :: %{
  source: :admin | :storefront,
  products: [map()],
  only: [atom()],
  fallback_reason: term() | nil
}

Functions

decide(arg1, arg2)

@spec decide({:ok, [map()]} | {:error, term()}, {:ok, String.t()} | {:error, term()}) ::
  {:use_admin, [map()], [atom()]}
  | {:use_storefront, String.t(), term(), [atom()]}
  | {:abort, term()}

Decides how to source products, given the Admin API's fetch result and an (already looked-up) shop domain, and what field set the resulting products carry.

Pure — no I/O. Rules:

  • Admin succeeded → {:use_admin, products, only}, only being ProductDiff.comparable_fields/0 — the Admin API carries every field.
  • Admin failed with a credential error (:unauthorized, :missing_credentials, :forbidden) and a shop domain is available → {:use_storefront, domain, reason, only}reason is the Admin failure that triggered the fallback, only is [:price].
  • Admin failed with a credential error but no shop domain is available → {:abort, reason} — there is nothing to fall back to.
  • Admin failed with anything else (rate limited, 5xx, timeout, ...) → {:abort, reason}, regardless of whether a domain is available.

fetch(integration_uuid, opts \\ [])

@spec fetch(String.t(), keyword()) :: {:ok, result()} | {:error, term()}

Fetches Shopify products for integration_uuid, preferring the Admin API and falling back to the public storefront per decide/2.

result.only travels with the result so a caller doesn't have to re-derive it: ProductDiff.comparable_fields/0 for the Admin source, [:price] for the storefront source — hand it straight to ProductDiff.diff/4's :only option.

The shop domain is only looked up when the Admin API actually failed — decide/2 never needs it on the success path (see its first clause), so this is meant to spare a successful sync a second PhoenixKit.Integrations.get_credentials/1 call it would otherwise make and discard. That is the intent, not a pinned guarantee: nothing in this suite currently asserts the call count.

Options

  • :admin_options — forwarded to AdminClient.fetch_products/2.
  • :storefront_options — forwarded to StorefrontClient.fetch_products/2.