Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kody/shopify

src/lib/client.ts

498 lines · 14.3 KB · TypeScript
import { kody, packageStorage } from 'kody:runtime'
import type { ShopifyClientOptions, ShopifyPageInfo } from '../types.ts'
import {
	clampInt,
	DEFAULT_API_VERSION,
	normalizeShop,
	resolveApiVersion,
	shopOrigin,
	trimString,
} from './shop.ts'

export const ADMIN_ACCESS_TOKEN_SECRET = 'shopifyAdminAccessToken'
export const CLIENT_ID_SECRET = 'shopifyClientId'
export const CLIENT_SECRET_SECRET = 'shopifyClientSecret'
export const DEFAULT_SHOP_KEY = 'defaultShop'
export const DEFAULT_API_VERSION_KEY = 'defaultApiVersion'

const ADMIN_TOKEN_PLACEHOLDER = '{{secret:shopifyAdminAccessToken}}'
const CLIENT_ID_PLACEHOLDER = '{{secret:shopifyClientId}}'
const CLIENT_SECRET_PLACEHOLDER = '{{secret:shopifyClientSecret}}'

export class ShopifyApiError extends Error {
	status: number
	body: unknown
	shop: string
	path: string

	constructor(
		status: number,
		body: unknown,
		shop: string,
		path: string,
		message?: string,
	) {
		const detail = extractErrorDetail(body)
		const suffix = detail ? `: ${detail}` : ''
		const rateNote = status === 429 ? ' (rate limited — retry with backoff)' : ''
		super(message || `Shopify API ${status} ${path}${suffix}${rateNote}`)
		this.name = 'ShopifyApiError'
		this.status = status
		this.body = body
		this.shop = shop
		this.path = path
	}
}

function extractErrorDetail(body: unknown): string {
	if (!body) return ''
	if (typeof body === 'string') return body.slice(0, 400)
	if (typeof body !== 'object') return ''
	const record = body as Record<string, unknown>
	if (typeof record.errors === 'string') return record.errors
	if (Array.isArray(record.errors)) {
		return record.errors
			.map((item) => {
				if (typeof item === 'string') return item
				if (item && typeof item === 'object' && 'message' in item) {
					return String((item as { message: unknown }).message)
				}
				return JSON.stringify(item)
			})
			.join('; ')
			.slice(0, 400)
	}
	if (typeof record.message === 'string') return record.message
	return ''
}

function secretEntries(
	result: unknown,
): Array<{ name?: string; scope?: string }> {
	if (
		result &&
		typeof result === 'object' &&
		Array.isArray((result as { secrets?: unknown }).secrets)
	) {
		return (result as { secrets: Array<{ name?: string; scope?: string }> })
			.secrets
	}
	return Array.isArray(result) ? result : []
}

export async function listUserSecretNames(): Promise<Set<string>> {
	const listed = await kody.secret_list({ scope: 'user' })
	const names = new Set<string>()
	for (const entry of secretEntries(listed)) {
		if (entry?.name && (entry.scope === 'user' || !entry.scope)) {
			names.add(entry.name)
		}
	}
	return names
}

export type ShopifyAuthMode = 'admin-token' | 'client-credentials'

export async function resolveAuthMode(): Promise<ShopifyAuthMode> {
	const names = await listUserSecretNames()
	if (names.has(ADMIN_ACCESS_TOKEN_SECRET)) return 'admin-token'
	if (names.has(CLIENT_ID_SECRET) && names.has(CLIENT_SECRET_SECRET)) {
		return 'client-credentials'
	}
	throw new Error(
		[
			'Shopify credentials are missing.',
			`Save either ${ADMIN_ACCESS_TOKEN_SECRET} (legacy custom-app Admin API token)`,
			`or both ${CLIENT_ID_SECRET} and ${CLIENT_SECRET_SECRET} (Dev Dashboard client credentials).`,
			'Setup: https://kody.codes/account/secrets/new?name=shopifyClientSecret&description=Shopify%20Dev%20Dashboard%20client%20secret&scope=user',
			'Docs: https://shopify.dev/docs/apps/build/dev-dashboard/get-api-access-tokens',
		].join(' '),
	)
}

