rust_store_core/
resource.rs1use std::collections::BTreeMap;
7
8use serde_json::{Map, Value};
9
10pub fn content_path(sha1: &str) -> Result<String, String> {
14 if sha1.len() < 2 {
15 return Err(format!("sha1 非法(长度 < 2): \"{sha1}\""));
16 }
17 let shard = &sha1[..2];
18 Ok(format!("objects/{shard}/{sha1}"))
19}
20
21pub fn is_external_url(reference: &str) -> bool {
23 let r = reference.trim().to_ascii_lowercase();
24 r.starts_with("http://") || r.starts_with("https://") || r.starts_with("data:")
25}
26
27pub fn ref_kind(reference: &str) -> &'static str {
29 if is_external_url(reference) {
30 "external"
31 } else {
32 "internal"
33 }
34}
35
36pub fn compose_url(reference: &str, cfg: &Value) -> Result<String, String> {
50 if reference.is_empty() {
51 return Err("resource reference 为空".to_string());
52 }
53 let obj = cfg.as_object();
54 if is_external_url(reference) {
55 let passthrough = obj
56 .and_then(|o| o.get("externalPassthrough"))
57 .and_then(|v| v.as_bool())
58 .unwrap_or(true);
59 return if passthrough {
60 Ok(reference.to_string())
61 } else {
62 Err(format!("外部 URL 直返被禁用: \"{reference}\""))
63 };
64 }
65 let empty = Map::new();
66 let o = obj.unwrap_or(&empty);
67 let base = o.get("baseUrl").and_then(|v| v.as_str()).unwrap_or("");
68 let template = o
69 .get("pathTemplate")
70 .and_then(|v| v.as_str())
71 .unwrap_or("{key}");
72 let mut vars: BTreeMap<String, String> = BTreeMap::new();
73 vars.insert("key".into(), reference.to_string());
74 vars.insert("sha1".into(), reference.to_string());
75 vars.insert("contentPath".into(), content_path(reference)?);
76 if let Some(Value::Object(m)) = o.get("vars") {
77 for (k, v) in m {
78 vars.insert(k.clone(), scalar_to_string(v));
79 }
80 }
81 let path = render(template, &vars);
82 let mut url = join_base(base, &path);
83 if let Some(Value::Object(q)) = o.get("query") {
84 let mut pairs: BTreeMap<String, String> = BTreeMap::new();
85 for (k, v) in q {
86 pairs.insert(
87 k.clone(),
88 percent_encode(&render(&scalar_to_string(v), &vars)),
89 );
90 }
91 if !pairs.is_empty() {
92 let qs = pairs
93 .iter()
94 .map(|(k, v)| format!("{}={}", percent_encode(k), v))
95 .collect::<Vec<_>>()
96 .join("&");
97 url.push('?');
98 url.push_str(&qs);
99 }
100 }
101 Ok(url)
102}
103
104fn render(template: &str, vars: &BTreeMap<String, String>) -> String {
106 let mut out = String::with_capacity(template.len());
107 let mut rest = template;
108 while let Some(open) = rest.find('{') {
109 out.push_str(&rest[..open]);
110 let after = &rest[open + 1..];
111 match after.find('}') {
112 Some(close) => {
113 let name = &after[..close];
114 match vars.get(name) {
115 Some(v) => out.push_str(v),
116 None => {
117 out.push('{');
118 out.push_str(name);
119 out.push('}');
120 }
121 }
122 rest = &after[close + 1..];
123 }
124 None => {
125 out.push_str(&rest[open..]);
126 rest = "";
127 }
128 }
129 }
130 out.push_str(rest);
131 out
132}
133
134fn join_base(base: &str, path: &str) -> String {
136 if base.is_empty() {
137 return path.to_string();
138 }
139 let b = base.trim_end_matches('/');
140 if path.starts_with('/') {
141 format!("{b}{path}")
142 } else {
143 format!("{b}/{path}")
144 }
145}
146
147fn scalar_to_string(v: &Value) -> String {
149 match v {
150 Value::String(s) => s.clone(),
151 Value::Number(n) => n.to_string(),
152 Value::Bool(b) => b.to_string(),
153 _ => String::new(),
154 }
155}
156
157fn percent_encode(s: &str) -> String {
159 let mut out = String::with_capacity(s.len());
160 for b in s.bytes() {
161 let unreserved = b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~');
162 if unreserved {
163 out.push(b as char);
164 } else {
165 out.push_str(&format!("%{b:02X}"));
166 }
167 }
168 out
169}