المعاملات
- SHOPENTLY_SHOP_URL — عنوان URL للمورد.
- SHOPENTLY_ACCESS_TOKEN — بيانات اعتماد Bearer قصيرة الأجل.
- PRODUCT_ID — معرّف المنتج.
- MARKET_ID — معرّف السوق الذي تعيده نقطة نهاية السياق.
- NEW_AMOUNT — مبلغ عشري ممثل كسلسلة JSON؛ يسمح بالقيمة null فقط حيث يكون ذلك مسموحًا.
- IDEMPOTENCY_KEY — معرّف فريد لعملية كتابة منطقية واحدة.
PHP 8.3 (64-bit, ext-curl, JSON) · Python 3.12 + requests · cURL 8 + jq 1.7.
مثال كامل
cURL
#!/bin/sh
# POSIX shell, curl 8+, jq 1.7. Credentials stay in environment variables.
set -eu
: "${SHOPENTLY_SHOP_URL:?}"
base=${SHOPENTLY_SHOP_URL%/}
case "$base" in https://*) ;; *) echo 'Use an HTTPS origin' >&2; exit 1;; esac
authority=${base#https://}
case "$authority" in ''|*/*|*\?*|*\#*|*@*) echo 'Use an HTTPS origin' >&2; exit 1;; esac
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT HUP INT TERM
chmod 700 "$work"
encode() { printf '%s' "$1" | jq -sRr @uri; }
api() {
method=$1; path=$2; body=${3-}; key=${4-}
scheme=Bearer; credential=${SHOPENTLY_ACCESS_TOKEN-}
if [ "$path" = /api/v1/auth/token ]; then scheme=ApiKey; credential=${SHOPENTLY_API_KEY-}; fi
[ -n "$credential" ] || { echo 'Missing credential' >&2; return 1; }
set -- --disable --globoff --silent --show-error --proto '=https' --connect-timeout 5 --max-time 30 \
--request "$method" --header "Authorization: $scheme $credential" \
--dump-header "$work/headers" --output "$work/body" --write-out '%{http_code}'
if [ -n "$key" ]; then set -- "$@" --header "Idempotency-Key: $key"; fi
if [ -n "$body" ]; then set -- "$@" --header 'Content-Type: application/json' --data-raw "$body"; fi
status=$(curl "$@" --url "$base$path") || return 1
case "$status" in
2??) if [ -s "$work/body" ]; then jq -e . "$work/body"; else printf 'null\n'; fi ;;
*) printf 'HTTP %s\n' "$status" >&2
jq -c '.error | {code, fields}' "$work/body" >&2 2>/dev/null || true
return 1 ;;
esac
}
: "${MARKET_ID:?}" "${PRODUCT_ID:?}" "${NEW_AMOUNT:?}" "${IDEMPOTENCY_KEY:?}"
context=$(api GET /api/v1/context)
market=$(printf '%s' "$context" | jq -ec --arg id "$MARKET_ID" '.data.markets[] | select(.id == $id and .price_list != null)')
base_market=$(printf '%s' "$context" | jq -er '.data.base_market_id')
kind=market_fixed
[ "$MARKET_ID" != "$base_market" ] || kind=base
path="/api/v1/products/$(encode "$PRODUCT_ID")?market_id=$(encode "$MARKET_ID")"
product=$(api GET "$path")
body=$(printf '%s' "$product" | jq -c --argjson market "$market" --arg kind "$kind" --arg amount "$NEW_AMOUNT" '{
expected_version: .data.version, pricing: {
kind: $kind, amount: $amount, currency: $market.price_list.currency,
price_list_id: $market.price_list.id, expected_price_list_version: $market.price_list.version,
expected_version: .data.price_version, tax_included: $market.price_list.tax_included}}')
# Persist this key and body before sending. Retry an uncertain write unchanged.
api PATCH "$path" "$body" "$IDEMPOTENCY_KEY"
PHP
<?php
declare(strict_types=1);
// PHP 8.3+, 64-bit, ext-curl. Credentials stay in environment variables.
function env(string $name): string {
$value = getenv($name);
if ($value === false || $value === '' || strpbrk($value, "\r\n") !== false) {
throw new RuntimeException('Invalid environment variable: ' . $name);
}
return $value;
}
$base = rtrim(env('SHOPENTLY_SHOP_URL'), '/');
$origin = parse_url($base);
if (!$origin || ($origin['scheme'] ?? '') !== 'https' || empty($origin['host']) ||
isset($origin['user']) || isset($origin['pass']) || isset($origin['path']) ||
isset($origin['query']) || isset($origin['fragment']) || PHP_INT_SIZE !== 8) {
throw new RuntimeException('Use a 64-bit PHP runtime and an HTTPS origin');
}
class ApiError extends RuntimeException {
public function __construct(public int $status, public array $headers, public array $error) {
parent::__construct('HTTP ' . $status . '; code=' . ($error['code'] ?? '') .
'; request_id=' . ($headers['x-request-id'] ?? ''));
}
}
function api(string $method, string $path, array $query = [], ?array $body = null, ?string $key = null, bool $apiKey = false): ?array {
global $base;
$credential = env($apiKey ? 'SHOPENTLY_API_KEY' : 'SHOPENTLY_ACCESS_TOKEN');
$headers = ['Authorization: ' . ($apiKey ? 'ApiKey ' : 'Bearer ') . $credential];
if ($key !== null) $headers[] = 'Idempotency-Key: ' . $key;
if ($body !== null) $headers[] = 'Content-Type: application/json';
$url = $base . $path . ($query ? '?' . http_build_query($query, '', '&', PHP_QUERY_RFC3986) : '');
$responseHeaders = [];
$h = curl_init($url);
curl_setopt_array($h, [
CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => false,
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS, CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_TIMEOUT => 30,
CURLOPT_HEADERFUNCTION => static function ($h, string $line) use (&$responseHeaders): int {
if (str_contains($line, ':')) {
[$name, $value] = explode(':', $line, 2);
$responseHeaders[strtolower(trim($name))] = trim($value);
}
return strlen($line);
},
]);
if ($body !== null) {
$json = $apiKey && $body === [] ? '{}' : json_encode($body, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
curl_setopt($h, CURLOPT_POSTFIELDS, $json);
}
$raw = curl_exec($h);
$status = curl_getinfo($h, CURLINFO_RESPONSE_CODE);
$error = curl_error($h);
curl_close($h);
if ($raw === false) throw new RuntimeException('Transport error: ' . $error);
if ($status < 200 || $status >= 300) {
$data = json_decode($raw, true);
throw new ApiError($status, $responseHeaders, is_array($data) ? ($data['error'] ?? []) : []);
}
return $raw === '' ? null : json_decode($raw, true, 512, JSON_THROW_ON_ERROR | JSON_BIGINT_AS_STRING);
}
function output(mixed $value): void {
echo json_encode($value, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), PHP_EOL;
}
$context = api('GET', '/api/v1/context')['data'];
$matches = array_values(array_filter($context['markets'], fn($m) => $m['id'] === env('MARKET_ID')));
if (count($matches) !== 1 || !$matches[0]['price_list']) throw new RuntimeException('Select a market with an active price list');
$market = $matches[0]; $priceList = $market['price_list'];
$path = '/api/v1/products/' . rawurlencode(env('PRODUCT_ID'));
$query = ['market_id' => $market['id']];
$product = api('GET', $path, $query)['data'];
$body = ['expected_version' => $product['version'], 'pricing' => [
'kind' => $market['id'] === $context['base_market_id'] ? 'base' : 'market_fixed',
'amount' => env('NEW_AMOUNT'), 'currency' => $priceList['currency'],
'price_list_id' => $priceList['id'], 'expected_price_list_version' => $priceList['version'],
'expected_version' => $product['price_version'], 'tax_included' => $priceList['tax_included'],
]];
// Persist this key and body before sending. Retry an uncertain write unchanged.
output(api('PATCH', $path, $query, $body, env('IDEMPOTENCY_KEY')));
Python
"""Run with Python 3.12 and requests. Credentials stay in environment variables."""
import json
import os
import random
import time
from urllib.parse import quote, urlencode, urlsplit
import requests
def env(name):
value = os.environ[name]
if not value or "\r" in value or "\n" in value:
raise ValueError("Invalid environment variable: " + name)
return value
base = env("SHOPENTLY_SHOP_URL").rstrip("/")
origin = urlsplit(base)
if origin.scheme != "https" or not origin.hostname or origin.username or origin.password or origin.path or origin.query or origin.fragment:
raise ValueError("SHOPENTLY_SHOP_URL must be an HTTPS origin")
class ApiError(RuntimeError):
def __init__(self, response):
self.status = response.status_code
self.request_id = response.headers.get("X-Request-ID", "")
self.retry_after = response.headers.get("Retry-After", "")
try:
self.error = response.json().get("error", {})
except (ValueError, AttributeError):
self.error = {}
super().__init__(f"HTTP {self.status}; code={self.error.get('code', '')}; request_id={self.request_id}")
def api(method, path, *, query=None, body=None, key=None, api_key=False):
credential = env("SHOPENTLY_API_KEY" if api_key else "SHOPENTLY_ACCESS_TOKEN")
headers = {"Authorization": ("ApiKey " if api_key else "Bearer ") + credential}
if key:
headers["Idempotency-Key"] = key
if body is not None:
headers["Content-Type"] = "application/json"
response = requests.request(method, base + path, params=query, headers=headers,
data=json.dumps(body, ensure_ascii=False, allow_nan=False).encode() if body is not None else None,
timeout=(5, 30), allow_redirects=False)
if not 200 <= response.status_code < 300:
raise ApiError(response)
return response.json() if response.content else None
def segment(value):
return quote(value, safe="")
# The chosen market must expose its own active price list.
context = api("GET", "/api/v1/context")["data"]
market = next(m for m in context["markets"] if m["id"] == env("MARKET_ID"))
price_list = market["price_list"]
if not price_list:
raise ValueError("Selected market has no active price list")
path = "/api/v1/products/" + segment(env("PRODUCT_ID"))
query = {"market_id": market["id"]}
product = api("GET", path, query=query)["data"]
body = {"expected_version": product["version"], "pricing": {
"kind": "base" if market["id"] == context["base_market_id"] else "market_fixed",
"amount": env("NEW_AMOUNT"), "currency": price_list["currency"],
"price_list_id": price_list["id"], "expected_price_list_version": price_list["version"],
"expected_version": product["price_version"], "tax_included": price_list["tax_included"]}}
# Persist this key and body before sending. Retry an uncertain write unchanged.
print(json.dumps(api("PATCH", path, query=query, body=body, key=env("IDEMPOTENCY_KEY"))))
آلية العمل
استخدم POST لإنشاء الموارد المدعومة، وPATCH للتحديثات الجزئية، وDELETE لعمليات الحذف المدعومة. تتطلب كل عملية كتابة على الموارد Idempotency-Key. أنشئ مفتاحًا واحدًا لكل عملية منطقية؛ وكرر المفتاح والطريقة والوجهة والمحتوى نفسها عند إعادة تلك العملية.
اقرأ المورد قبل تعديله، وأرسل expected_version وفق التوثيق. قد تتطلب تغييرات الأسعار أيضًا إصدارات المنتج أو السعر أو أسعار المتغيرات أو قائمة الأسعار. إذا تغير إصدار، فأعد القراءة ووفّق التحديث مع الحالة الحالية بدلًا من فرض بيانات قديمة.
تعيد العملية الناجحة المكررة نتيجتها المخزنة دون تطبيق التغيير مرة أخرى. تؤدي إعادة استخدام مفتاح لطلب مختلف إلى idempotency.key_reused. كل الأمثلة مصطنعة؛ استبدل المعرّفات بقيم مأخوذة من متجرك قبل تنفيذها.