Реализация импорта товаров через API поставщика
Импорт через API поставщика — наиболее гибкий и актуальный способ получения данных. Вместо файловых выгрузок данные забираются напрямую из системы поставщика по расписанию или по событию. Сложность в том, что у каждого поставщика свой API: разные форматы аутентификации, структуры ответов и модели пагинации.
Типы API поставщиков
| Тип | Пример | Особенности |
|---|---|---|
| REST JSON | Большинство современных | Пагинация cursor/offset, JWT/API-key |
| REST XML | Старые системы (1С) | Нужен XML-парсер ответа |
| SOAP | Корпоративные ERP | WSDL, SOAPClient |
| GraphQL | Редко у поставщиков | Гибкий выбор полей |
| oData | SAP, Microsoft | $filter, $top, $skip |
Базовый клиент с retry и rate limiting
class SupplierApiClient
{
private \GuzzleHttp\Client $http;
private RateLimiter $rateLimiter;
public function __construct(
private SupplierApiConfig $config,
) {
$this->http = new \GuzzleHttp\Client([
'base_uri' => $config->baseUrl,
'timeout' => 30,
'handler' => $this->buildHandlerStack(),
]);
}
private function buildHandlerStack(): \GuzzleHttp\HandlerStack
{
$stack = \GuzzleHttp\HandlerStack::create();
$stack->push(\GuzzleHttp\Middleware::retry(
function (int $retries, $request, $response, $exception) {
if ($retries >= 3) return false;
if ($exception instanceof \GuzzleHttp\Exception\ConnectException) return true;
if ($response && $response->getStatusCode() >= 500) return true;
return false;
},
fn(int $retries) => 1000 * (2 ** $retries) // экспоненциальный backoff
));
return $stack;
}
public function get(string $path, array $params = []): array
{
// Rate limiting: не более N запросов в секунду
$this->rateLimiter->throttle($this->config->id, $this->config->rateLimit);
$response = $this->http->get($path, [
'query' => $params,
'headers' => $this->buildHeaders(),
]);
return json_decode($response->getBody(), true);
}
private function buildHeaders(): array
{
return match ($this->config->authType) {
'bearer' => ['Authorization' => 'Bearer ' . $this->config->token],
'api_key' => ['X-API-Key' => $this->config->apiKey],
'basic' => ['Authorization' => 'Basic ' . base64_encode(
$this->config->login . ':' . $this->config->password
)],
default => [],
};
}
}
Модели пагинации
Offset-пагинация
public function fetchAllProducts(): iterable
{
$page = 1;
$perPage = 100;
do {
$response = $this->client->get('/products', [
'page' => $page,
'per_page' => $perPage,
]);
foreach ($response['data'] as $item) {
yield $item;
}
$hasMore = count($response['data']) === $perPage;
$page++;
} while ($hasMore);
}
Cursor-пагинация (эффективнее для больших таблиц)
public function fetchAllProducts(): iterable
{
$cursor = null;
do {
$params = ['limit' => 200];
if ($cursor) $params['cursor'] = $cursor;
$response = $this->client->get('/v2/products', $params);
foreach ($response['items'] as $item) {
yield $item;
}
$cursor = $response['next_cursor'] ?? null;
} while ($cursor);
}
Scroll / stream (для больших выгрузок)
Некоторые поставщики поддерживают нстримовую выгрузку — один запрос, ответ в несколько мегабайт с построчным NDJSON (Newline Delimited JSON):
$response = $this->http->get('/products/export', ['stream' => true]);
$body = $response->getBody();
while (!$body->eof()) {
$line = $this->readLine($body);
if ($line) yield json_decode($line, true);
}
OAuth 2.0 авторизация
Ряд поставщиков требует OAuth 2.0 client credentials:
class OAuth2TokenProvider
{
private ?string $accessToken = null;
private ?int $expiresAt = null;
public function getToken(): string
{
if ($this->accessToken && time() < ($this->expiresAt - 60)) {
return $this->accessToken;
}
$response = Http::asForm()->post($this->tokenUrl, [
'grant_type' => 'client_credentials',
'client_id' => $this->clientId,
'client_secret' => $this->clientSecret,
'scope' => 'products:read stocks:read',
]);
$data = $response->json();
$this->accessToken = $data['access_token'];
$this->expiresAt = time() + $data['expires_in'];
return $this->accessToken;
}
}
Токен кэшируется до истечения — не делать лишних запросов на авторизацию.
Нормализация разных структур ответа
Каждый поставщик имеет своё JSON-поле:
class SupplierResponseNormalizer
{
private array $fieldMap;
public function normalize(array $raw): array
{
return [
'sku' => $this->extract($raw, $this->fieldMap['sku']),
'name' => $this->extract($raw, $this->fieldMap['name']),
'price' => (float) $this->extract($raw, $this->fieldMap['price']),
'qty' => (int) $this->extract($raw, $this->fieldMap['qty']),
'description' => $this->extract($raw, $this->fieldMap['description']),
'images' => $this->extractImages($raw),
];
}
private function extract(array $data, string $path): mixed
{
// Поддержка dot-notation: "product.details.sku"
return data_get($data, $path);
}
}
Конфиг fieldMap хранится в БД как JSON — добавление нового поставщика без изменения кода.
Инкрементальная синхронизация
Самый ценный режим — получать только изменения с момента последней синхронизации:
public function fetchUpdatedSince(\DateTimeInterface $since): iterable
{
return $this->fetchAllProducts([
'updated_after' => $since->format(DATE_ATOM),
'fields' => 'sku,price,qty,name,description',
]);
}
Время последней успешной синхронизации хранится в supplier_sync_log:
SELECT MAX(completed_at) FROM supplier_sync_log
WHERE supplier_id = $1 AND status = 'success';
SOAP-клиент для 1С-совместимых поставщиков
$client = new \SoapClient($this->wsdlUrl, [
'login' => $this->login,
'password' => $this->password,
'encoding' => 'UTF-8',
'soap_version' => SOAP_1_2,
'cache_wsdl' => WSDL_CACHE_DISK,
]);
$result = $client->GetProductList([
'DateFrom' => $since->format('Y-m-d\TH:i:s'),
'Categories' => $this->categoryFilter,
]);
foreach ($result->Products->Product as $product) {
yield (array) $product;
}
Сроки реализации
- Один REST-поставщик, offset-пагинация, нормализация, импорт — 2 дня
- OAuth 2.0, cursor-пагинация, инкрементальная синхронизация — +1 день
- Мультипоставщик через конфиг, SOAP, rate limiting, retry — +2 дня







