Skip to main content

phemios_core/
concerts.rs

1//! Explicit JamBase v3 reads with native credentials and durable, fail-closed admission.
2use 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.";
11/// Validate header-safe provider credentials without returning secrets in errors.
12pub 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}
34/// Fixed HTTPS host, no redirects, bounded responses and at most one HTTP request per second.
35pub 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    /// Check the unmetered shared subscription quota, then charge locally before the event request.
107    /// No retries: each explicit attempt, including a failed HTTP read, consumes one local slot.
108    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(&quota)?;
134    budget.reconcile(&quota)?;
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;