. */ declare(strict_types=1); namespace App\Services\InfoProviderSystem\Providers; use App\Entity\Parts\ManufacturingStatus; use App\Services\InfoProviderSystem\DTOs\FileDTO; use App\Services\InfoProviderSystem\DTOs\ParameterDTO; use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; use App\Services\InfoProviderSystem\DTOs\PriceDTO; use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO; use App\Services\OAuth\OAuthTokenManager; use Psr\Cache\CacheItemPoolInterface; use Symfony\Component\HttpClient\HttpOptions; use Symfony\Contracts\HttpClient\HttpClientInterface; /** * This class implements the Octopart/Nexar API as an InfoProvider * * As the limits for Octopart are quite limited, we use an additional layer of caching here, we get the full parts during a search * and cache them, so we can use them for the detail view without having to query the API again. */ class OctopartProvider implements InfoProviderInterface { private const OAUTH_APP_NAME = 'ip_octopart_oauth'; /** * This defines what fields are returned in the answer from the Octopart API */ private const GRAPHQL_PART_SECTION = <<<'GRAPHQL' { id mpn octopartUrl manufacturer { name } shortDescription category { ancestors { name } name } bestImage { url } bestDatasheet { url name } manufacturerUrl medianPrice1000 { price currency quantity } sellers(authorizedOnly: $authorizedOnly) { company { name } isAuthorized offers { clickUrl inventoryLevel moq sku packaging prices { price currency quantity } } }, specs { attribute { name shortname group id } displayValue value siValue units unitsName unitsSymbol valueType } } GRAPHQL; public function __construct(private readonly HttpClientInterface $httpClient, private readonly OAuthTokenManager $authTokenManager, private readonly CacheItemPoolInterface $partInfoCache, private readonly string $clientId, private readonly string $secret, private readonly string $currency, private readonly string $country, private readonly int $search_limit, private readonly bool $onlyAuthorizedSellers) { } /** * Gets the latest OAuth token for the Octopart API, or creates a new one if none is available * @return string */ private function getToken(): string { //Check if we already have a token saved for this app, otherwise we have to retrieve one via OAuth if (!$this->authTokenManager->hasToken(self::OAUTH_APP_NAME)) { $this->authTokenManager->retrieveClientCredentialsToken(self::OAUTH_APP_NAME); } $tmp = $this->authTokenManager->getAlwaysValidTokenString(self::OAUTH_APP_NAME); if ($tmp === null) { throw new \RuntimeException('Could not retrieve OAuth token for Octopart'); } return $tmp; } /** * Make a GraphQL call to the Octopart API * @return array */ private function makeGraphQLCall(string $query, ?array $variables = null): array { if ($variables === []) { $variables = null; } $options = (new HttpOptions()) ->setJson(['query' => $query, 'variables' => $variables]) ->setAuthBearer($this->getToken()) ; $response = $this->httpClient->request( 'POST', 'https://api.nexar.com/graphql/', $options->toArray(), ); return $response->toArray(true); } public function getProviderInfo(): array { return [ 'name' => 'Octopart', 'description' => 'This provider uses the Nexar/Octopart API to search for parts on Octopart.', 'url' => 'https://www.octopart.com/', 'disabled_help' => 'Set the PROVIDER_OCTOPART_CLIENT_ID and PROVIDER_OCTOPART_SECRET env option.' ]; } public function getProviderKey(): string { return 'octopart'; } public function isActive(): bool { //The client ID has to be set and a token has to be available (user clicked connect) //return /*!empty($this->clientId) && */ $this->authTokenManager->hasToken(self::OAUTH_APP_NAME); return !empty($this->clientId) && !empty($this->secret); } private function mapLifeCycleStatus(?string $value): ?ManufacturingStatus { return match ($value) { 'Production', 'New' => ManufacturingStatus::ACTIVE, 'Obsolete' => ManufacturingStatus::DISCONTINUED, 'NRND' => ManufacturingStatus::NRFND, 'EOL' => ManufacturingStatus::EOL, default => null, }; } /** * Saves the given part to the cache. * Everytime this function is called, the cache is overwritten. * @param PartDetailDTO $part * @return void */ private function saveToCache(PartDetailDTO $part): void { $key = 'octopart_part_'.$part->provider_id; $item = $this->partInfoCache->getItem($key); $item->set($part); $item->expiresAfter(3600 * 24); //Cache for 1 day $this->partInfoCache->save($item); } /** * Retrieves a from the cache, or null if it was not cached yet. * @param string $id * @return PartDetailDTO|null */ private function getFromCache(string $id): ?PartDetailDTO { $key = 'octopart_part_'.$id; $item = $this->partInfoCache->getItem($key); if ($item->isHit()) { return $item->get(); } return null; } private function partResultToDTO(array $part): PartDetailDTO { //Parse the specifications $parameters = []; $mass = null; $package = null; $pinCount = null; $mStatus = null; foreach ($part['specs'] as $spec) { //If we encounter the mass spec, we save it for later if ($spec['attribute']['shortname'] === "weight") { $mass = (float) $spec['siValue']; } else if ($spec['attribute']['shortname'] === "case_package") { //Package $package = $spec['value']; } else if ($spec['attribute']['shortname'] === "numberofpins") { //Pin Count $pinCount = $spec['value']; } else if ($spec['attribute']['shortname'] === "lifecyclestatus") { //LifeCycleStatus $mStatus = $this->mapLifeCycleStatus($spec['value']); } $parameters[] = new ParameterDTO( name: $spec['attribute']['name'], value_text: $spec['valueType'] === 'text' ? $spec['value'] : null, value_typ: in_array($spec['valueType'], ['float', 'integer'], true) ? (float) $spec['value'] : null, unit: $spec['valueType'] === 'text' ? null : $spec['units'], group: $spec['attribute']['group'], ); } //Parse the offers $orderinfos = []; foreach ($part['sellers'] as $seller) { foreach ($seller['offers'] as $offer) { $prices = []; foreach ($offer['prices'] as $price) { $prices[] = new PriceDTO( minimum_discount_amount: $price['quantity'], price: (string) $price['price'], currency_iso_code: $price['currency'], ); } $orderinfos[] = new PurchaseInfoDTO( distributor_name: $seller['company']['name'], order_number: $offer['sku'], prices: $prices, product_url: $offer['clickUrl'], ); } } //Generate a footprint name from the package and pin count $footprint = null; if ($package !== null) { $footprint = $package; if ($pinCount !== null) { //Add pin count if available $footprint .= '-' . $pinCount; } } //Built the category full path $category = null; if (!empty($part['category']['name'])) { $category = implode(' -> ', array_map(static fn($c) => $c['name'], $part['category']['ancestors'] ?? [])); if (!empty($category)) { $category .= ' -> '; } $category .= $part['category']['name']; } return new PartDetailDTO( provider_key: $this->getProviderKey(), provider_id: $part['id'], name: $part['mpn'], description: $part['shortDescription'] ?? null, category: $category , manufacturer: $part['manufacturer']['name'] ?? null, mpn: $part['mpn'], preview_image_url: $part['bestImage']['url'] ?? null, manufacturing_status: $mStatus, provider_url: $part['octopartUrl'] ?? null, footprint: $footprint, datasheets: $part['bestDatasheet'] !== null ? [new FileDTO($part['bestDatasheet']['url'], $part['bestDatasheet']['name'])]: null, parameters: $parameters, vendor_infos: $orderinfos, mass: $mass, manufacturer_product_url: $part['manufacturerUrl'] ?? null, ); } public function searchByKeyword(string $keyword): array { $graphQL = sprintf(<<<'GRAPHQL' query partSearch($keyword: String, $limit: Int, $currency: String!, $country: String!, $authorizedOnly: Boolean!) { supSearch( q: $keyword inStockOnly: false limit: $limit currency: $currency country: $country ) { hits results { part %s } } } GRAPHQL, self::GRAPHQL_PART_SECTION); $result = $this->makeGraphQLCall($graphQL, [ 'keyword' => $keyword, 'limit' => $this->search_limit, 'currency' => $this->currency, 'country' => $this->country, 'authorizedOnly' => $this->onlyAuthorizedSellers, ]); $tmp = []; foreach ($result['data']['supSearch']['results'] ?? [] as $p) { $dto = $this->partResultToDTO($p['part']); $tmp[] = $dto; //Cache the part, so we can get the details later, without having to make another request $this->saveToCache($dto); } return $tmp; } public function getDetails(string $id): PartDetailDTO { //Check if we have the part cached $cached = $this->getFromCache($id); if ($cached !== null) { return $cached; } //Otherwise we have to make a request $graphql = sprintf(<<<'GRAPHQL' query partSearch($ids: [String!]!, $currency: String!, $country: String!, $authorizedOnly: Boolean!) { supParts(ids: $ids, currency: $currency, country: $country) %s } GRAPHQL, self::GRAPHQL_PART_SECTION); $result = $this->makeGraphQLCall($graphql, [ 'ids' => [$id], 'currency' => $this->currency, 'country' => $this->country, 'authorizedOnly' => $this->onlyAuthorizedSellers, ]); $tmp = $this->partResultToDTO($result['data']['supParts'][0]); $this->saveToCache($tmp); return $tmp; } public function getCapabilities(): array { return [ ProviderCapabilities::BASIC, ProviderCapabilities::FOOTPRINT, ProviderCapabilities::PICTURE, ProviderCapabilities::DATASHEET, ProviderCapabilities::PRICE, ]; } }