diff --git a/.docker/symfony.conf b/.docker/symfony.conf index 1527286c..f5847434 100644 --- a/.docker/symfony.conf +++ b/.docker/symfony.conf @@ -41,6 +41,7 @@ PassEnv PROVIDER_ELEMENT14_KEY PROVIDER_ELEMENT14_STORE_ID PassEnv PROVIDER_TME_KEY PROVIDER_TME_SECRET PROVIDER_TME_CURRENCY PROVIDER_TME_LANGUAGE PROVIDER_TME_COUNTRY PROVIDER_TME_GET_GROSS_PRICES PassEnv PROVIDER_OCTOPART_CLIENT_ID PROVIDER_OCTOPART_SECRET PROVIDER_OCTOPART_CURRENCY PROVIDER_OCTOPART_COUNTRY PROVIDER_OCTOPART_SEARCH_LIMIT PROVIDER_OCTOPART_ONLY_AUTHORIZED_SELLERS + PassEnv PROVIDER_MOUSER_KEY PROVIDER_MOUSER_SEARCH_OPTION PROVIDER_MOUSER_SEARCH_LIMIT PROVIDER_MOUSER_SEARCH_WITH_SIGNUP_LANGUAGE # For most configuration files from conf-available/, which are # enabled or disabled at a global level, it is possible to diff --git a/.env b/.env index 9947522e..0b834c2e 100644 --- a/.env +++ b/.env @@ -142,6 +142,17 @@ PROVIDER_OCTOPART_SEARCH_LIMIT=10 # Set to false to include non authorized offers in the results PROVIDER_OCTOPART_ONLY_AUTHORIZED_SELLERS=1 +# Mouser Provider API V2: +# You can get your API key from https://www.mouser.it/api-hub/ +PROVIDER_MOUSER_KEY= +# Filter search results by RoHS compliance and stock availability: +# Available options: None | Rohs | InStock | RohsAndInStock +PROVIDER_MOUSER_SEARCH_OPTION='None' +# The number of results to get from Mouser while searching (please note that this value is max 50) +PROVIDER_MOUSER_SEARCH_LIMIT=50 +# It is recommended to leave this set to 'true'. The option is not really good doumented by Mouser: +# Used when searching for keywords in the language specified when you signed up for Search API. +PROVIDER_MOUSER_SEARCH_WITH_SIGNUP_LANGUAGE='true' ################################################################################### # SAML Single sign on-settings diff --git a/config/services.yaml b/config/services.yaml index 8e6ee8af..b99bb72a 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -277,6 +277,13 @@ services: $search_limit: '%env(int:PROVIDER_OCTOPART_SEARCH_LIMIT)%' $onlyAuthorizedSellers: '%env(bool:PROVIDER_OCTOPART_ONLY_AUTHORIZED_SELLERS)%' + App\Services\InfoProviderSystem\Providers\MouserProvider: + arguments: + $api_key: '%env(string:PROVIDER_MOUSER_KEY)%' + $language: '%env(string:PROVIDER_MOUSER_SEARCH_WITH_SIGNUP_LANGUAGE)%' + $options: '%env(string:PROVIDER_MOUSER_SEARCH_OPTION)%' + $search_limit: '%env(int:PROVIDER_MOUSER_SEARCH_LIMIT)%' + #################################################################################################################### # API system #################################################################################################################### diff --git a/docs/usage/information_provider_system.md b/docs/usage/information_provider_system.md index 49ff6ebe..ea7a1fda 100644 --- a/docs/usage/information_provider_system.md +++ b/docs/usage/information_provider_system.md @@ -126,6 +126,18 @@ Following env configuration options are available: * `PROVIDER_ELEMENT14_KEY`: The API key you got from Farnell (mandatory) * `PROVIDER_ELEMENT14_STORE_ID`: The store ID you want to use. This decides the language of results, currency and country of prices (optional, default: `de.farnell.com`, see [here](https://partner.element14.com/docs/Product_Search_API_REST__Description) for availailable values) +### Mouser +The Mouser provider uses the [Mouser API](https://www.mouser.de/api-home/) to search for parts and getting shopping information from [Mouser](https://www.mouser.com/). +You have to create an account at Mouser and register for an API key for the Search API on the [Mouser API page](https://www.mouser.de/api-home/). +You will receive an API token, which you have to enter in the Part-DB env configuration (see below): +At the registration you choose a country, language and currency in which you want to get the results. + +Following env configuration options are available: +* `PROVIDER_MOUSER_KEY`: The API key you got from Mouser (mandatory) +* `PROVIDER_MOUSER_SEARCH_LIMIT`: The maximum number of results to return per search (maximum 50) +* `PROVIDER_MOUSER_SEARCH_OPTION`: You can choose an option here to restrict the search results to RoHs compliant and available parts. Possible values are `None`, `Rohs`, `InStock`, `RohsAndInStock`. +* `PROVIDER_MOUSER_SEARCH_WITH_SIGNUP_LANGUAGE`: A bit of an obscure option. The original description of Mouser is: Used when searching for keywords in the language specified when you signed up for Search API. + ### Custom provider To create a custom provider, you have to create a new class implementing the `InfoProviderInterface` interface. As long as it is a valid Symfony service, it will be automatically loaded and can be used. diff --git a/src/Services/InfoProviderSystem/Providers/MouserProvider.php b/src/Services/InfoProviderSystem/Providers/MouserProvider.php new file mode 100644 index 00000000..22da8f3e --- /dev/null +++ b/src/Services/InfoProviderSystem/Providers/MouserProvider.php @@ -0,0 +1,305 @@ +. + */ + +/* +* This file provide an interface with the Mouser API V2 (also compatible with the V1) +* +* Copyright (C) 2023 Pasquale D'Orsi (https://github.com/pdo59) +* +* TODO: Obtain an API keys with an US Mouser user (currency $) and test the result of prices +* +*/ + +declare(strict_types=1); + + +namespace App\Services\InfoProviderSystem\Providers; + +use App\Entity\Parts\ManufacturingStatus; +use App\Form\InfoProviderSystem\ProviderSelectType; +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\SearchResultDTO; +use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO; +use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\Contracts\HttpClient\ResponseInterface; + + +class MouserProvider implements InfoProviderInterface +{ + + private const ENDPOINT_URL = 'https://api.mouser.com/api/v2/search'; + + public const DISTRIBUTOR_NAME = 'Mouser'; + + public function __construct( + private readonly HttpClientInterface $mouserClient, + private readonly string $api_key, + private readonly string $language, + private readonly string $options, + private readonly int $search_limit + ) { + } + + public function getProviderInfo(): array + { + return [ + 'name' => 'Mouser', + 'description' => 'This provider uses the Mouser API to search for parts.', + 'url' => 'https://www.mouser.com/', + 'disabled_help' => 'Configure the API key in the PROVIDER_MOUSER_KEY environment variable to enable.' + ]; + } + + public function getProviderKey(): string + { + return 'mouser'; + } + + public function isActive(): bool + { + return !empty($this->api_key); + } + + public function searchByKeyword(string $keyword): array + { + /* + SearchByKeywordRequest description: + Search parts by keyword and return a maximum of 50 parts. + + keyword* string + Used for keyword part search. + + records integer($int32) + Used to specify how many records the method should return. + + startingRecord integer($int32) + Indicates where in the total recordset the return set should begin. + From the startingRecord, the number of records specified will be returned up to the end of the recordset. + This is useful for paging through the complete recordset of parts matching keyword. + + searchOptions string + Optional. + If not provided, the default is None. + Refers to options supported by the search engine. + Only one value at a time is supported. + Available options: None | Rohs | InStock | RohsAndInStock - can use string representations or integer IDs: 1[None] | 2[Rohs] | 4[InStock] | 8[RohsAndInStock]. + + searchWithYourSignUpLanguage string + Optional. + If not provided, the default is false. + Used when searching for keywords in the language specified when you signed up for Search API. + Can use string representation: true. + { + "SearchByKeywordRequest": { + "keyword": "BC557", + "records": 0, + "startingRecord": 0, + "searchOptions": "", + "searchWithYourSignUpLanguage": "" + } + } + */ + + $response = $this->mouserClient->request('POST', self::ENDPOINT_URL."/keyword", [ + 'query' => [ + 'apiKey' => $this->api_key, + ], + 'json' => [ + 'SearchByKeywordRequest' => [ + 'keyword' => $keyword, + 'records' => $this->search_limit, //self::NUMBER_OF_RESULTS, + 'startingRecord' => 0, + 'searchOptions' => $this->options, + 'searchWithYourSignUpLanguage' => $this->language, + ] + ], + ]); + + return $this->responseToDTOArray($response); + } + + public function getDetails(string $id): PartDetailDTO + { + /* + SearchByPartRequest description: + Search parts by part number and return a maximum of 50 parts. + + mouserPartNumber string + Used to search parts by the specific Mouser part number with a maximum input of 10 part numbers, separated by a pipe symbol for the search. + Each part number must be a minimum of 3 characters and a maximum of 40 characters. For example: 494-JANTX2N2222A|610-2N2222-TL|637-2N2222A + + partSearchOptions string + Optional. + If not provided, the default is None. Refers to options supported by the search engine. Only one value at a time is supported. + The following values are valid: None | Exact - can use string representations or integer IDs: 1[None] | 2[Exact] + + { + "SearchByPartRequest": { + "mouserPartNumber": "string", + "partSearchOptions": "string" + } + } + */ + + $response = $this->mouserClient->request('POST', self::ENDPOINT_URL."/partnumber", [ + 'query' => [ + 'apiKey' => $this->api_key, + ], + 'json' => [ + 'SearchByPartRequest' => [ + 'mouserPartNumber' => $id, + 'partSearchOptions' => 2 + ] + ], + ]); + $tmp = $this->responseToDTOArray($response); + + //Ensure that we have exactly one result + if (count($tmp) === 0) { + throw new \RuntimeException('No part found with ID '.$id); + } + + if (count($tmp) > 1) { + throw new \RuntimeException('Multiple parts found with ID '.$id); + } + + return $tmp[0]; + } + + public function getCapabilities(): array + { + return [ + ProviderCapabilities::BASIC, + ProviderCapabilities::PICTURE, + ProviderCapabilities::DATASHEET, + ProviderCapabilities::PRICE, + ]; + } + + + /** + * @param ResponseInterface $response + * @return PartDetailDTO[] + */ + private function responseToDTOArray(ResponseInterface $response): array + { + $arr = $response->toArray(); + + if (isset($arr['SearchResults'])) { + $products = $arr['SearchResults']['Parts'] ?? []; + } else { + throw new \RuntimeException('Unknown response format'); + } + $result = []; + foreach ($products as $product) { + $result[] = new PartDetailDTO( + provider_key: $this->getProviderKey(), + provider_id: $product['MouserPartNumber'], + name: $product['ManufacturerPartNumber'], + description: $product['Description'], + category: $product['Category'], + manufacturer: $product['Manufacturer'], + mpn: $product['ManufacturerPartNumber'], + preview_image_url: $product['ImagePath'], + manufacturing_status: $this->releaseStatusCodeToManufacturingStatus($product['LifecycleStatus'] ?? null), + provider_url: $product['ProductDetailUrl'], + datasheets: $this->parseDataSheets($product['DataSheetUrl'] ?? null, + $product['MouserPartNumber'] ?? null), + vendor_infos: $this->pricingToDTOs($product['PriceBreaks'] ?? [], $product['MouserPartNumber'], + $product['ProductDetailUrl']), + ); + } + return $result; + } + + + private function parseDataSheets(?string $sheetUrl, ?string $sheetName): ?array + { + if (empty($sheetUrl)) { + return null; + } + $result = []; + $result[] = new FileDTO(url: $sheetUrl, name: $sheetName); + return $result; + } + + /* + * Mouser API price is a string in the form "n[.,]nnn[.,] currency" + * then this convert it to a number + */ + private function priceStrToFloat($val): float + { + $val = str_replace(",", ".", $val); + $val = preg_replace('/\.(?=.*\.)/', '', $val); + return (float)$val; + } + + /** + * Converts the pricing (StandardPricing field) from the Mouser API to an array of PurchaseInfoDTOs + * @param array $price_breaks + * @param string $order_number + * @param string $product_url + * @return PurchaseInfoDTO[] + */ + private function pricingToDTOs(array $price_breaks, string $order_number, string $product_url): array + { + $prices = []; + + foreach ($price_breaks as $price_break) { + $number = $this->priceStrToFloat($price_break['Price']); + $prices[] = new PriceDTO( + minimum_discount_amount: $price_break['Quantity'], + price: (string)$number, + currency_iso_code: $price_break['Currency'] + ); + } + + return [ + new PurchaseInfoDTO(distributor_name: self::DISTRIBUTOR_NAME, order_number: $order_number, prices: $prices, + product_url: $product_url) + ]; + } + + + /* Converts the product status from the MOUSER API to the manufacturing status used in Part-DB: + Factory Special Order - Ordine speciale in fabbrica + Not Recommended for New Designs - Non raccomandato per nuovi progetti + New Product - Nuovo prodotto + End of Life - Fine vita + -vuoto- - Attivo + + TODO: Probably need to review the values of field Lifecyclestatus + */ + private function releaseStatusCodeToManufacturingStatus(?string $productStatus): ?ManufacturingStatus + { + return match ($productStatus) { + null => null, + "New Product" => ManufacturingStatus::ANNOUNCED, + "Not Recommended for New Designs" => ManufacturingStatus::NRFND, + "Factory Special Order" => ManufacturingStatus::DISCONTINUED, + "End of Life" => ManufacturingStatus::EOL, + "Obsolete" => ManufacturingStatus::DISCONTINUED, + default => ManufacturingStatus::ACTIVE, + }; + } +} \ No newline at end of file