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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371
//! Backend HTTP proxy for the Mouser API. Replaces
//! gallia/services/mouser/server.js.
//!
//! Endpoints:
//! GET /health
//! GET /search?keyword=...&limit=10&inStockOnly=false
//! GET /part?partNumber=...
//! POST / — action-based: {"action":"search", ...}
//! GET / — landing page (text/html)
//!
//! Env: MOUSER_API_KEY is REQUIRED.
//! Port: 8775 by default (configurable via --port / MOUSER_PORT).
use crate::mouser::{self, SearchResult};
use crate::{note, ok, warn, ServeArgs, CYAN, RESET};
use serde::Serialize;
use serde_json::{json, Value};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use tiny_http::{Header, Method, Response, Server};
const LANDING_HTML: &str = include_str!("service_landing.html");
const CACHE_TTL: Duration = Duration::from_secs(10 * 60);
const CACHE_MAX: usize = 200;
#[derive(Clone)]
struct CacheEntry {
data: Value,
inserted: Instant,
}
fn cache_get(cache: &Arc<Mutex<HashMap<String, CacheEntry>>>, key: &str) -> Option<Value> {
let mut m = cache.lock().ok()?;
if let Some(e) = m.get(key) {
if e.inserted.elapsed() < CACHE_TTL {
return Some(e.data.clone());
}
m.remove(key);
}
None
}
fn cache_set(cache: &Arc<Mutex<HashMap<String, CacheEntry>>>, key: String, data: Value) {
if let Ok(mut m) = cache.lock() {
if m.len() >= CACHE_MAX {
// Evict the oldest entry.
if let Some((k, _)) = m
.iter()
.min_by_key(|(_, v)| v.inserted)
.map(|(k, v)| (k.clone(), v.clone()))
{
m.remove(&k);
}
}
m.insert(key, CacheEntry { data, inserted: Instant::now() });
}
}
#[derive(Serialize)]
struct HealthResponse<'a> {
ok: bool,
version: &'static str,
cache_size: usize,
cache_max: usize,
cache_ttl_ms: u64,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
api_reachable: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
api_error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
test_results: Option<u64>,
/// Daily upstream-API-quota telemetry — see `quota` module.
quota: crate::quota::QuotaState,
/// True iff a `KEL_ALERT_WEBHOOK` env var is configured.
kel_alert_configured: bool,
}
fn json_resp(code: u16, body: Value) -> Response<std::io::Cursor<Vec<u8>>> {
Response::from_string(body.to_string())
.with_status_code(code)
.with_header(Header::from_bytes("Content-Type", "application/json").unwrap())
.with_header(Header::from_bytes("Access-Control-Allow-Origin", "*").unwrap())
.with_header(Header::from_bytes("Access-Control-Allow-Methods", "GET,POST,OPTIONS").unwrap())
.with_header(Header::from_bytes("Access-Control-Allow-Headers", "Content-Type").unwrap())
}
fn html_resp(html: String) -> Response<std::io::Cursor<Vec<u8>>> {
Response::from_string(html)
.with_status_code(200)
.with_header(Header::from_bytes("Content-Type", "text/html; charset=utf-8").unwrap())
}
fn parse_query(url: &str) -> HashMap<String, String> {
let mut out = HashMap::new();
if let Some(q) = url.split_once('?').map(|(_, q)| q) {
for pair in q.split('&') {
if let Some((k, v)) = pair.split_once('=') {
out.insert(
urldecode(k),
urldecode(v),
);
}
}
}
out
}
fn urldecode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
let b = bytes[i];
if b == b'+' {
out.push(' ');
i += 1;
} else if b == b'%' && i + 2 < bytes.len() {
if let Ok(n) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
out.push(n as char);
i += 3;
continue;
}
out.push(b as char);
i += 1;
} else {
out.push(b as char);
i += 1;
}
}
out
}
fn path_of(url: &str) -> &str {
url.split_once('?').map(|(p, _)| p).unwrap_or(url)
}
fn do_search(
api_key: &str,
cache: &Arc<Mutex<HashMap<String, CacheEntry>>>,
keyword: &str,
limit: u32,
in_stock_only: bool,
) -> Result<SearchResult, String> {
let cache_key = format!("s:{}:{}:{}", keyword, limit, in_stock_only);
if let Some(cached) = cache_get(cache, &cache_key) {
// Cache hit: don't count toward upstream quota (no API call
// was made). Do count the results returned toward the "usage
// today" figure so the human-facing stat reflects our reach.
if let Some(n) = cached.get("components").and_then(|v| v.as_array()).map(|a| a.len() as u64) {
// (We increment a "served from cache" counter by reusing
// results_returned; searches is not incremented because
// that metric is about upstream quota use.)
let _ = n; // placeholder — not tracked yet; upstream-quota tracking is the priority.
}
return serde_json::from_value(cached).map_err(|e| e.to_string());
}
match mouser::search(api_key, keyword, limit, in_stock_only) {
Ok(result) => {
let returned = result.components.len() as u64;
crate::quota::record_success(returned);
let v = serde_json::to_value(&result).map_err(|e| e.to_string())?;
cache_set(cache, cache_key, v);
Ok(result)
}
Err(e) => {
// Detect quota-exhausted response in the 403-body path too.
let lower = e.to_lowercase();
if lower.contains("maximum calls per day") || lower.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
));
}
}
Err(e)
}
}
}
fn do_part(
api_key: &str,
cache: &Arc<Mutex<HashMap<String, CacheEntry>>>,
part_number: &str,
) -> Result<SearchResult, String> {
let cache_key = format!("p:{}", part_number);
if let Some(cached) = cache_get(cache, &cache_key) {
return serde_json::from_value(cached).map_err(|e| e.to_string());
}
let result = mouser::part(api_key, part_number)?;
let v = serde_json::to_value(&result).map_err(|e| e.to_string())?;
cache_set(cache, cache_key, v);
Ok(result)
}
fn health(
api_key: &str,
cache: &Arc<Mutex<HashMap<String, CacheEntry>>>,
live: bool,
) -> (u16, Value) {
let has_key = !api_key.is_empty();
let cache_size = cache.lock().map(|m| m.len()).unwrap_or(0);
let mut hr = HealthResponse {
ok: has_key,
version: env!("CARGO_PKG_VERSION"),
cache_size,
cache_max: CACHE_MAX,
cache_ttl_ms: CACHE_TTL.as_millis() as u64,
error: if has_key { None } else { Some("MOUSER_API_KEY not set") },
api_reachable: None,
api_error: None,
test_results: None,
quota: crate::quota::get(),
kel_alert_configured: std::env::var("KEL_ALERT_WEBHOOK").map(|s| !s.is_empty()).unwrap_or(false),
};
if has_key && live {
match mouser::ping(api_key) {
Ok(n) => {
hr.api_reachable = Some(true);
hr.test_results = Some(n);
}
Err(e) => {
hr.api_reachable = Some(false);
hr.api_error = Some(e);
}
}
}
let code = if hr.ok { 200 } else { 500 };
(code, serde_json::to_value(&hr).unwrap_or_else(|_| json!({"ok": false})))
}
pub fn run_serve(args: ServeArgs) -> Result<(), String> {
let api_key = std::env::var("MOUSER_API_KEY").unwrap_or_default();
if api_key.is_empty() {
warn("MOUSER_API_KEY is not set — service will return errors for live queries.");
}
let addr = format!("0.0.0.0:{}", args.port);
let server = Server::http(&addr).map_err(|e| format!("bind {}: {}", addr, e))?;
let cache: Arc<Mutex<HashMap<String, CacheEntry>>> = Arc::new(Mutex::new(HashMap::new()));
ok(format!("adom-mouser backend listening on port {}", args.port));
note!(" {CYAN}GET /health{RESET} — health check + live API ping");
note!(" {CYAN}GET /search?keyword=...{RESET} — keyword/MPN search");
note!(" {CYAN}GET /part?partNumber=...{RESET} — part lookup");
note!(" {CYAN}POST /{RESET} — action-based API");
for mut request in server.incoming_requests() {
// CORS preflight
if request.method() == &Method::Options {
let _ = request.respond(json_resp(204, json!({})));
continue;
}
let url = request.url().to_string();
let method = request.method().clone();
let path = path_of(&url).trim_end_matches('/');
let path = if path.is_empty() { "/" } else { path };
// Landing page on GET / with Accept: text/html
if method == Method::Get && path == "/" {
let wants_html = request
.headers()
.iter()
.any(|h| h.field.equiv("Accept") && h.value.as_str().contains("text/html"));
if wants_html {
let proto = request
.headers()
.iter()
.find(|h| h.field.equiv("X-Forwarded-Proto"))
.map(|h| h.value.as_str().to_string())
.unwrap_or_else(|| "https".into());
let host = request
.headers()
.iter()
.find(|h| h.field.equiv("X-Forwarded-Host"))
.or_else(|| request.headers().iter().find(|h| h.field.equiv("Host")))
.map(|h| h.value.as_str().to_string())
.unwrap_or_else(|| format!("localhost:{}", args.port));
let base = format!("{}://{}", proto, host);
let key_present = !api_key.is_empty();
let html = LANDING_HTML
.replace("{{BASE_URL}}", &base)
.replace("{{STATUS_CLASS}}", if key_present { "ok" } else { "err" })
.replace(
"{{STATUS_TEXT}}",
if key_present {
"Healthy — API key configured"
} else {
"Error — MOUSER_API_KEY not set"
},
);
let _ = request.respond(html_resp(html));
continue;
}
}
let result: (u16, Value) = match (&method, path) {
(Method::Get, "/health") => {
// Default: CHEAP health check — no upstream ping (every
// ping burns a slot on Mouser's daily-quota counter).
// Opt-in via `?live=true` when you actually want the
// end-to-end reachability test.
let q = parse_query(&url);
let live = q.get("live").map(|s| s == "true").unwrap_or(false);
health(&api_key, &cache, live)
}
(Method::Get, "/search") => {
let q = parse_query(&url);
let keyword = q.get("keyword").cloned().unwrap_or_default();
let limit: u32 = q.get("limit").and_then(|s| s.parse().ok()).unwrap_or(10);
let in_stock = q.get("inStockOnly").map(|s| s == "true").unwrap_or(false);
match do_search(&api_key, &cache, &keyword, limit, in_stock) {
Ok(r) => (200, serde_json::to_value(&r).unwrap()),
Err(e) => (400, json!({"error": e})),
}
}
(Method::Get, "/part") => {
let q = parse_query(&url);
let pn = q.get("partNumber").cloned().unwrap_or_default();
match do_part(&api_key, &cache, &pn) {
Ok(r) => (200, serde_json::to_value(&r).unwrap()),
Err(e) => (400, json!({"error": e})),
}
}
(Method::Post, "/") => {
let mut body = String::new();
if std::io::Read::read_to_string(&mut request.as_reader(), &mut body).is_err() {
let _ = request.respond(json_resp(400, json!({"error": "read body"})));
continue;
}
let body_json: Value = match serde_json::from_str(&body) {
Ok(v) => v,
Err(_) => {
let _ = request.respond(json_resp(400, json!({"error": "invalid JSON"})));
continue;
}
};
let action = body_json.get("action").and_then(|v| v.as_str()).unwrap_or("");
match action {
"search" => {
let keyword = body_json.get("keyword").and_then(|v| v.as_str()).unwrap_or("").to_string();
let limit = body_json.get("limit").and_then(|v| v.as_u64()).unwrap_or(10) as u32;
let in_stock = body_json.get("inStockOnly").and_then(|v| v.as_bool()).unwrap_or(false);
match do_search(&api_key, &cache, &keyword, limit, in_stock) {
Ok(r) => (200, serde_json::to_value(&r).unwrap()),
Err(e) => (400, json!({"error": e})),
}
}
"part" => {
let pn = body_json.get("partNumber").and_then(|v| v.as_str()).unwrap_or("").to_string();
match do_part(&api_key, &cache, &pn) {
Ok(r) => (200, serde_json::to_value(&r).unwrap()),
Err(e) => (400, json!({"error": e})),
}
}
"health" => health(&api_key, &cache, false),
other => (400, json!({"error": format!("unknown action: '{}'. Try: search, part, health", other)})),
}
}
_ => (404, json!({"error": format!("unknown endpoint: {} {}", method, path)})),
};
let _ = request.respond(json_resp(result.0, result.1));
}
Ok(())
}