1use serde_json::Value;
3use std::time::{Duration, Instant};
4use ureq::unversioned::transport::DefaultConnector;
5#[path = "concerts_budget.rs"]
6mod budget;
7#[path = "concerts_projection.rs"]
8mod projection;
9pub use budget::Budget;
10const INVALID: &str = "Réponse JamBase invalide.";
11pub fn validate_key(key: &str) -> Result<(), String> {
13 if !(16..=256).contains(&key.len())
14 || !key
15 .bytes()
16 .all(|v| v.is_ascii_alphanumeric() || b"_-".contains(&v))
17 {
18 return Err("Clé JamBase invalide.".into());
19 }
20 Ok(())
21}
22fn validate_query(key: &str, artist: &str, page: u32) -> Result<(), String> {
23 validate_key(key)?;
24 if artist.is_empty()
25 || artist.len() > 200
26 || artist.chars().any(char::is_control)
27 || artist.contains('|')
28 || page >= 10
29 {
30 return Err("Recherche de concerts invalide.".into());
31 }
32 Ok(())
33}
34pub struct Directory {
36 agent: ureq::Agent,
37 last: Option<Instant>,
38}
39impl Default for Directory {
40 fn default() -> Self {
41 let config = ureq::Agent::config_builder()
42 .timeout_global(Some(Duration::from_secs(15)))
43 .max_redirects(0)
44 .proxy(None)
45 .build();
46 Self {
47 agent: ureq::Agent::with_parts(
48 config,
49 DefaultConnector::default(),
50 crate::podcast::PublicResolver,
51 ),
52 last: None,
53 }
54 }
55}
56impl Directory {
57 fn request(
58 &mut self,
59 key: &str,
60 endpoint: &str,
61 artist: &str,
62 page: u32,
63 ) -> Result<Value, String> {
64 if let Some(last) = self.last {
65 std::thread::sleep(Duration::from_secs(1).saturating_sub(last.elapsed()));
66 }
67 self.last = Some(Instant::now());
68 let mut request = self
69 .agent
70 .get(endpoint)
71 .header("Authorization", format!("Bearer {key}"))
72 .header("Accept", "application/json")
73 .header(
74 "User-Agent",
75 concat!(
76 "Phemios/",
77 env!("CARGO_PKG_VERSION"),
78 " (https://github.com/craft-and-code/phemios)"
79 ),
80 );
81 request
82 .headers_mut()
83 .and_then(|headers| headers.get_mut("Authorization"))
84 .ok_or("Clé JamBase invalide.")?
85 .set_sensitive(true);
86 if !artist.is_empty() {
87 request = request
88 .query("artistName", artist)
89 .query("perPage", "100")
90 .query("page", (page + 1).to_string())
91 .query("sort", "eventDate");
92 }
93 let mut response = request.call().map_err(|error| match error {
94 ureq::Error::StatusCode(401 | 403) => "Clé JamBase refusée.",
95 ureq::Error::StatusCode(429) => "Limite JamBase atteinte. Réessayez plus tard.",
96 _ => "JamBase indisponible. Réessayez plus tard.",
97 })?;
98 let body = response
99 .body_mut()
100 .with_config()
101 .limit(2 * 1024 * 1024)
102 .read_to_vec()
103 .map_err(|_| "Réponse JamBase trop grande ou interrompue.")?;
104 serde_json::from_slice(&body).map_err(|_| INVALID.into())
105 }
106 pub fn events(
109 &mut self,
110 key: &str,
111 artist: &str,
112 page: u32,
113 budget: &mut Budget,
114 ) -> Result<Value, String> {
115 guarded_events(key, artist, page, budget, |endpoint, artist, page| {
116 self.request(key, endpoint, artist, page)
117 })
118 }
119}
120fn guarded_events(
121 key: &str,
122 artist: &str,
123 page: u32,
124 budget: &mut Budget,
125 mut request: impl FnMut(&str, &str, u32) -> Result<Value, String>,
126) -> Result<Value, String> {
127 let artist = artist.trim();
128 validate_query(key, artist, page)?;
129 if budget.status()["remaining"] == 0 {
130 return Err(budget::EXHAUSTED.into());
131 }
132 let quota = request("https://api.data.jambase.com/v3/quota", "", 0)?;
133 quota_allows("a)?;
134 budget.reconcile("a)?;
135 budget.reserve()?;
136 let value = request("https://api.data.jambase.com/v3/events", artist, page)?;
137 projection::project(&value, artist, page)
138}
139
140fn quota_allows(value: &Value) -> Result<(), String> {
141 let used = value["usedCalls"]
142 .as_u64()
143 .ok_or("Quota JamBase indisponible. Aucune recherche envoyée.")?;
144 let remaining = value["remainingCalls"]
145 .as_u64()
146 .ok_or("Quota JamBase indisponible. Aucune recherche envoyée.")?;
147 let quota = value["quota"].as_u64().ok_or(INVALID)?;
148 if used >= 1000 || remaining == 0 || used >= quota {
149 return Err(budget::EXHAUSTED.into());
150 }
151 if remaining > quota.saturating_sub(used) {
152 return Err(INVALID.into());
153 }
154 if value["overageCalls"].as_u64() != Some(0) || value["noMonthlyCap"] != false {
155 return Err(INVALID.into());
156 }
157 Ok(())
158}
159#[cfg(test)]
160#[path = "concerts_tests.rs"]
161mod tests;