add Printful provider integration with HTTP client and order routing
Printful HTTP client (v2 + v1 for sync products), Provider behaviour implementation with all callbacks (test_connection, fetch_products, submit_order, get_order_status, fetch_shipping_rates), and multi-provider order routing that looks up the provider connection from the order's product instead of hardcoding "printify". Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
318
lib/simpleshop_theme/clients/printful.ex
Normal file
318
lib/simpleshop_theme/clients/printful.ex
Normal file
@@ -0,0 +1,318 @@
|
||||
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
|
||||
|
||||
# =============================================================================
|
||||
# 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, 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
|
||||
|
||||
# =============================================================================
|
||||
# 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
|
||||
Reference in New Issue
Block a user