@@ -427,7 +427,7 @@ async def _make_api_request(
427427
428428 async def _handle_response (self , response : aiohttp .ClientResponse , endpoint : str ) -> Dict [str , Any ]:
429429 """Handle API response and extract data or raise appropriate errors"""
430- if response .status == 200 :
430+ if response .status in ( 200 , 201 ) :
431431 content_type = response .headers .get ("Content-Type" , "" )
432432 if "application/json" in content_type :
433433 return await response .json ()
@@ -569,27 +569,29 @@ async def search_parts(self, query: str, limit: int = 50) -> List[PartSearchResu
569569 logger .error (f"❌ Search failed for query '{ query } ': { str (e )} " )
570570 return []
571571
572- async def _subscribe_to_product (self , part_number : str , credentials : Dict [str , str ] = None ) -> bool :
572+ async def _subscribe_to_product (self , part_number : str , credentials : Dict [str , str ] = None ) -> Optional [ Dict [ str , Any ]] :
573573 """Subscribe to a product to enable access to its data.
574574
575575 Per McMaster API docs, the request body format is:
576576 {"URL": "https://mcmaster.com/{partNumber}"}
577+
578+ Returns the product data from the subscribe response (201), or None on failure.
577579 """
578580 try :
579581 creds = credentials or self ._credentials or {}
580582 # PUT /v1/products with URL format per Postman collection
581583 product_url = f"https://mcmaster.com/{ part_number } "
582- await self ._make_api_request (
584+ response = await self ._make_api_request (
583585 "products" ,
584586 creds ,
585587 method = "PUT" ,
586588 json_body = {"URL" : product_url }
587589 )
588590 logger .info (f"✅ Subscribed to product: { part_number } " )
589- return True
591+ return response
590592 except Exception as e :
591593 logger .warning (f"⚠️ Could not subscribe to product { part_number } : { str (e )} " )
592- return False
594+ return None
593595
594596 async def get_part_details (self , supplier_part_number : str ) -> Optional [PartSearchResult ]:
595597 """Get detailed part information from McMaster-Carr API"""
@@ -601,16 +603,36 @@ async def get_part_details(self, supplier_part_number: str) -> Optional[PartSear
601603 logger .error ("No credentials configured for part details" )
602604 return None
603605
604- # McMaster-Carr requires subscribing to products before accessing them
605- # Try to subscribe first (may already be subscribed)
606- await self ._subscribe_to_product (supplier_part_number , credentials )
606+ # McMaster-Carr requires subscribing to products before accessing them.
607+ # The subscribe (PUT) response already contains full product data.
608+ subscribe_data = await self ._subscribe_to_product (supplier_part_number , credentials )
609+
610+ if subscribe_data :
611+ result = self ._parse_part_data (subscribe_data )
612+ if result :
613+ # Download authenticated image if it's an API URL
614+ if result .image_url and "api.mcmaster.com" in result .image_url :
615+ local_url = await self ._download_authenticated_image (
616+ result .image_url , supplier_part_number
617+ )
618+ if local_url :
619+ result .image_url = local_url
620+ logger .info (f"✅ Retrieved details for part from subscribe response: { supplier_part_number } " )
621+ return result
607622
608- # Get product details using correct endpoint: /v1/products/{partNumber}
623+ # Fallback: try GET endpoint directly
609624 endpoint = f"products/{ supplier_part_number } "
610625 response = await self ._make_api_request (endpoint , credentials )
611626
612627 result = self ._parse_part_data (response )
613628 if result :
629+ # Download authenticated image if it's an API URL
630+ if result .image_url and "api.mcmaster.com" in result .image_url :
631+ local_url = await self ._download_authenticated_image (
632+ result .image_url , supplier_part_number
633+ )
634+ if local_url :
635+ result .image_url = local_url
614636 logger .info (f"✅ Retrieved details for part: { supplier_part_number } " )
615637 return result
616638 else :
@@ -625,68 +647,172 @@ async def get_part_details(self, supplier_part_number: str) -> Optional[PartSear
625647 return None
626648
627649 def _parse_part_data (self , part_data : Dict [str , Any ]) -> Optional [PartSearchResult ]:
628- """Parse part data from McMaster-Carr API response"""
650+ """Parse part data from McMaster-Carr API response.
651+
652+ The McMaster API returns PascalCase fields:
653+ - PartNumber, ProductStatus, ProductCategory
654+ - FamilyDescription, DetailDescription
655+ - Specifications: [{Attribute, Values}, ...]
656+ - Links: [{Key, Value}, ...]
657+ """
629658 try :
630- part_number = part_data .get ("partNumber" )
659+ # Support both PascalCase (actual API) and camelCase (legacy)
660+ part_number = part_data .get ("PartNumber" ) or part_data .get ("partNumber" )
631661 if not part_number :
632662 return None
633663
634- # Extract specifications
664+ # Build description from FamilyDescription + DetailDescription
665+ family_desc = part_data .get ("FamilyDescription" , "" )
666+ detail_desc = part_data .get ("DetailDescription" , "" )
667+ if family_desc and detail_desc :
668+ description = f"{ family_desc } , { detail_desc } "
669+ elif family_desc :
670+ description = family_desc
671+ elif detail_desc :
672+ description = detail_desc
673+ else :
674+ description = part_data .get ("description" , f"McMaster-Carr Part { part_number } " )
675+
676+ # Extract category
677+ category = part_data .get ("ProductCategory" ) or part_data .get ("category" , "Industrial Supply" )
678+
679+ # Extract specifications from array format: [{Attribute, Values}, ...]
635680 specifications = {}
636- specs_data = part_data .get ("specifications" , {})
637- for spec_name , spec_value in specs_data .items ():
638- if spec_value :
639- specifications [spec_name ] = spec_value
681+ specs_data = part_data .get ("Specifications" ) or part_data .get ("specifications" )
682+ if isinstance (specs_data , list ):
683+ for spec in specs_data :
684+ attr = spec .get ("Attribute" ) or spec .get ("attribute" , "" )
685+ values = spec .get ("Values" ) or spec .get ("values" , [])
686+ if attr and values :
687+ specifications [attr ] = ", " .join (str (v ) for v in values )
688+ elif isinstance (specs_data , dict ):
689+ for spec_name , spec_value in specs_data .items ():
690+ if spec_value :
691+ specifications [spec_name ] = spec_value
692+
693+ # Extract URLs from Links array: [{Key, Value}, ...]
694+ image_url = None
695+ datasheet_url = None
696+ price_link = None
697+ links = part_data .get ("Links" , [])
698+ base_api_url = self ._config .get ("api_base_url" , "https://api.mcmaster.com" )
699+ for link in links :
700+ key = link .get ("Key" , "" )
701+ value = link .get ("Value" , "" )
702+ if key == "Image" and value :
703+ # Image links are API-relative paths
704+ image_url = f"{ base_api_url } /{ value .lstrip ('/' )} " if not value .startswith ("http" ) else value
705+ elif key == "2-D PDF" and value :
706+ datasheet_url = f"{ base_api_url } /{ value .lstrip ('/' )} " if not value .startswith ("http" ) else value
707+ elif key == "Price" and value :
708+ price_link = value
709+
710+ # Fallback to legacy field names
711+ if not image_url :
712+ image_url = part_data .get ("imageUrl" )
713+ if image_url and not image_url .startswith ("http" ):
714+ image_url = f"https://www.mcmaster.com{ image_url } "
640715
641- # Extract pricing
716+ # Extract pricing from nested data or link
642717 pricing_data = part_data .get ("pricing" , {})
643718 price_text = None
644719 if pricing_data :
645720 price_value = pricing_data .get ("unitPrice" )
646721 unit_quantity = pricing_data .get ("unitQuantity" , 1 )
647722 currency = pricing_data .get ("currency" , "USD" )
648-
649723 if price_value :
650- if unit_quantity > 1 :
651- price_text = f"{ currency } { price_value } per { unit_quantity } "
652- else :
653- price_text = f"{ currency } { price_value } each"
654-
655- # Extract image URL
656- image_url = part_data .get ("imageUrl" )
657- if image_url and not image_url .startswith ("http" ):
658- image_url = f"https://www.mcmaster.com{ image_url } "
659-
660- # Extract datasheet URL
661- datasheet_url = part_data .get ("datasheetUrl" )
662- if datasheet_url and not datasheet_url .startswith ("http" ):
663- datasheet_url = f"https://www.mcmaster.com{ datasheet_url } "
724+ price_text = f"{ currency } { price_value } per { unit_quantity } " if unit_quantity > 1 else f"{ currency } { price_value } each"
664725
665726 return PartSearchResult (
666727 supplier_part_number = part_number ,
667728 manufacturer = "McMaster-Carr" ,
668729 manufacturer_part_number = part_number ,
669- description = part_data . get ( " description" , f"McMaster-Carr Part { part_number } " ) ,
670- category = part_data . get ( " category" , "Industrial Supply" ) ,
730+ description = description ,
731+ category = category ,
671732 datasheet_url = datasheet_url ,
672733 image_url = image_url ,
673734 stock_quantity = part_data .get ("stockQuantity" ),
674735 pricing = price_text ,
675736 specifications = specifications if specifications else None ,
676737 additional_data = {
677738 "source" : "mcmaster_api" ,
678- "api_version" : part_data .get ("apiVersion" ),
679- "last_updated" : part_data .get ("lastUpdated" ),
680- "unit_of_measure" : part_data .get ("unitOfMeasure" ),
681- "minimum_order_quantity" : part_data .get ("minimumOrderQuantity" ),
682- "lead_time_days" : part_data .get ("leadTimeDays" ),
739+ "product_status" : part_data .get ("ProductStatus" ),
740+ "product_detail_url" : f"https://www.mcmaster.com/{ part_number } " ,
741+ "price_api_link" : price_link ,
683742 },
684743 )
685744
686745 except Exception as e :
687746 logger .error (f"❌ Failed to parse part data: { str (e )} " )
688747 return None
689748
749+ async def _download_authenticated_image (self , image_api_url : str , part_number : str ) -> Optional [str ]:
750+ """Download an image from the McMaster API using authenticated session and save locally.
751+
752+ McMaster image URLs are API-internal and require client cert + bearer token.
753+ The browser cannot load them directly, so we download and serve locally.
754+
755+ Returns:
756+ Local serving URL (e.g., /api/utility/get_image/{uuid}) or None on failure.
757+ """
758+ try :
759+ from MakerMatrix .services .system .file_download_service import FileDownloadService
760+
761+ creds = self ._credentials or {}
762+ token = await self ._authenticate (creds )
763+ ssl_context = await self ._setup_ssl_context (creds )
764+ connector = aiohttp .TCPConnector (ssl = ssl_context )
765+ timeout = aiohttp .ClientTimeout (total = self ._config .get ("timeout_seconds" , 30 ))
766+
767+ headers = {
768+ "Accept" : "image/*" ,
769+ "Authorization" : f"Bearer { token } " ,
770+ }
771+
772+ async with aiohttp .ClientSession (connector = connector , timeout = timeout ) as session :
773+ logger .info (f"Downloading authenticated image for { part_number } : { image_api_url } " )
774+ async with session .get (image_api_url , headers = headers ) as response :
775+ if response .status not in (200 , 201 ):
776+ logger .warning (f"Image download failed with status { response .status } " )
777+ return None
778+
779+ content_type = response .headers .get ("Content-Type" , "" ).lower ()
780+ image_data = await response .read ()
781+
782+ if len (image_data ) < 100 :
783+ logger .warning (f"Image too small ({ len (image_data )} bytes), skipping" )
784+ return None
785+
786+ # Determine extension from content type
787+ ext = ".png"
788+ if "jpeg" in content_type or "jpg" in content_type :
789+ ext = ".jpg"
790+ elif "gif" in content_type :
791+ ext = ".gif"
792+ elif "webp" in content_type :
793+ ext = ".webp"
794+
795+ # Generate UUID and save
796+ import uuid as uuid_mod
797+ image_uuid = str (uuid_mod .uuid5 (uuid_mod .NAMESPACE_URL , image_api_url ))
798+ file_service = FileDownloadService ()
799+ filename = f"{ image_uuid } { ext } "
800+ file_path = file_service .uploaded_images_path / filename
801+
802+ if file_path .exists ():
803+ logger .info (f"McMaster image already cached: { filename } " )
804+ return file_service .get_image_url (image_uuid )
805+
806+ with open (file_path , "wb" ) as f :
807+ f .write (image_data )
808+
809+ logger .info (f"✅ Saved McMaster image: { filename } ({ len (image_data )} bytes)" )
810+ return file_service .get_image_url (image_uuid )
811+
812+ except Exception as e :
813+ logger .warning (f"Failed to download McMaster image: { e } " )
814+ return None
815+
690816 async def fetch_datasheet (self , supplier_part_number : str ) -> Optional [str ]:
691817 """Fetch datasheet URL for a part"""
692818 try :
0 commit comments