Fix API-Access, add image, price and parameter retrieval (Datasheets not yet implemented because it is not available in the API response)

This commit is contained in:
Marc Kreidler 2025-12-06 23:53:14 +01:00
parent 351078fb43
commit 73ecbb53cf

View file

@ -38,12 +38,13 @@ use Symfony\Contracts\HttpClient\HttpClientInterface;
class BuerklinProvider implements InfoProviderInterface
{
private const ENDPOINT_URL = 'https://buerklin.com/buerklinws/v2/buerklin';
private const ENDPOINT_URL = 'https://www.buerklin.com/buerklinws/v2/buerklin';
public const DISTRIBUTOR_NAME = 'Buerklin';
private const OAUTH_APP_NAME = 'ip_buerklin_oauth';
public function __construct(private readonly HttpClientInterface $client,
public function __construct(
private readonly HttpClientInterface $client,
private readonly OAuthTokenManager $authTokenManager,
private readonly CacheItemPoolInterface $partInfoCache,
#[Autowire(env: "string:PROVIDER_BUERKLIN_CLIENT_ID")]
@ -54,12 +55,11 @@ class BuerklinProvider implements InfoProviderInterface
private readonly string $username = "",
#[Autowire(env: "string:PROVIDER_BUERKLIN_PASSWORD")]
private readonly string $password = "",
#[Autowire(env: "PROVIDER_BUERKLIN_LANGUAGE")]
#[Autowire(env: "string:PROVIDER_BUERKLIN_LANGUAGE")]
private readonly string $language = "en",
#[Autowire(env: "PROVIDER_BUERKLIN_CURRENCY")]
#[Autowire(env: "string:PROVIDER_BUERKLIN_CURRENCY")]
private readonly string $currency = "EUR"
)
{
) {
}
@ -69,34 +69,71 @@ class BuerklinProvider implements InfoProviderInterface
*/
private function getToken(): string
{
if (!$this->authTokenManager->hasToken(self::OAUTH_APP_NAME)) {
$this->authTokenManager->retrieveClientCredentialsToken(self::OAUTH_APP_NAME);
}
$token = $this->authTokenManager->getAlwaysValidTokenString(self::OAUTH_APP_NAME);
if ($token === null) {
throw new \RuntimeException('Could not retrieve OAuth token for Buerklin');
}
return $token;
}
// Cache token to avoid hammering the auth server on every request
$cacheKey = 'buerklin.oauth.token';
$item = $this->partInfoCache->getItem($cacheKey);
if ($item->isHit()) {
$token = $item->get();
if (is_string($token) && $token !== '') {
return $token;
}
}
// Bürklin OAuth2 password grant (ROPC)
$resp = $this->client->request('POST', 'https://www.buerklin.com/authorizationserver/oauth/token/', [
'headers' => [
'Accept' => 'application/json',
'Content-Type' => 'application/x-www-form-urlencoded',
],
'body' => [
'grant_type' => 'password',
'client_id' => $this->clientId,
'client_secret' => $this->secret,
'username' => $this->username,
'password' => $this->password,
],
]);
$data = $resp->toArray(false);
if (!isset($data['access_token'])) {
throw new \RuntimeException(
'Invalid token response from Bürklin: HTTP ' . $resp->getStatusCode() . ' body=' . $resp->getContent(false)
);
}
$token = (string) $data['access_token'];
// Cache for (expires_in - 30s) if available
$ttl = 300;
if (isset($data['expires_in']) && is_numeric($data['expires_in'])) {
$ttl = max(60, (int) $data['expires_in'] - 30);
}
$item->set($token);
$item->expiresAfter($ttl);
$this->partInfoCache->save($item);
return $token;
}
/**
* Make a http get request to the Buerklin API
* @return array
*/
private function makeAPICall(string $endpoint, array $queryParams = []): array
{
try {
$response = $this->client->request('GET', self::ENDPOINT_URL . $endpoint, [
'auth_bearer' => $this->getToken(),
'query' => $queryParams,
'headers' => ['Accept' => 'application/json'],
'query' => array_merge(['curr' => $this->currency ?: 'EUR', 'language' => $this->language ?: 'de'], $queryParams),
]);
return $response->toArray();
} catch (\Exception $e) {
throw new \RuntimeException("Buerklin API request failed: " . $e->getMessage());
throw new \RuntimeException("Buerklin API request failed: " .
"Endpoint: " . $endpoint .
"Token: [redacted] " .
"QueryParams: " . json_encode($queryParams, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . " " .
"Exception message: " . $e->getMessage());
}
}
@ -107,6 +144,7 @@ class BuerklinProvider implements InfoProviderInterface
'name' => 'Buerklin',
'description' => 'This provider uses the Buerklin API to search for parts.',
'url' => 'https://www.buerklin.com/',
'oauth_app_name' => self::OAUTH_APP_NAME,
'disabled_help' => 'Set the environment variables PROVIDER_BUERKLIN_CLIENT_ID, PROVIDER_BUERKLIN_SECRET, PROVIDER_BUERKLIN_USERNAME and PROVIDER_BUERKLIN_PASSWORD.'
];
}
@ -120,7 +158,10 @@ class BuerklinProvider implements InfoProviderInterface
public function isActive(): bool
{
//The client ID has to be set and a token has to be available (user clicked connect)
return $this->clientId !== '' && $this->secret !== '' && $this->username !== '' && $this->password !== '';
return $this->clientId !== ''
&& $this->secret !== ''
&& $this->username !== ''
&& $this->password !== '';
}
/**
@ -129,9 +170,10 @@ class BuerklinProvider implements InfoProviderInterface
*/
private function queryDetail(string $id): PartDetailDTO
{
$arr = $this->makeAPICall('/products', ['sku' => $id]);
$product = $arr['result'] ?? null;
$product = $this->makeAPICall('/products/' . rawurlencode($id) . '/', [
'curr' => $this->currency,
'language' => $this->language,
]);
if ($product === null) {
throw new \RuntimeException('Could not find product code: ' . $id);
}
@ -160,41 +202,96 @@ class BuerklinProvider implements InfoProviderInterface
*/
private function getPartDetail(array $product): PartDetailDTO
{
// Get product images in advance
$product_images = $this->getProductImages($product['images'] ?? null);
$product['productImageUrl'] ??= null;
// If the product does not have a product image but otherwise has attached images, use the first one which should be thumbnail.
if (count($product_images) > 0) {
$product['productImageUrl'] ??= self::ENDPOINT_URL . $product_images[0]->url;
// If this is a search-result object, it may not contain prices/features/images -> reload full detail.
if ((!isset($product['price']) && !isset($product['volumePrices'])) && isset($product['code'])) {
try {
$product = $this->makeAPICall('/products/' . rawurlencode((string) $product['code']) . '/');
} catch (\Throwable $e) {
// If reload fails, keep the partial product data and continue.
}
}
// Find the footprint in classifications->features. en: name='Design'; de: name='Bauform'
if (isset($product['classifications']['features'])) {
foreach ($product['classifications']['features'] as $feature) {
if (isset($feature['name']) && ($feature['name'] === 'Design' || $feature['name'] === 'Bauform')) {
$footprint = $feature['featureValues']['value'] ?? null;
// Images (already absolute + dedup in getProductImages())
$productImages = $this->getProductImages($product['images'] ?? null);
// Preview image: DO NOT prefix ENDPOINT_URL here (images are already absolute)
$preview = $productImages[0]->url ?? null;
// Features live in classifications[0].features in Bürklin JSON
$features = $product['classifications'][0]['features'] ?? [];
$group = $product['classifications'][0]['name'] ?? null;
// 1) Feature-Parameter (aus classifications->features)
$featureParams = $this->attributesToParameters($features, $group);
// 2) Compliance-Parameter (aus Top-Level Feldern wie RoHS/SVHC/…)
$complianceParams = $this->complianceToParameters($product, 'Compliance');
// 3) Zusammenführen
$allParams = array_merge($featureParams, $complianceParams);
// Footprint: "Design" (en) / "Bauform" (de)
$footprint = null;
if (is_array($features)) {
foreach ($features as $feature) {
$name = $feature['name'] ?? null;
if ($name === 'Design' || $name === 'Bauform') {
$footprint = $feature['featureValues'][0]['value'] ?? null;
break;
}
}
}
// Prices: prefer volumePrices, fallback to single price
$code = (string) ($product['orderNumber'] ?? $product['code'] ?? '');
$prices = $product['volumePrices'] ?? null;
if (!is_array($prices) || count($prices) === 0) {
$pVal = $product['price']['value'] ?? null;
$pCur = $product['price']['currencyIso'] ?? ($this->currency ?: 'EUR');
if (is_numeric($pVal)) {
$prices = [
[
'minQuantity' => 1,
'value' => (float) $pVal,
'currencyIso' => (string) $pCur,
]
];
} else {
$prices = [];
}
}
return new PartDetailDTO(
provider_key: $this->getProviderKey(),
provider_id: $product['code'],
name: $product['name'],
description: $this->sanitizeField($product['description']),
category: $this->sanitizeField($product['classifications'][0]['name'] ?? null),
provider_id: (string) ($product['code'] ?? $code),
name: (string) ($product['manufacturerProductId'] ?? $code),
description: $this->sanitizeField($product['description'] ?? null),
category: $this->sanitizeField($product['classifications'][0]['name'] ?? ($product['categories'][0]['name'] ?? null)),
manufacturer: $this->sanitizeField($product['manufacturer'] ?? null),
mpn: $this->sanitizeField($product['manufacturerProductId'] ?? null),
preview_image_url: $product['productImageUrl'],
preview_image_url: $preview,
manufacturing_status: null,
provider_url: $this->getProductShortURL($product['code']),
footprint: $footprint ?? null,
datasheets: null, //datasheet urls not found in API responses
images: $product_images,
parameters: $this->attributesToParameters($product['classifications']['features'] ?? []),
vendor_infos: $this->pricesToVendorInfo($product['code'], $this->getProductShortURL($product['code']), $product['productPriceList'] ?? []),
provider_url: $this->getProductShortURL((string) ($product['code'] ?? $code)),
footprint: $footprint,
datasheets: null, // not in /products/{code}/ JSON; you decided to skip for now
images: $productImages,
parameters: $allParams,
vendor_infos: $this->pricesToVendorInfo(
sku: $code,
url: $this->getProductShortURL($code),
prices: $prices
),
mass: $product['weight'] ?? null,
);
}
@ -208,12 +305,22 @@ class BuerklinProvider implements InfoProviderInterface
*/
private function pricesToVendorInfo(string $sku, string $url, array $prices): array
{
$priceDTOs = array_map(fn($price) => new PriceDTO(
minimum_discount_amount: $price['minQuantity'],
price: $price['value'],
currency_iso_code: $price['currencyIso'],
includes_tax: false
), $prices);
$priceDTOs = array_map(function ($price) {
$val = $price['value'] ?? null;
$valStr = is_numeric($val)
? number_format((float) $val, 6, '.', '') // 6 Nachkommastellen, trailing zeros ok
: (string) $val;
// Optional: weich kürzen (z.B. 75.550000 -> 75.55)
$valStr = rtrim(rtrim($valStr, '0'), '.');
return new PriceDTO(
minimum_discount_amount: (float) ($price['minQuantity'] ?? 1),
price: $valStr,
currency_iso_code: (string) ($price['currencyIso'] ?? $this->currency ?? 'EUR'),
includes_tax: false
);
}, $prices);
return [
new PurchaseInfoDTO(
@ -233,80 +340,214 @@ class BuerklinProvider implements InfoProviderInterface
*/
private function getProductShortURL(string $product_code): string
{
return 'https://www.buerklin.com/de/p/' . $product_code .'/';
return 'https://www.buerklin.com/de/p/' . $product_code . '/';
}
/**
* Returns a FileDTO array with a list of product images
* @param array|null $images
* @return FileDTO[]
* Returns a deduplicated list of product images as FileDTOs.
*
* Bürklin liefert oft mehrere Einträge mit gleicher URL (und verschiedene "format"s).
* Diese Variante:
* - nimmt nur echte URL-Strings
* - macht relative URLs absolut
* - dedupliziert nach URL
* - bevorzugt zoom/product vor thumbnail
*
* @param array|null $images
* @return \App\Services\InfoProviderSystem\DTOs\FileDTO[]
*/
private function getProductImages(?array $images): array
{
return array_map(static fn($image) => new FileDTO($image), $images ?? []);
if (!is_array($images))
return [];
// 1) Nur echte Image-Arrays
$imgs = array_values(array_filter($images, fn($i) => is_array($i) && !empty($i['url'])));
// 2) Bevorzuge zoom; wenn vorhanden, nimm ausschließlich zoom
$zoom = array_values(array_filter($imgs, fn($i) => ($i['format'] ?? null) === 'zoom'));
$chosen = count($zoom) > 0
? $zoom
: array_values(array_filter($imgs, fn($i) => ($i['format'] ?? null) === 'product'));
// 3) Falls auch keine product-Bilder da sind, nimm alles (letzter Fallback)
if (count($chosen) === 0) {
$chosen = $imgs;
}
// 4) Dedupliziere nach URL + relativ -> absolut
$byUrl = [];
foreach ($chosen as $img) {
$url = (string) $img['url'];
if (!str_starts_with($url, 'http://') && !str_starts_with($url, 'https://')) {
$url = 'https://www.buerklin.com' . $url;
}
if (!filter_var($url, FILTER_VALIDATE_URL))
continue;
$byUrl[$url] = $url;
}
return array_map(
fn($url) => new \App\Services\InfoProviderSystem\DTOs\FileDTO($url),
array_values($byUrl)
);
}
/**
* @param array|null $attributes
* @return ParameterDTO[]
*/
private function attributesToParameters(?array $attributes): array
private function attributesToParameters(array $features, ?string $group = null): array
{
$result = [];
foreach ($attributes as $attribute) {
if (!isset($attribute['name'], $attribute['featureValues']['value'])) {
if (!is_array($features)) {
return [];
}
$out = [];
foreach ($features as $f) {
if (!is_array($f))
continue;
}
$value = trim((string)$attribute['featureValues']['value']);
if ($value === '' || $value === '-') {
$name = $f['name'] ?? null;
if (!is_string($name) || trim($name) === '')
continue;
// Bürklin: featureValues ist ein Array von { value: "..." }
$vals = [];
foreach (($f['featureValues'] ?? []) as $fv) {
if (is_array($fv) && isset($fv['value']) && is_string($fv['value']) && trim($fv['value']) !== '') {
$vals[] = trim($fv['value']);
}
}
$result[] = ParameterDTO::parseValueIncludingUnit(
name: $attribute['name'],
if (count($vals) === 0)
continue;
// Mehrfachwerte zusammenführen
$value = implode(', ', array_values(array_unique($vals)));
// Unit/Symbol aus Bürklin (optional)
$unit = $f['featureUnit']['symbol'] ?? null;
if (!is_string($unit) || trim($unit) === '') {
$unit = null;
}
// ParameterDTO kann Zahl/Range/Unit parsing selbst
$out[] = ParameterDTO::parseValueField(
name: $name,
value: $value,
group: null
unit: $unit,
symbol: null,
group: $group
);
}
return $result;
// Dedupe nach Name (falls Bürklin doppelt liefert)
$byName = [];
foreach ($out as $p) {
$byName[$p->name] ??= $p;
}
return array_values($byName);
}
public function searchByKeyword(string $keyword): array
{
$response = $this->makeAPICall('/products/search', [
'curr' => $this->currency,
'language' => $this->language,
'pageSize' => '50',
'currentPage' => '1',
'keyword' => $keyword,
'sort' => 'relevance']);
return array_map(fn($product) => $this->getPartDetail($product), $response['products'] ?? []);
$keyword = strtoupper(trim($keyword));
if ($keyword === '') {
return [];
}
$response = $this->makeAPICall('/products/search/', [
'pageSize' => 50,
'currentPage' => 1,
'query' => $keyword,
'sort' => 'relevance',
]);
$products = $response['products'] ?? [];
// Normalfall: Search liefert Treffer
if (is_array($products) && count($products) > 0) {
return array_map(fn($p) => $this->getPartDetail($p), $products);
}
// Fallback: Bestellnummer/Code direkt abfragen
// (funktioniert bei deinen Postman-Tests für /products/{code}/)
try {
$product = $this->makeAPICall('/products/' . rawurlencode($keyword) . '/');
return [$this->getPartDetail($product)];
} catch (\Throwable $e) {
return [];
}
}
public function getDetails(string $id): PartDetailDTO
{
$response = $this->makeAPICall("/products", ['sku' => $id]);
if (empty($response['result'])) {
throw new \RuntimeException("No part found with ID $id");
}
return $this->getPartDetail($response['result']);
// Detail endpoint is /products/{code}/
$response = $this->makeAPICall('/products/' . rawurlencode($id) . '/', [
'curr' => $this->currency,
'language' => $this->language,
]);
return $this->getPartDetail($response);
}
public function getCapabilities(): array
{
return [
ProviderCapabilities::BASIC,
ProviderCapabilities::FOOTPRINT,
ProviderCapabilities::PICTURE,
ProviderCapabilities::DATASHEET,
ProviderCapabilities::PRICE,
ProviderCapabilities::FOOTPRINT,
];
}
private function complianceToParameters(array $product, ?string $group = 'Compliance'): array
{
$params = [];
$add = function (string $name, $value) use (&$params, $group) {
if ($value === null)
return;
if (is_bool($value)) {
$value = $value ? 'Yes' : 'No';
} elseif (is_array($value) || is_object($value)) {
// avoid dumping huge structures
return;
} else {
$value = trim((string) $value);
if ($value === '')
return;
}
$params[] = ParameterDTO::parseValueField(
name: $name,
value: (string) $value,
unit: null,
symbol: null,
group: $group
);
};
$add('RoHS', $product['labelRoHS'] ?? null); // "Ja"
$add('RoHS date', $product['dateRoHS'] ?? null); // ISO string
$add('SVHC', $product['SVHC'] ?? null); // bool
$add('Hazardous good', $product['hazardousGood'] ?? null); // bool
$add('Hazardous materials', $product['hazardousMaterials'] ?? null); // bool
// Optional, oft nützlich:
$add('Country of origin', $product['countryOfOrigin'] ?? null);
$add('Customs code', $product['articleCustomsCode'] ?? null);
return $params;
}
}