async function readStorageString(key: string): Promise<string | null> {
	try {
		const store = packageStorage()
		const raw = await store.get(key)
		if (typeof raw === 'string' && raw.trim()) return raw.trim()
		return null
	} catch {
		return null
	}
}

export async function resolveShop(shop?: unknown): Promise<string> {
	if (trimString(shop)) return normalizeShop(shop)
	const stored = await readStorageString(DEFAULT_SHOP_KEY)
	if (stored) return normalizeShop(stored)
	throw new Error(
		'Shop is required. Pass shop: "your-store", or after forking call setDefaultShop from kody:@kody/shopify/config.',
	)
}

export async function resolveVersion(apiVersion?: unknown): Promise<string> {
	if (trimString(apiVersion)) return resolveApiVersion(apiVersion)
	const stored = await readStorageString(DEFAULT_API_VERSION_KEY)
	return resolveApiVersion(stored || DEFAULT_API_VERSION)
}

async function exchangeClientCredentials(shop: string): Promise<string> {
	const url = `${shopOrigin(shop)}/admin/oauth/access_token`
	const body = new URLSearchParams({
		grant_type: 'client_credentials',
		client_id: CLIENT_ID_PLACEHOLDER,
		client_secret: CLIENT_SECRET_PLACEHOLDER,
	})
	const response = await fetch(url, {
		method: 'POST',
		headers: {
			Accept: 'application/json',
			'Content-Type': 'application/x-www-form-urlencoded',
			'User-Agent': 'kody-shopify/1.0',
		},
		body,
	})
	const text = await response.text()
	let parsed: unknown = text
	try {
		parsed = text ? JSON.parse(text) : null
	} catch {
		parsed = text
	}
	if (!response.ok) {
		throw new ShopifyApiError(
			response.status,
			parsed,
			shop,
			'/admin/oauth/access_token',
			'Shopify client-credentials token exchange failed',
		)
	}
	const token =
		parsed &&
		typeof parsed === 'object' &&
		typeof (parsed as { access_token?: unknown }).access_token === 'string'
			? (parsed as { access_token: string }).access_token
			: ''
	if (!token) {
		throw new Error(
			'Shopify client-credentials response did not include access_token.',
		)
	}
	return token
}

export async function resolveAccessToken(shop: string): Promise<string> {
	const mode = await resolveAuthMode()
	if (mode === 'admin-token') return ADMIN_TOKEN_PLACEHOLDER
	return await exchangeClientCredentials(shop)
}

export type ShopifyRequestInput = ShopifyClientOptions & {
	/** Admin path (`/products.json`) or absolute shop URL under /admin/. */
	path: string
	method?: string
	query?: Record<string, string | number | boolean | undefined | null>
	body?: unknown
	headers?: Record<string, string>
	maxAttempts?: number
}

export type ShopifyRequestResult = {
	status: number
	ok: boolean
	body: unknown
	headers: Record<string, string>
	shop: string
	url: string
}

