app
Mouser Electronics Search
Public Made by Adomby adom
Mouser component search in your workspace — stock, quantity price breaks, datasheets and lifecycle. CLI verbs, a Hydrogen app, and a shared backend service.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332
//! Mouser Electronics API v2 client + response normalization.
//!
//! Two ways to resolve a query:
//! 1. Direct — requires MOUSER_API_KEY. Used by `serve` and by `search`
//! / `part` / `health` when no backend URL is configured.
//! 2. Via a backend — the CLI hits `POST <api>/` and lets the backend
//! hold the key + cache. Used by `search` / `part` / `health` when
//! MOUSER_API is set.
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::{json, Value};
use std::time::Duration;
/// Accept u32 as either a JSON number or a numeric string. The legacy
/// Node backend returns `min_order` / `mult` as strings ("1"); a native
/// `adom-mouser serve` returns them as numbers. Handle both.
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 or numeric string, 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 or numeric string, got {:?}", other))),
}
}
const MOUSER_API_BASE: &str = "https://api.mouser.com/api/v2";
const FETCH_TIMEOUT_SECS: u64 = 15;
#[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 mouser_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>,
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 parse_stock(availability: &str) -> u64 {
// "1,234 In Stock" → 1234.
let s: String = availability
.chars()
.take_while(|c| c.is_ascii_digit() || *c == ',')
.collect();
s.replace(',', "").parse().unwrap_or(0)
}
fn normalize_part(p: &Value) -> Component {
let avail = p.get("Availability").and_then(|v| v.as_str()).unwrap_or("");
let stock = parse_stock(avail);
let popularity = if stock < 1000 {
"low"
} else if stock < 10_000 {
"medium"
} else {
"high"
};
let price_tiers: Vec<PriceTier> = p
.get("PriceBreaks")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|pb| {
Some(PriceTier {
qty: pb.get("Quantity")?.as_u64()? as u32,
price: pb.get("Price")?.as_str()?.to_string(),
})
})
.collect()
})
.unwrap_or_default();
let unit_price = price_tiers.first().map(|t| t.price.clone());
let attributes: Vec<Attribute> = p
.get("ProductAttributes")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|a| {
let name = a.get("AttributeName")?.as_str()?.to_string();
let value = a.get("AttributeValue")?.as_str()?.to_string();
if value.is_empty() || value.contains("MouseReel") {
return None;
}
Some(Attribute { name, value })
})
.collect()
})
.unwrap_or_default();
let s = |k: &str| p.get(k).and_then(|v| v.as_str()).unwrap_or("").to_string();
let u = |k: &str, d: u32| p.get(k).and_then(|v| v.as_u64()).map(|n| n as u32).unwrap_or(d);
let os = |k: &str| p.get(k).and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(|s| s.to_string());
Component {
mouser_pn: s("MouserPartNumber"),
mpn: s("ManufacturerPartNumber"),
manufacturer: s("Manufacturer"),
description: s("Description"),
category: s("Category"),
stock,
stock_text: if avail.is_empty() { "Unknown".into() } else { avail.into() },
popularity: popularity.into(),
lead_time: s("LeadTime"),
unit_price,
price_tiers,
min_order: u("Min", 1),
mult: u("Mult", 1),
rohs: s("ROHSStatus"),
lifecycle: s("LifecycleStatus"),
datasheet_url: os("DataSheetUrl"),
product_url: os("ProductDetailUrl"),
image_url: os("ImagePath"),
attributes,
}
}
/// Direct call to Mouser's `search/keyword` endpoint with a pre-built
/// request body. Returns normalized result.
pub fn mouser_keyword_search(
api_key: &str,
keyword: &str,
records: u32,
in_stock_only: bool,
) -> Result<Value, String> {
if api_key.is_empty() {
return Err("MOUSER_API_KEY is not set".into());
}
let url = format!("{}/search/keyword?apiKey={}", MOUSER_API_BASE, api_key);
let body = json!({
"SearchByKeywordRequest": {
"keyword": keyword.trim(),
"records": records.clamp(1, 50),
"startingRecord": 0,
"searchOptions": if in_stock_only { "InStock" } else { "" },
"searchWithYourSignUpLanguage": "",
}
});
let agent = ureq::AgentBuilder::new()
.timeout(Duration::from_secs(FETCH_TIMEOUT_SECS))
.build();
let res = agent
.post(&url)
.set("Content-Type", "application/json")
.set("Accept", "application/json")
.send_json(body)
.map_err(|e| match e {
// ureq's Display impl for Error includes the full URL which
// contains ?apiKey=<secret>. NEVER call e.to_string() — emit
// a classified, URL-free message. Inspect the response body so
// we can distinguish quota-exhaustion from key-revoked.
ureq::Error::Status(code, r) => {
let body_json: Value = r.into_json().unwrap_or(json!({}));
let upstream_msg = body_json
.get("Errors")
.and_then(|v| v.as_array())
.and_then(|arr| arr.first())
.and_then(|e0| e0.get("Message").and_then(|m| m.as_str()))
.unwrap_or("")
.to_string();
let upstream_code = body_json
.get("Errors")
.and_then(|v| v.as_array())
.and_then(|arr| arr.first())
.and_then(|e0| e0.get("Code").and_then(|m| m.as_str()))
.unwrap_or("")
.to_string();
if !upstream_msg.is_empty() || !upstream_code.is_empty() {
format!("Mouser API HTTP {} — {} {}", code, upstream_code, upstream_msg)
.trim().to_string()
} else {
format!("Mouser API HTTP {}", code)
}
}
ureq::Error::Transport(t) => format!("Mouser transport error: {}", t.kind()),
})?;
let data: Value = res
.into_json()
.map_err(|e| format!("Mouser API JSON parse failed: {}", e))?;
if let Some(errs) = data.get("Errors").and_then(|v| v.as_array()) {
if !errs.is_empty() {
let msg: Vec<String> = errs
.iter()
.filter_map(|e| {
e.get("Message")
.or_else(|| e.get("Code"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
})
.collect();
let joined = msg.join("; ");
// Quota tracker: upstream sometimes returns 2xx with
// {Errors:[{Code:"TooManyRequests"}]}. Route that through
// the same quota-hit path as the 403-body case.
if joined.to_lowercase().contains("maximum calls per day") || joined.to_lowercase().contains("toomanyrequests") {
if crate::quota::record_quota_hit() {
let q = crate::quota::get();
crate::quota::post_kel_alert(&format!(
"⚠️ *Mouser daily API quota exhausted.*\n\
Key used up for {} — resets at midnight CT.\n\
Usage today: {} searches, {} results returned.\n\
(DigiKey + JLCPCB are unaffected.)",
q.date_ct, q.searches, q.results_returned
));
}
}
return Err(format!("Mouser API: {}", joined));
}
}
Ok(data)
}
pub fn search(
api_key: &str,
keyword: &str,
limit: u32,
in_stock_only: bool,
) -> Result<SearchResult, String> {
if keyword.trim().is_empty() {
return Err("keyword is required".into());
}
let data = mouser_keyword_search(api_key, keyword, limit, in_stock_only)?;
let parts = data
.pointer("/SearchResults/Parts")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let count = data
.pointer("/SearchResults/NumberOfResult")
.and_then(|v| v.as_u64())
.unwrap_or(0);
Ok(SearchResult {
components: parts.iter().map(normalize_part).collect(),
count,
query: keyword.trim().into(),
exact_match: None,
})
}
pub fn part(api_key: &str, part_number: &str) -> Result<SearchResult, String> {
if part_number.trim().is_empty() {
return Err("partNumber is required".into());
}
let data = mouser_keyword_search(api_key, part_number, 5, false)?;
let parts = data
.pointer("/SearchResults/Parts")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let target = part_number.trim().to_lowercase();
let exact = parts.iter().find(|p| {
let mouser_pn = p.get("MouserPartNumber").and_then(|v| v.as_str()).unwrap_or("").to_lowercase();
let mpn = p.get("ManufacturerPartNumber").and_then(|v| v.as_str()).unwrap_or("").to_lowercase();
mouser_pn == target || mpn == target
});
let chosen = exact.or_else(|| parts.first());
match chosen {
Some(p) => Ok(SearchResult {
components: vec![normalize_part(p)],
count: 1,
query: part_number.trim().into(),
exact_match: Some(exact.is_some()),
}),
None => Ok(SearchResult {
components: vec![],
count: 0,
query: part_number.trim().into(),
exact_match: Some(false),
}),
}
}
/// A direct Mouser live-ping used by `health` when a backend is not
/// reachable. Returns `Ok(count)` on success.
pub fn ping(api_key: &str) -> Result<u64, String> {
let data = mouser_keyword_search(api_key, "resistor", 1, false)?;
Ok(data
.pointer("/SearchResults/NumberOfResult")
.and_then(|v| v.as_u64())
.unwrap_or(0))
}