use std::time::Duration;
use reqwest::{Client, StatusCode, Url};
use serde_json::Value;
use crate::error::{AppError, Result};
use crate::lceda::models::SearchItem;
use crate::util::nested_string;
const SEARCH_API: &str = "https://pro.lceda.cn/api/szlcsc/eda/product/list";
const COMPONENT_API: &str = "https://pro.lceda.cn/api/components";
const STEP_API: &str = "https://modules.lceda.cn/qAxj6KHrDKw4blvCG8QJPs7Y";
const OBJ_API: &str = "https://modules.lceda.cn/3dmodel";
const MAX_REQUEST_ATTEMPTS: usize = 3;
const RETRY_BASE_DELAY_MS: u64 = 250;
#[derive(Debug, Clone)]
pub struct LcedaClient {
client: Client,
}
impl LcedaClient {
pub fn new() -> Self {
let client = Client::builder()
.user_agent(format!("npnp/{}", env!("CARGO_PKG_VERSION")))
.connect_timeout(Duration::from_secs(5))
.timeout(Duration::from_secs(35))
.build()
.expect("failed to build reqwest client");
Self { client }
}
pub async fn search_components(&self, keyword: &str) -> Result<Vec<SearchItem>> {
let mut url = Url::parse(SEARCH_API)
.map_err(|err| AppError::InvalidResponse(format!("bad search url: {err}")))?;
url.query_pairs_mut().append_pair("wd", keyword);
let payload = self.get_json(url.as_str()).await?;
let Some(results) = payload.get("result").and_then(Value::as_array) else {
return Ok(Vec::new());
};
let mut items = Vec::with_capacity(results.len());
for (index, raw) in results.iter().enumerate() {
let attrs = raw.get("attributes").unwrap_or(&Value::Null);
items.push(SearchItem {
index: index + 1,
display_title: raw
.get("display_title")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
title: raw
.get("title")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
manufacturer: attrs
.get("Manufacturer")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
model_uuid: attrs
.get("3D Model")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
raw: raw.clone(),
});
}
Ok(items)
}
pub async fn select_item(&self, keyword: &str, index: usize) -> Result<SearchItem> {
let items = self.search_components(keyword).await?;
select_from_items(keyword, index, &items)
}
pub async fn component_detail(&self, uuid: &str) -> Result<Value> {
let mut url = Url::parse(&format!("{COMPONENT_API}/{uuid}"))
.map_err(|err| AppError::InvalidResponse(format!("bad component url: {err}")))?;
url.query_pairs_mut().append_pair("uuid", uuid);
self.get_json(url.as_str()).await
}
pub async fn get_model_uuid(&self, item: &SearchItem) -> Result<String> {
let Some(seed_uuid) = item.model_uuid.as_deref() else {
return Err(AppError::MissingModelUuid);
};
let detail = self.component_detail(seed_uuid).await?;
let code = detail
.get("code")
.and_then(Value::as_i64)
.unwrap_or_default();
if code == 0 {
if let Some(model_uuid) = nested_string(&detail, &["result", "3d_model_uuid"]) {
return Ok(model_uuid);
}
}
Ok(seed_uuid.to_string())
}
pub async fn download_step_bytes(&self, model_uuid: &str) -> Result<Vec<u8>> {
let url = format!("{STEP_API}/{model_uuid}");
self.get_bytes(&url).await
}
pub async fn download_obj_bytes(&self, model_uuid: &str) -> Result<Vec<u8>> {
let url = format!("{OBJ_API}/{model_uuid}");
self.get_bytes(&url).await
}
async fn get_json(&self, url: &str) -> Result<Value> {
let bytes = self.get_bytes(url).await?;
Ok(serde_json::from_slice(&bytes)?)
}
async fn get_bytes(&self, url: &str) -> Result<Vec<u8>> {
let mut last_error = None;
for attempt in 0..MAX_REQUEST_ATTEMPTS {
match self.get_bytes_once(url).await {
Ok(bytes) => return Ok(bytes),
Err(err)
if attempt + 1 < MAX_REQUEST_ATTEMPTS && should_retry_request_error(&err) =>
{
last_error = Some(err);
tokio::time::sleep(retry_delay_for_attempt(attempt)).await;
}
Err(err) => return Err(err.into()),
}
}
Err(last_error
.expect("retry loop should preserve the last error")
.into())
}
async fn get_bytes_once(&self, url: &str) -> std::result::Result<Vec<u8>, reqwest::Error> {
let response = self.client.get(url).send().await?.error_for_status()?;
let bytes = response.bytes().await?;
Ok(bytes.to_vec())
}
}
fn should_retry_request_error(error: &reqwest::Error) -> bool {
error.is_timeout() || error.is_connect() || error.status().is_some_and(is_retryable_status)
}
fn is_retryable_status(status: StatusCode) -> bool {
matches!(status.as_u16(), 408 | 425 | 429 | 500 | 502 | 503 | 504)
}
fn retry_delay_for_attempt(attempt: usize) -> Duration {
Duration::from_millis(RETRY_BASE_DELAY_MS.saturating_mul(1u64 << attempt.min(8)))
}
fn select_from_items(keyword: &str, index: usize, items: &[SearchItem]) -> Result<SearchItem> {
if items.is_empty() {
return Err(AppError::NoResults(keyword.to_string()));
}
if !(1..=items.len()).contains(&index) {
return Err(AppError::InvalidIndex {
keyword: keyword.to_string(),
index,
max: items.len(),
});
}
if index == 1 {
if let Some(item) = find_exact_lcsc_id_match(keyword, items) {
return Ok(item.clone());
}
}
Ok(items[index - 1].clone())
}
fn find_exact_lcsc_id_match<'a>(keyword: &str, items: &'a [SearchItem]) -> Option<&'a SearchItem> {
let keyword = keyword.trim();
if !is_lcsc_id_keyword(keyword) {
return None;
}
items.iter().find(|item| {
item.lcsc_id()
.as_deref()
.is_some_and(|lcsc_id| lcsc_id.eq_ignore_ascii_case(keyword))
})
}
fn is_lcsc_id_keyword(keyword: &str) -> bool {
let trimmed = keyword.trim();
let Some(digits) = trimmed
.strip_prefix('C')
.or_else(|| trimmed.strip_prefix('c'))
else {
return false;
};
!digits.is_empty() && digits.bytes().all(|byte| byte.is_ascii_digit())
}
impl Default for LcedaClient {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::{
is_lcsc_id_keyword, is_retryable_status, retry_delay_for_attempt, select_from_items,
};
use crate::lceda::SearchItem;
use reqwest::StatusCode;
use serde_json::json;
use std::time::Duration;
#[test]
fn selects_exact_lcsc_id_match_for_default_index() {
let items = vec![
item(1, "Almost Match", "C20400"),
item(2, "Exact Match", "C2040"),
];
let selected = select_from_items("C2040", 1, &items).expect("select exact item");
assert_eq!(selected.display_name(), "Exact Match");
assert_eq!(selected.index, 2);
}
#[test]
fn explicit_non_default_index_keeps_index_selection() {
let items = vec![item(1, "Exact Match", "C2040"), item(2, "Other", "C9999")];
let selected = select_from_items("C2040", 2, &items).expect("select indexed item");
assert_eq!(selected.display_name(), "Other");
assert_eq!(selected.index, 2);
}
#[test]
fn recognizes_exact_lcsc_id_keywords() {
assert!(is_lcsc_id_keyword("C2040"));
assert!(is_lcsc_id_keyword(" c2040 "));
assert!(!is_lcsc_id_keyword("C"));
assert!(!is_lcsc_id_keyword("C20A40"));
assert!(!is_lcsc_id_keyword("RP2040"));
}
#[test]
fn retries_transient_http_status_codes() {
for status in [
StatusCode::REQUEST_TIMEOUT,
StatusCode::TOO_EARLY,
StatusCode::TOO_MANY_REQUESTS,
StatusCode::INTERNAL_SERVER_ERROR,
StatusCode::BAD_GATEWAY,
StatusCode::SERVICE_UNAVAILABLE,
StatusCode::GATEWAY_TIMEOUT,
] {
assert!(
is_retryable_status(status),
"expected retryable status: {status}"
);
}
}
#[test]
fn does_not_retry_permanent_http_status_codes() {
for status in [
StatusCode::BAD_REQUEST,
StatusCode::UNAUTHORIZED,
StatusCode::FORBIDDEN,
StatusCode::NOT_FOUND,
StatusCode::UNPROCESSABLE_ENTITY,
] {
assert!(
!is_retryable_status(status),
"expected non-retryable status: {status}"
);
}
}
#[test]
fn retry_delay_grows_per_attempt() {
assert_eq!(retry_delay_for_attempt(0), Duration::from_millis(250));
assert_eq!(retry_delay_for_attempt(1), Duration::from_millis(500));
assert_eq!(retry_delay_for_attempt(2), Duration::from_millis(1000));
}
fn item(index: usize, display_title: &str, lcsc_id: &str) -> SearchItem {
SearchItem {
index,
display_title: display_title.to_string(),
title: String::new(),
manufacturer: String::new(),
model_uuid: None,
raw: json!({"product_code": lcsc_id}),
}
}
}