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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324
//! Backend HTTP proxy for the DigiKey API. Replaces
//! gallia/services/digikey/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: DIGIKEY_CLIENT_ID is REQUIRED.
//! Port: 8777 by default (configurable via --port / DIGIKEY_PORT).
use crate::digikey::{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>,
}
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(
client_id: &str,
client_secret: &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) {
return serde_json::from_value(cached).map_err(|e| e.to_string());
}
let result = digikey::search(client_id, client_secret, keyword, limit, in_stock_only)?;
let v = serde_json::to_value(&result).map_err(|e| e.to_string())?;
cache_set(cache, cache_key, v);
Ok(result)
}
fn do_part(
client_id: &str,
client_secret: &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 = digikey::part(client_id, client_secret, 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(
client_id: &str,
client_secret: &str,
cache: &Arc<Mutex<HashMap<String, CacheEntry>>>,
live: bool,
) -> (u16, Value) {
let has_creds = !client_id.is_empty() && !client_secret.is_empty();
let cache_size = cache.lock().map(|m| m.len()).unwrap_or(0);
let mut hr = HealthResponse {
ok: has_creds,
version: env!("CARGO_PKG_VERSION"),
cache_size,
cache_max: CACHE_MAX,
cache_ttl_ms: CACHE_TTL.as_millis() as u64,
error: if has_creds { None } else { Some("DIGIKEY_CLIENT_ID / DIGIKEY_CLIENT_SECRET not set") },
api_reachable: None,
api_error: None,
test_results: None,
};
if has_creds && live {
match digikey::ping(client_id, client_secret) {
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 client_id = std::env::var("DIGIKEY_CLIENT_ID").unwrap_or_default();
let client_secret = std::env::var("DIGIKEY_CLIENT_SECRET").unwrap_or_default();
if client_id.is_empty() || client_secret.is_empty() {
warn("DIGIKEY_CLIENT_ID / DIGIKEY_CLIENT_SECRET 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-digikey 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 = !client_id.is_empty() && !client_secret.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 — DIGIKEY_CLIENT_ID / DIGIKEY_CLIENT_SECRET not set"
},
);
let _ = request.respond(html_resp(html));
continue;
}
}
let result: (u16, Value) = match (&method, path) {
(Method::Get, "/health") => health(&client_id, &client_secret, &cache, true),
(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(&client_id, &client_secret, &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(&client_id, &client_secret, &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(&client_id, &client_secret, &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(&client_id, &client_secret, &cache, &pn) {
Ok(r) => (200, serde_json::to_value(&r).unwrap()),
Err(e) => (400, json!({"error": e})),
}
}
"health" => health(&client_id, &client_secret, &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(())
}