use std::collections::BTreeMap;
use serde_json::{Map, Value};
pub fn content_path(sha1: &str) -> Result<String, String> {
if sha1.len() < 2 {
return Err(format!("sha1 非法(长度 < 2): \"{sha1}\""));
}
let shard = &sha1[..2];
Ok(format!("objects/{shard}/{sha1}"))
}
pub fn is_external_url(reference: &str) -> bool {
let r = reference.trim().to_ascii_lowercase();
r.starts_with("http://") || r.starts_with("https://") || r.starts_with("data:")
}
pub fn ref_kind(reference: &str) -> &'static str {
if is_external_url(reference) {
"external"
} else {
"internal"
}
}
pub fn compose_url(reference: &str, cfg: &Value) -> Result<String, String> {
if reference.is_empty() {
return Err("resource reference 为空".to_string());
}
let obj = cfg.as_object();
if is_external_url(reference) {
let passthrough = obj
.and_then(|o| o.get("externalPassthrough"))
.and_then(|v| v.as_bool())
.unwrap_or(true);
return if passthrough {
Ok(reference.to_string())
} else {
Err(format!("外部 URL 直返被禁用: \"{reference}\""))
};
}
let empty = Map::new();
let o = obj.unwrap_or(&empty);
let base = o.get("baseUrl").and_then(|v| v.as_str()).unwrap_or("");
let template = o
.get("pathTemplate")
.and_then(|v| v.as_str())
.unwrap_or("{key}");
let mut vars: BTreeMap<String, String> = BTreeMap::new();
vars.insert("key".into(), reference.to_string());
vars.insert("sha1".into(), reference.to_string());
vars.insert("contentPath".into(), content_path(reference)?);
if let Some(Value::Object(m)) = o.get("vars") {
for (k, v) in m {
vars.insert(k.clone(), scalar_to_string(v));
}
}
let path = render(template, &vars);
let mut url = join_base(base, &path);
if let Some(Value::Object(q)) = o.get("query") {
let mut pairs: BTreeMap<String, String> = BTreeMap::new();
for (k, v) in q {
pairs.insert(
k.clone(),
percent_encode(&render(&scalar_to_string(v), &vars)),
);
}
if !pairs.is_empty() {
let qs = pairs
.iter()
.map(|(k, v)| format!("{}={}", percent_encode(k), v))
.collect::<Vec<_>>()
.join("&");
url.push('?');
url.push_str(&qs);
}
}
Ok(url)
}
fn render(template: &str, vars: &BTreeMap<String, String>) -> String {
let mut out = String::with_capacity(template.len());
let mut rest = template;
while let Some(open) = rest.find('{') {
out.push_str(&rest[..open]);
let after = &rest[open + 1..];
match after.find('}') {
Some(close) => {
let name = &after[..close];
match vars.get(name) {
Some(v) => out.push_str(v),
None => {
out.push('{');
out.push_str(name);
out.push('}');
}
}
rest = &after[close + 1..];
}
None => {
out.push_str(&rest[open..]);
rest = "";
}
}
}
out.push_str(rest);
out
}
fn join_base(base: &str, path: &str) -> String {
if base.is_empty() {
return path.to_string();
}
let b = base.trim_end_matches('/');
if path.starts_with('/') {
format!("{b}{path}")
} else {
format!("{b}/{path}")
}
}
fn scalar_to_string(v: &Value) -> String {
match v {
Value::String(s) => s.clone(),
Value::Number(n) => n.to_string(),
Value::Bool(b) => b.to_string(),
_ => String::new(),
}
}
fn percent_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
let unreserved = b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~');
if unreserved {
out.push(b as char);
} else {
out.push_str(&format!("%{b:02X}"));
}
}
out
}