app
DigiKey Electronics Search
Public Made by Adomby adom
DigiKey component search in your workspace — millions of parts with real-time stock, quantity pricing and datasheets. CLI verbs, a Hydrogen app, and a shared backend service.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356
//! DigiKey Products v4 API client + response normalization.
//!
//! Two ways to resolve a query:
//! 1. Direct — requires DIGIKEY_CLIENT_ID + DIGIKEY_CLIENT_SECRET. OAuth2
//! client-credentials flow, bearer token cached in-process with ~80%
//! of TTL as safety margin.
//! 2. Via a backend — the CLI hits `POST <api>/` and lets the backend hold
//! the credentials + cache.
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::{json, Value};
use std::sync::Mutex;
use std::time::{Duration, Instant};
const DIGIKEY_API_BASE: &str = "https://api.digikey.com/products/v4";
const TOKEN_URL: &str = "https://api.digikey.com/v1/oauth2/token";
const FETCH_TIMEOUT_SECS: u64 = 30;
// ── OAuth2 token cache ─────────────────────────────────────────────
struct TokenCache {
token: Option<String>,
expires_at: Option<Instant>,
}
static TOKEN_CACHE: Mutex<TokenCache> = Mutex::new(TokenCache { token: None, expires_at: None });
fn get_access_token(client_id: &str, client_secret: &str) -> Result<String, String> {
if client_id.is_empty() || client_secret.is_empty() {
return Err("DIGIKEY_CLIENT_ID and DIGIKEY_CLIENT_SECRET must be set".into());
}
if let Ok(cache) = TOKEN_CACHE.lock() {
if let (Some(tok), Some(exp)) = (cache.token.as_ref(), cache.expires_at) {
if Instant::now() < exp {
return Ok(tok.clone());
}
}
}
let agent = ureq::AgentBuilder::new()
.timeout(Duration::from_secs(FETCH_TIMEOUT_SECS))
.build();
let res = match agent
.post(TOKEN_URL)
.set("Content-Type", "application/x-www-form-urlencoded")
.send_form(&[
("client_id", client_id),
("client_secret", client_secret),
("grant_type", "client_credentials"),
]) {
Ok(r) => r,
Err(ureq::Error::Status(code, r)) => {
let body = r.into_string().unwrap_or_default();
return Err(format!("OAuth token request failed: HTTP {}: {}", code, body));
}
Err(e) => return Err(format!("OAuth token request failed: {}", e)),
};
let data: Value = res.into_json().map_err(|e| format!("OAuth response parse: {}", e))?;
let token = data.get("access_token").and_then(|v| v.as_str())
.ok_or_else(|| format!("OAuth response missing access_token: {}", data))?.to_string();
let ttl = data.get("expires_in").and_then(|v| v.as_u64()).unwrap_or(600);
let expires_at = Instant::now() + Duration::from_secs(ttl * 4 / 5);
if let Ok(mut cache) = TOKEN_CACHE.lock() {
cache.token = Some(token.clone());
cache.expires_at = Some(expires_at);
}
Ok(token)
}
// ── Normalized types (same shape as adom-mouser — UI is compatible) ─
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PriceTier {
#[serde(deserialize_with = "de_u32_lenient")]
pub qty: u32,
pub price: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Attribute { pub name: String, pub value: String }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Component {
pub digikey_pn: String,
pub mpn: String,
pub manufacturer: String,
pub description: String,
pub category: String,
#[serde(deserialize_with = "de_u64_lenient")]
pub stock: u64,
pub stock_text: String,
pub popularity: String,
pub lead_time: String,
pub unit_price: Option<String>,
pub price_tiers: Vec<PriceTier>,
#[serde(deserialize_with = "de_u32_lenient")]
pub min_order: u32,
#[serde(deserialize_with = "de_u32_lenient")]
pub mult: u32,
pub rohs: String,
pub lifecycle: String,
pub datasheet_url: Option<String>,
pub product_url: Option<String>,
pub image_url: Option<String>,
/// Direct URL (usually via SamacSys / SnapEDA / Ultra Librarian
/// redirect) to a STEP, IGES, or ECAD model for this part. Populated
/// from `MediaLinks[]` in the DigiKey v4 response. `None` when the
/// part has no CAD model on DigiKey's side.
#[serde(skip_serializing_if = "Option::is_none")]
pub cad_3d_url: Option<String>,
/// Best-effort file format guess from the URL or MediaType ("step",
/// "iges", "zip" if bundled, or the raw MediaType when unclear).
#[serde(skip_serializing_if = "Option::is_none")]
pub cad_3d_format: Option<String>,
pub attributes: Vec<Attribute>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchResult {
pub components: Vec<Component>,
pub count: u64,
pub query: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub exact_match: Option<bool>,
}
fn de_u32_lenient<'de, D: Deserializer<'de>>(d: D) -> Result<u32, D::Error> {
let v = Value::deserialize(d)?;
match v {
Value::Number(n) => n.as_u64().map(|n| n as u32).ok_or_else(|| serde::de::Error::custom("not u32")),
Value::String(s) => s.parse().map_err(serde::de::Error::custom),
Value::Null => Ok(1),
other => Err(serde::de::Error::custom(format!("expected u32, got {:?}", other))),
}
}
fn de_u64_lenient<'de, D: Deserializer<'de>>(d: D) -> Result<u64, D::Error> {
let v = Value::deserialize(d)?;
match v {
Value::Number(n) => n.as_u64().ok_or_else(|| serde::de::Error::custom("not u64")),
Value::String(s) => s.replace(',', "").parse().map_err(serde::de::Error::custom),
Value::Null => Ok(0),
other => Err(serde::de::Error::custom(format!("expected u64, got {:?}", other))),
}
}
fn digikey_post(client_id: &str, client_secret: &str, endpoint: &str, body: Value) -> Result<Value, String> {
let token = get_access_token(client_id, client_secret)?;
let url = format!("{}/{}", DIGIKEY_API_BASE, endpoint);
let agent = ureq::AgentBuilder::new().timeout(Duration::from_secs(FETCH_TIMEOUT_SECS)).build();
let req = agent.post(&url)
.set("Content-Type", "application/json")
.set("Accept", "application/json")
.set("Authorization", &format!("Bearer {}", token))
.set("X-DIGIKEY-Client-Id", client_id)
.set("X-DIGIKEY-Locale-Site", "US")
.set("X-DIGIKEY-Locale-Language", "en")
.set("X-DIGIKEY-Locale-Currency", "USD");
let res = match req.send_json(body) {
Ok(r) => r,
Err(ureq::Error::Status(code, r)) => {
let body = r.into_string().unwrap_or_default();
return Err(format!("DigiKey API {}: {}", code, body));
}
Err(e) => return Err(format!("DigiKey API request failed: {}", e)),
};
res.into_json().map_err(|e| format!("DigiKey API JSON parse failed: {}", e))
}
fn digikey_get(client_id: &str, client_secret: &str, endpoint: &str) -> Result<Value, String> {
let token = get_access_token(client_id, client_secret)?;
let url = format!("{}/{}", DIGIKEY_API_BASE, endpoint);
let agent = ureq::AgentBuilder::new().timeout(Duration::from_secs(FETCH_TIMEOUT_SECS)).build();
let req = agent.get(&url)
.set("Content-Type", "application/json")
.set("Accept", "application/json")
.set("Authorization", &format!("Bearer {}", token))
.set("X-DIGIKEY-Client-Id", client_id)
.set("X-DIGIKEY-Locale-Site", "US")
.set("X-DIGIKEY-Locale-Language", "en")
.set("X-DIGIKEY-Locale-Currency", "USD");
let res = match req.call() {
Ok(r) => r,
Err(ureq::Error::Status(code, r)) => {
let body = r.into_string().unwrap_or_default();
return Err(format!("DigiKey API {}: {}", code, body));
}
Err(e) => return Err(format!("DigiKey API request failed: {}", e)),
};
res.into_json().map_err(|e| format!("DigiKey API JSON parse failed: {}", e))
}
fn normalize_part(p: &Value) -> Component {
let stock = p.get("QuantityAvailable").and_then(|v| v.as_u64()).unwrap_or(0);
let popularity = if stock < 1000 { "low" } else if stock < 10_000 { "medium" } else { "high" };
let variation = p.pointer("/ProductVariations/0");
let price_tiers: Vec<PriceTier> = variation
.and_then(|v| v.get("StandardPricing"))
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|t| {
let qty = t.get("BreakQuantity")?.as_u64()? as u32;
let unit = t.get("UnitPrice")?.as_f64()?;
Some(PriceTier { qty, price: format!("${:.4}", unit) })
}).collect())
.unwrap_or_default();
let attributes: Vec<Attribute> = p.get("Parameters").and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|param| {
let name = param.get("ParameterText").or_else(|| param.get("Parameter"))
.and_then(|v| v.as_str())?.to_string();
let value = param.get("ValueText").or_else(|| param.get("Value"))
.and_then(|v| v.as_str())?.to_string();
Some(Attribute { name, value })
}).collect())
.unwrap_or_default();
// MediaLinks[] on the v4 response carries datasheet + photo + 3D
// model links. We scan for the first entry whose MediaType is a
// known CAD-model label. DigiKey routes most CAD links through
// SamacSys — the resolved URL usually requires the user to click
// through a vendor-library auth flow, so we pass the URL straight
// to the UI rather than trying to download it.
let mut cad_3d_url: Option<String> = None;
let mut cad_3d_format: Option<String> = None;
if let Some(media) = p.get("MediaLinks").and_then(|v| v.as_array()) {
for m in media {
let mt = m.get("MediaType").and_then(|v| v.as_str()).unwrap_or("").to_lowercase();
let url = m.get("Url").and_then(|v| v.as_str()).unwrap_or("");
if url.is_empty() { continue; }
// Accept the common DigiKey MediaType values. "Design Files"
// occasionally bundles schematic/footprint/3D together.
if mt.contains("3d model") || mt == "3d" || mt.contains("cad") || mt == "design files" {
// Guess format from URL suffix; fall back to label.
let lower = url.to_lowercase();
let fmt = if lower.contains(".step") || lower.contains(".stp") { "step" }
else if lower.contains(".iges") || lower.contains(".igs") { "iges" }
else if lower.contains(".stl") { "stl" }
else if lower.contains(".zip") { "zip" }
else { "step" }; // SamacSys almost always serves STEP
cad_3d_url = Some(url.to_string());
cad_3d_format = Some(fmt.to_string());
break;
}
}
}
let s = |k: &str| p.get(k).and_then(|v| v.as_str()).unwrap_or("").to_string();
let os = |ptr: &str| p.pointer(ptr).and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(|s| s.to_string());
let digikey_pn = variation.and_then(|v| v.get("DigiKeyProductNumber"))
.and_then(|v| v.as_str()).unwrap_or("").to_string();
let min_order = variation.and_then(|v| v.get("MinimumOrderQuantity"))
.and_then(|v| v.as_u64()).unwrap_or(1) as u32;
let stock_text = if stock > 0 { format!("{} In Stock", format_with_commas(stock)) } else { "Out of Stock".to_string() };
let lead_time = p.get("ManufacturerLeadWeeks").and_then(|v| v.as_u64())
.map(|w| format!("{} weeks", w)).unwrap_or_default();
let unit_price = p.get("UnitPrice").and_then(|v| v.as_f64()).map(|u| format!("${:.4}", u));
Component {
digikey_pn,
mpn: s("ManufacturerProductNumber"),
manufacturer: p.pointer("/Manufacturer/Name").and_then(|v| v.as_str()).unwrap_or("").to_string(),
description: p.pointer("/Description/ProductDescription").and_then(|v| v.as_str()).unwrap_or("").to_string(),
category: p.pointer("/Category/Name").and_then(|v| v.as_str()).unwrap_or("").to_string(),
stock,
stock_text,
popularity: popularity.to_string(),
lead_time,
unit_price,
price_tiers,
min_order,
mult: 1,
rohs: p.pointer("/Classifications/RohsStatus").and_then(|v| v.as_str()).unwrap_or("").to_string(),
lifecycle: p.pointer("/ProductStatus/Status").and_then(|v| v.as_str()).unwrap_or("").to_string(),
datasheet_url: os("/DatasheetUrl"),
product_url: os("/ProductUrl"),
image_url: os("/PhotoUrl"),
cad_3d_url,
cad_3d_format,
attributes,
}
}
fn format_with_commas(n: u64) -> String {
let s = n.to_string();
let mut out = String::new();
for (i, c) in s.chars().rev().enumerate() {
if i > 0 && i % 3 == 0 { out.insert(0, ','); }
out.insert(0, c);
}
out
}
pub fn search(client_id: &str, client_secret: &str, keyword: &str, limit: u32, in_stock_only: bool) -> Result<SearchResult, String> {
if keyword.trim().is_empty() {
return Err("keyword is required".into());
}
let mut body = json!({
"Keywords": keyword.trim(),
"Limit": limit.clamp(1, 50),
"Offset": 0,
});
if in_stock_only {
body["FilterOptionsRequest"] = json!({"SearchOptions": ["InStock"]});
}
let data = digikey_post(client_id, client_secret, "search/keyword", body)?;
let products = data.get("Products").and_then(|v| v.as_array()).cloned().unwrap_or_default();
let count = data.get("ProductsCount").and_then(|v| v.as_u64()).unwrap_or(0);
Ok(SearchResult {
components: products.iter().map(normalize_part).collect(),
count,
query: keyword.trim().to_string(),
exact_match: None,
})
}
pub fn part(client_id: &str, client_secret: &str, part_number: &str) -> Result<SearchResult, String> {
if part_number.trim().is_empty() {
return Err("partNumber is required".into());
}
let encoded = urlencoding_simple(part_number.trim());
let endpoint = format!("search/{}/productdetails", encoded);
match digikey_get(client_id, client_secret, &endpoint) {
Ok(data) => {
let product = data.get("Product");
match product {
Some(p) if !p.is_null() => Ok(SearchResult {
components: vec![normalize_part(p)],
count: 1,
query: part_number.trim().into(),
exact_match: Some(true),
}),
_ => Ok(SearchResult { components: vec![], count: 0, query: part_number.trim().into(), exact_match: Some(false) }),
}
}
Err(e) if e.contains(" 404") => Ok(SearchResult {
components: vec![], count: 0, query: part_number.trim().into(), exact_match: Some(false),
}),
Err(e) => Err(e),
}
}
pub fn ping(client_id: &str, client_secret: &str) -> Result<u64, String> {
let data = digikey_post(client_id, client_secret, "search/keyword",
json!({"Keywords": "resistor", "Limit": 1, "Offset": 0}))?;
Ok(data.get("ProductsCount").and_then(|v| v.as_u64()).unwrap_or(0))
}
fn urlencoding_simple(s: &str) -> String {
let mut out = String::new();
for c in s.chars() {
match c {
'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => out.push(c),
_ => {
let mut buf = [0u8; 4];
for b in c.encode_utf8(&mut buf).bytes() {
out.push_str(&format!("%{:02X}", b));
}
}
}
}
out
}