diff --git a/config/packages/knpu_oauth2_client.yaml b/config/packages/knpu_oauth2_client.yaml index f06bca1b..7d296a8b 100644 --- a/config/packages/knpu_oauth2_client.yaml +++ b/config/packages/knpu_oauth2_client.yaml @@ -21,3 +21,18 @@ knpu_oauth2_client: #urlAuthorize: 'https://sandbox-api.digikey.com/v1/oauth2/authorize' #urlAccessToken: 'https://sandbox-api.digikey.com/v1/oauth2/token' #urlResourceOwnerDetails: '' + + ip_octopart_oauth: + type: generic + provider_class: '\League\OAuth2\Client\Provider\GenericProvider' + + client_id: '%env(PROVIDER_OCTOPART_CLIENT_ID)%' + client_secret: '%env(PROVIDER_OCTOPART_SECRET)%' + + redirect_route: 'oauth_client_check' + redirect_params: { name: 'ip_octopart_oauth' } + + provider_options: + urlAuthorize: 'https://identity.nexar.com/connect/authorize' + urlAccessToken: 'https://identity.nexar.com/connect/token' + urlResourceOwnerDetails: '' \ No newline at end of file diff --git a/config/packages/nelmio_security.yaml b/config/packages/nelmio_security.yaml index c12fdb8b..075ce930 100644 --- a/config/packages/nelmio_security.yaml +++ b/config/packages/nelmio_security.yaml @@ -16,8 +16,9 @@ nelmio_security: # Whitelist the domain of the SAML IDP, so we can redirect to it during the SAML login process - '%env(string:key:host:url:SAML_IDP_SINGLE_SIGN_ON_SERVICE)%' - # Whitelist the info provider APIs + # Whitelist the info provider APIs (OAuth redirects) - 'digikey.com' + - 'nexar.com' # forces Microsoft's XSS-Protection with # its block mode diff --git a/src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php b/src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php index 7a7a83ca..9f365f1e 100644 --- a/src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php +++ b/src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php @@ -53,6 +53,8 @@ class PartDetailDTO extends SearchResultDTO public readonly ?array $vendor_infos = null, /** The mass of the product in grams */ public readonly ?float $mass = null, + /** The URL to the product on the website of the manufacturer */ + public readonly ?string $manufacturer_product_url = null, ) { parent::__construct( provider_key: $provider_key, diff --git a/src/Services/InfoProviderSystem/DTOtoEntityConverter.php b/src/Services/InfoProviderSystem/DTOtoEntityConverter.php index a7f5551e..881d5f20 100644 --- a/src/Services/InfoProviderSystem/DTOtoEntityConverter.php +++ b/src/Services/InfoProviderSystem/DTOtoEntityConverter.php @@ -162,6 +162,7 @@ final class DTOtoEntityConverter $entity->setManufacturerProductNumber($dto->mpn ?? ''); $entity->setManufacturingStatus($dto->manufacturing_status ?? ManufacturingStatus::NOT_SET); + $entity->setManufacturerProductURL($dto->manufacturer_product_url ?? ''); //Set the provider reference on the part $entity->setProviderReference(InfoProviderReference::fromPartDTO($dto)); diff --git a/src/Services/InfoProviderSystem/PartInfoRetriever.php b/src/Services/InfoProviderSystem/PartInfoRetriever.php index 744610f7..73b0236d 100644 --- a/src/Services/InfoProviderSystem/PartInfoRetriever.php +++ b/src/Services/InfoProviderSystem/PartInfoRetriever.php @@ -74,7 +74,7 @@ final class PartInfoRetriever protected function searchInProvider(InfoProviderInterface $provider, string $keyword): array { //Generate key and escape reserved characters from the provider id - $escaped_keyword = urlencode($keyword); + $escaped_keyword = urlencode($keyword) . uniqid(); return $this->partInfoCache->get("search_{$provider->getProviderKey()}_{$escaped_keyword}", function (ItemInterface $item) use ($provider, $keyword) { //Set the expiration time diff --git a/src/Services/InfoProviderSystem/Providers/OctopartProvider.php b/src/Services/InfoProviderSystem/Providers/OctopartProvider.php new file mode 100644 index 00000000..fe533319 --- /dev/null +++ b/src/Services/InfoProviderSystem/Providers/OctopartProvider.php @@ -0,0 +1,239 @@ +. + */ + +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\PartDetailDTO; +use App\Services\OAuth\OAuthTokenManager; +use Symfony\Component\HttpClient\HttpOptions; +use Symfony\Component\HttpClient\NativeHttpClient; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +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 { + name + path + } + bestImage { + url + } + bestDatasheet { + url + name + } + extras { + lifeCycle + } + manufacturerUrl + medianPrice1000 { + price + currency + quantity + } + sellers(authorizedOnly: false) { + company { + name + homepageUrl + } + isAuthorized + offers { + clickUrl + inventoryLevel + moq + packaging + } + } + } + GRAPHQL; + + + public function __construct(private readonly HttpClientInterface $httpClient, + private readonly OAuthTokenManager $authTokenManager) + { + + } + + /** + * 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 true; + } + + private function partResultToDTO(array $part): PartDetailDTO + { + return new PartDetailDTO( + provider_key: $this->getProviderKey(), + provider_id: $part['id'], + name: $part['mpn'], + description: $part['shortDescription'], + category: $part['category']['name'], + manufacturer: $part['manufacturer']['name'], + mpn: $part['mpn'], + preview_image_url: $part['bestImage']['url'], + manufacturing_status: ManufacturingStatus::NOT_SET, //TODO + provider_url: $part['octopartUrl'], + datasheets: [new FileDTO($part['bestDatasheet']['url'], $part['bestDatasheet']['name'])], + manufacturer_product_url: $part['manufacturerUrl'], //TODO + ); + } + + public function searchByKeyword(string $keyword): array + { + $graphQL = sprintf(<<<'GRAPHQL' + query partSearch($keyword: String, $limit: Int) { + supSearch( + q: $keyword + inStockOnly: false + limit: $limit + ) { + hits + results { + part + %s + } + } + } + GRAPHQL, self::GRAPHQL_PART_SECTION); + + + $result = $this->makeGraphQLCall($graphQL, [ + 'keyword' => $keyword, + 'limit' => 4, + ]); + + $tmp = []; + + foreach ($result['data']['supSearch']['results'] as $p) { + $tmp[] = $this->partResultToDTO($p['part']); + } + + return $tmp; + } + + + + public function getDetails(string $id): PartDetailDTO + { + $graphql = sprintf(<<<'GRAPHQL' + query partSearch($ids: [String!]!) { + supParts(ids: $ids) + %s + } + GRAPHQL, self::GRAPHQL_PART_SECTION); + + dump($graphql); + + $result = $this->makeGraphQLCall($graphql, [ + 'ids' => [$id], + ]); + + return $this->partResultToDTO($result['data']['supParts'][0]); + } + + public function getCapabilities(): array + { + return [ + ProviderCapabilities::BASIC, + ProviderCapabilities::FOOTPRINT, + ProviderCapabilities::PICTURE, + ProviderCapabilities::DATASHEET, + ProviderCapabilities::PRICE, + ]; + } +} \ No newline at end of file