defmodule SimpleshopTheme.Clients.Printful do @moduledoc """ HTTP client for the Printful API. Uses v2 endpoints where available, v1 for sync products (store products). Handles authentication via Bearer tokens stored in the process dictionary. """ @base_url "https://api.printful.com" # ============================================================================= # Auth # ============================================================================= @doc """ Get the API token. Checks process dictionary first (for provider connections with stored credentials), then falls back to environment variable (for development/testing). """ def api_token do Process.get(:printful_api_key) || System.get_env("PRINTFUL_API_TOKEN") || raise "PRINTFUL_API_TOKEN environment variable is not set" end @doc """ Get the store ID from the process dictionary. """ def store_id do Process.get(:printful_store_id) end # ============================================================================= # Core HTTP # ============================================================================= @doc """ Make a GET request to the Printful API. """ def get(path, _opts \\ []) do url = @base_url <> path case Req.get(url, headers: auth_headers(), receive_timeout: 30_000) do {:ok, %Req.Response{status: status, body: body}} when status in 200..299 -> {:ok, unwrap_response(path, body)} {:ok, %Req.Response{status: status, body: body}} -> {:error, {status, body}} {:error, reason} -> {:error, reason} end end @doc """ Make a POST request to the Printful API. """ def post(path, body, _opts \\ []) do url = @base_url <> path case Req.post(url, json: body, headers: auth_headers(), receive_timeout: 60_000) do {:ok, %Req.Response{status: status, body: body}} when status in 200..299 -> {:ok, unwrap_response(path, body)} {:ok, %Req.Response{status: status, body: body}} -> {:error, {status, body}} {:error, reason} -> {:error, reason} end end @doc """ Make a DELETE request to the Printful API. """ def delete(path, _opts \\ []) do url = @base_url <> path case Req.delete(url, headers: auth_headers(), receive_timeout: 30_000) do {:ok, %Req.Response{status: status, body: body}} when status in 200..299 -> {:ok, unwrap_response(path, body)} {:ok, %Req.Response{status: 204}} -> {:ok, nil} {:ok, %Req.Response{status: status, body: body}} -> {:error, {status, body}} {:error, reason} -> {:error, reason} end end # v1 responses wrap data in "result", v2 wraps in "data" defp unwrap_response("/v2/" <> _, %{"data" => data}), do: data defp unwrap_response(_path, %{"code" => _, "result" => result}), do: result defp unwrap_response(_path, body), do: body # ============================================================================= # Stores (v2) # ============================================================================= @doc """ List all stores for the authenticated account. """ def get_stores do get("/v2/stores") end @doc """ Get the first store's ID. """ def get_store_id do case get_stores() do {:ok, stores} when is_list(stores) and stores != [] -> {:ok, hd(stores)["id"]} {:ok, []} -> {:error, :no_stores} error -> error end end # ============================================================================= # Catalogue (v2) # ============================================================================= @doc """ Get a catalogue product by ID. """ def get_catalog_product(product_id) do get("/v2/catalog-products/#{product_id}") end @doc """ Get variants for a catalogue product. """ def get_catalog_variants(product_id) do get("/v2/catalog-products/#{product_id}/catalog-variants") end @doc """ Get stock availability for a catalogue product. """ def get_product_availability(product_id) do get("/v2/catalog-products/#{product_id}/availability") end # ============================================================================= # Sync Products (v1 — no v2 equivalent) # ============================================================================= @doc """ List the seller's configured products (sync products). These are products the seller has set up in Printful's dashboard with designs. Supports pagination via `offset` and `limit` options. """ def list_sync_products(opts \\ []) do offset = Keyword.get(opts, :offset, 0) limit = Keyword.get(opts, :limit, 20) get("/store/products?offset=#{offset}&limit=#{limit}") end @doc """ Get a single sync product with all its variants and files. """ def get_sync_product(product_id) do get("/store/products/#{product_id}") end @doc """ Create a sync product with variants and design files. ## Example create_sync_product(%{ sync_product: %{name: "My T-Shirt"}, sync_variants: [%{ variant_id: 4011, retail_price: "29.99", files: [%{url: "https://example.com/design.png", type: "default"}] }] }) """ def create_sync_product(product_data) do post("/store/products", product_data) end @doc """ Delete a sync product and all its variants. """ def delete_sync_product(product_id) do delete("/store/products/#{product_id}") end # ============================================================================= # Shipping (v2) # ============================================================================= @doc """ Calculate shipping rates for a set of items to a recipient. ## Example calculate_shipping( %{country_code: "GB"}, [%{source: "catalog", catalog_variant_id: 474, quantity: 1}] ) """ def calculate_shipping(recipient, items) do post("/v2/shipping-rates", %{recipient: recipient, order_items: items}) end # ============================================================================= # Orders (v2) # ============================================================================= @doc """ Create a new order (draft status). """ def create_order(order_data) do post("/v2/orders", order_data) end @doc """ Confirm an order for fulfilment. This triggers charges and production. """ def confirm_order(order_id) do post("/v2/orders/#{order_id}/confirmation", %{}) end @doc """ Get an order by ID. """ def get_order(order_id) do get("/v2/orders/#{order_id}") end @doc """ Get shipments for an order. """ def get_order_shipments(order_id) do get("/v2/orders/#{order_id}/shipments") end # ============================================================================= # Mockups (v2) # ============================================================================= @doc """ Create a mockup generation task. """ def create_mockup_task(body) do post("/v2/mockup-tasks", body) end @doc """ Get mockup task results. Pass `task_id` to poll a specific task. """ def get_mockup_tasks(params \\ %{}) do query = URI.encode_query(params) path = if query == "", do: "/v2/mockup-tasks", else: "/v2/mockup-tasks?#{query}" get(path) end # ============================================================================= # Mockup generator (legacy, multi-angle) # ============================================================================= @doc """ Create a mockup generator task for a catalog product. Returns `{:ok, %{"task_key" => "gt-...", "status" => "pending"}}`. """ def create_mockup_generator_task(catalog_product_id, body) do post("/mockup-generator/create-task/#{catalog_product_id}", body) end @doc """ Poll a mockup generator task by task key. Returns `{:ok, %{"status" => "completed", "mockups" => [...]}}` when done. """ def get_mockup_generator_task(task_key) do get("/mockup-generator/task?task_key=#{task_key}") end # ============================================================================= # Files (v2) # ============================================================================= @doc """ Upload a file to the Printful file library. """ def upload_file(url) do post("/v2/files", %{url: url}) end # ============================================================================= # Webhooks (v2) # ============================================================================= @doc """ Set up webhook configuration. """ def setup_webhooks(url, events) do post("/v2/webhooks", %{url: url, events: events}) end @doc """ Get current webhook configuration. """ def get_webhooks do get("/v2/webhooks") end @doc """ Disable webhook support. """ def delete_webhooks do delete("/v2/webhooks") end # ============================================================================= # File Downloads # ============================================================================= @doc """ Download a file from a URL to a local path. """ def download_file(url, output_path) do case Req.get(url, into: File.stream!(output_path), receive_timeout: 60_000) do {:ok, %Req.Response{status: status}} when status in 200..299 -> {:ok, output_path} {:ok, %Req.Response{status: status}} -> File.rm(output_path) {:error, {:http_error, status}} {:error, reason} -> File.rm(output_path) {:error, reason} end end # ============================================================================= # Private # ============================================================================= defp auth_headers do headers = [ {"Authorization", "Bearer #{api_token()}"}, {"Content-Type", "application/json"} ] case store_id() do nil -> headers id -> [{"X-PF-Store-Id", to_string(id)} | headers] end end end