function assertAdminPath(path: string): string {
	const trimmed = trimString(path)
	if (!trimmed) throw new Error('path is required.')
	if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
		const url = new URL(trimmed)
		if (!url.hostname.endsWith('.myshopify.com')) {
			throw new Error('Absolute Shopify URLs must use a *.myshopify.com host.')
		}
		if (!url.pathname.startsWith('/admin/')) {
			throw new Error('Absolute Shopify URLs must stay under /admin/.')
		}
		return trimmed
	}
	const withSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}`
	if (withSlash.startsWith('/admin/')) return withSlash
	return withSlash
}

function sleep(ms: number): Promise<void> {
	return new Promise((resolve) => setTimeout(resolve, ms))
}

function parseRetryMs(response: Response, attempt: number): number {
	const retryAfter = response.headers.get('retry-after')
	if (retryAfter) {
		if (Number.isFinite(Number(retryAfter))) return Number(retryAfter) * 1000
		const until = Date.parse(retryAfter)
		if (Number.isFinite(until)) return Math.max(0, until - Date.now())
	}
	return Math.min(8000, 400 * 2 ** (attempt - 1))
}

function headerRecord(headers: Headers): Record<string, string> {
	const out: Record<string, string> = {}
	headers.forEach((value, key) => {
		out[key] = value
	})
	return out
}

function parseBody(text: string, contentType: string): unknown {
	if (!text.trim()) return null
	if (contentType.includes('application/json')) {
		try {
			return JSON.parse(text)
		} catch {
			return text
		}
	}
	const trimmed = text.trim()
	if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
		try {
			return JSON.parse(trimmed)
		} catch {
			return text
		}
	}
	return text
}

/** Compact authenticated request to a shop Admin API (REST or absolute /admin URL). */
export async function shopifyRequestRaw(
	input: ShopifyRequestInput,
): Promise<ShopifyRequestResult> {
	const shop = await resolveShop(input.shop)
	const version = await resolveVersion(input.apiVersion)
	const token = await resolveAccessToken(shop)
	const origin = shopOrigin(shop)
	const rawPath = assertAdminPath(input.path)
	const url = rawPath.startsWith('http')
		? new URL(rawPath)
		: new URL(
				rawPath.startsWith('/admin/')
					? rawPath
					: `/admin/api/${version}${rawPath.startsWith('/') ? rawPath : `/${rawPath}`}`,
				origin,
			)
	if (url.origin !== origin || !url.pathname.startsWith('/admin/')) {
		throw new Error('Shopify requests must stay on this shop\'s /admin/ origin.')
	}
	if (input.query) {
		for (const [key, value] of Object.entries(input.query)) {
			if (value === undefined || value === null || value === '') continue
			url.searchParams.set(key, String(value))
		}
	}

	const maxAttempts = clampInt(input.maxAttempts, 1, 5, 3)
	let last: ShopifyRequestResult | null = null
	for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
		const headers = new Headers({
			Accept: 'application/json',
			'X-Shopify-Access-Token': token,
			'User-Agent': 'kody-shopify/1.0',
		})
		if (input.headers) {
			for (const [key, value] of Object.entries(input.headers)) {
				if (value) headers.set(key, value)
			}
		}
		const init: RequestInit = {
			method: trimString(input.method) || 'GET',
			headers,
		}
		if (input.body !== undefined) {
			if (!headers.has('Content-Type')) {
				headers.set('Content-Type', 'application/json')
			}
			init.body =
				typeof input.body === 'string'
					? input.body
					: JSON.stringify(input.body)
		}

		const response = await fetch(url.toString(), init)
		const text = await response.text()
		const body = parseBody(text, response.headers.get('content-type') || '')
		last = {
			status: response.status,
			ok: response.ok,
			body,
			headers: headerRecord(response.headers),
			shop,
			url: url.toString(),
		}
		if (response.ok) return last
		if (
			!(response.status === 429 || response.status >= 500) ||
			attempt >= maxAttempts
		) {
			throw new ShopifyApiError(response.status, body, shop, url.pathname)
		}
		await sleep(parseRetryMs(response, attempt))
	}
	throw new ShopifyApiError(last?.status || 0, last?.body, shop, url.pathname)
}

export async function shopifyRest<T = unknown>(
	input: ShopifyRequestInput,
): Promise<T> {
	const result = await shopifyRequestRaw(input)
	return result.body as T
}

export type ShopifyGraphqlInput = ShopifyClientOptions & {
	query: string
	variables?: Record<string, unknown>
}

export type ShopifyGraphqlResult<T = unknown> = {
	data: T
	errors?: Array<{ message: string; extensions?: Record<string, unknown> }>
	extensions?: {
		cost?: {
			requestedQueryCost?: number
			actualQueryCost?: number
			throttleStatus?: {
				maximumAvailable?: number
				currentlyAvailable?: number
				restoreRate?: number
			}
		}
	}
	shop: string
}

export async function shopifyGraphqlRaw<T = unknown>(
	input: ShopifyGraphqlInput,
): Promise<ShopifyGraphqlResult<T>> {
	const query = trimString(input.query)
	if (!query) throw new Error('GraphQL query is required.')
	const shop = await resolveShop(input.shop)
	const version = await resolveVersion(input.apiVersion)
	const result = await shopifyRequestRaw({
		shop,
		apiVersion: version,
		path: `/admin/api/${version}/graphql.json`,
		method: 'POST',
		body: {
			query,
			variables: input.variables || {},
		},
	})
	const payload = (result.body || {}) as {
		data?: T
		errors?: Array<{ message: string; extensions?: Record<string, unknown> }>
		extensions?: ShopifyGraphqlResult['extensions']
	}
	return {
		data: (payload.data ?? null) as T,
		errors: payload.errors,
		extensions: payload.extensions,
		shop,
	}
}

export async function shopifyGraphql<T = unknown>(
	input: ShopifyGraphqlInput,
): Promise<T> {
	const result = await shopifyGraphqlRaw<T>(input)
	if (result.errors?.length) {
		const first = result.errors[0]
		const code =
			first.extensions && typeof first.extensions.code === 'string'
				? ` [${first.extensions.code}]`
				: ''
		throw new ShopifyApiError(
			200,
			result.errors,
			result.shop,
			'/graphql.json',
			`Shopify GraphQL error${code}: ${first.message}`,
		)
	}
	return result.data
}

export function pageInfoFromConnection(value: unknown): ShopifyPageInfo {
	const info =
		value && typeof value === 'object'
			? ((value as { pageInfo?: Record<string, unknown> }).pageInfo ?? {})
			: {}
	return {
		hasNextPage: Boolean(info.hasNextPage),
		hasPreviousPage: Boolean(info.hasPreviousPage),
		startCursor: typeof info.startCursor === 'string' ? info.startCursor : null,
		endCursor: typeof info.endCursor === 'string' ? info.endCursor : null,
	}
}

export function nodesFromConnection<T>(value: unknown): T[] {
	if (!value || typeof value !== 'object') return []
	const record = value as { nodes?: unknown; edges?: unknown }
	if (Array.isArray(record.nodes)) return record.nodes as T[]
	if (Array.isArray(record.edges)) {
		return record.edges
			.map((edge) =>
				edge && typeof edge === 'object'
					? ((edge as { node?: T }).node as T)
					: undefined,
			)
			.filter((node): node is T => node !== undefined)
	}
	return []
}

export function moneyFromSet(value: unknown): { amount: string; currencyCode: string } | null {
	if (!value || typeof value !== 'object') return null
	const shopMoney = (value as { shopMoney?: unknown }).shopMoney
	if (!shopMoney || typeof shopMoney !== 'object') return null
	const amount = trimString((shopMoney as { amount?: unknown }).amount)
	const currencyCode = trimString(
		(shopMoney as { currencyCode?: unknown }).currencyCode,
	)
	if (!amount || !currencyCode) return null
	return { amount, currencyCode }
}

export function userErrorsFrom(value: unknown): Array<{
	field: Array<string> | null
	message: string
}> {
	if (!value || typeof value !== 'object') return []
	const errors = (value as { userErrors?: unknown }).userErrors
	if (!Array.isArray(errors)) return []
	return errors.map((error) => {
		const record = error && typeof error === 'object' ? error : {}
		const field = Array.isArray((record as { field?: unknown }).field)
			? ((record as { field: Array<unknown> }).field.map(String) as Array<string>)
			: null
		return {
			field,
			message: trimString((record as { message?: unknown }).message) || 'Unknown user error',
		}
	})
}

export function assertNoUserErrors(
	payload: unknown,
	label: string,
): void {
	const errors = userErrorsFrom(payload)
	if (!errors.length) return
	throw new Error(
		`${label} failed: ${errors.map((error) => error.message).join('; ')}`,
	)
}

export { clampInt, trimString }