Skip to main content

appblocklist/
lib.rs

1//! Client for the [AppBlockList](https://www.appblocklist.com) App Lookup API.
2//!
3//! One call takes an iOS bundle ID, an App Store ID (with or without the `id`
4//! prefix) or an Android package name and returns the app's name, developer,
5//! store genre, age rating, ranked filtering categories and IAB category.
6//!
7//! ```no_run
8//! let client = appblocklist::Client::from_env()?;
9//! let app = client.lookup("com.zhiliaoapp.musically")?;
10//! if app.found && app.in_category(&["Social Networking"]) {
11//!     println!("block {}", app.bundle_id);
12//! }
13//! # Ok::<(), appblocklist::Error>(())
14//! ```
15//!
16//! A 404 is not an error: it comes back as an [`App`] with `found == false`.
17
18use serde::{Deserialize, Deserializer, Serialize};
19use std::collections::HashSet;
20use std::fmt;
21use std::thread;
22use std::time::Duration;
23
24/// Version of this crate.
25pub const VERSION: &str = "1.0.0";
26/// API root. The lookup endpoint is `DEFAULT_BASE_URL + "/app"`.
27pub const DEFAULT_BASE_URL: &str = "https://www.appblocklist.com/api/v1";
28/// Environment variable read by [`Client::from_env`].
29pub const ENV_API_KEY: &str = "APPBLOCKLIST_API_KEY";
30/// Longest ID the API accepts.
31pub const MAX_ID_LENGTH: usize = 200;
32/// User-Agent sent with every request.
33pub const USER_AGENT: &str = "appblocklist-rust/1.0.0 (+https://www.appblocklist.com)";
34
35/// One answer from the lookup endpoint.
36///
37/// When `found` is false the other fields are empty, except `android_package`
38/// for apps listed on Google Play but not yet categorized, and `message`,
39/// which carries the API's explanation.
40#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
41pub struct App {
42    #[serde(default)]
43    pub found: bool,
44    #[serde(default)]
45    pub name: String,
46    #[serde(default)]
47    pub developer: String,
48    #[serde(default)]
49    pub bundle_id: String,
50    /// Returned by the API as a string; numbers are accepted too.
51    #[serde(default, deserialize_with = "string_or_number")]
52    pub app_store_id: String,
53    /// Google Play twin, or an empty string.
54    #[serde(default)]
55    pub android_package: String,
56    #[serde(default)]
57    pub app_store_genre: String,
58    /// App Store age rating such as `4+`, `9+`, `12+` or `17+`.
59    #[serde(default)]
60    pub age_rating: String,
61    /// Ranked, most descriptive first. One or more of 58 categories.
62    #[serde(default, deserialize_with = "vec_or_null")]
63    pub filtering_categories: Vec<String>,
64    #[serde(default)]
65    pub developer_domain: String,
66    #[serde(default)]
67    pub iab_category: String,
68    /// The `error` text sent with a 404.
69    #[serde(default, rename = "error", skip_serializing_if = "Option::is_none")]
70    pub message: Option<String>,
71}
72
73impl App {
74    /// The most descriptive filtering category, if any.
75    pub fn primary_category(&self) -> Option<&str> {
76        self.filtering_categories.first().map(String::as_str)
77    }
78
79    /// True when the app carries any of `categories` (case-insensitive).
80    pub fn in_category<S: AsRef<str>>(&self, categories: &[S]) -> bool {
81        self.filtering_categories.iter().any(|have| {
82            categories
83                .iter()
84                .any(|want| have.trim().eq_ignore_ascii_case(want.as_ref().trim()))
85        })
86    }
87
88    /// True when an Android package name is known for the app.
89    pub fn on_google_play(&self) -> bool {
90        !self.android_package.is_empty()
91    }
92
93    /// Identifiers an MDM needs to block this app on both platforms.
94    pub fn block_ids(&self) -> Vec<&str> {
95        let mut ids = Vec::new();
96        if !self.bundle_id.is_empty() {
97            ids.push(self.bundle_id.as_str());
98        }
99        if !self.android_package.is_empty() && self.android_package != self.bundle_id {
100            ids.push(self.android_package.as_str());
101        }
102        ids
103    }
104}
105
106fn string_or_number<'de, D: Deserializer<'de>>(d: D) -> Result<String, D::Error> {
107    Ok(match serde_json::Value::deserialize(d)? {
108        serde_json::Value::String(s) => s,
109        serde_json::Value::Null => String::new(),
110        other => other.to_string(),
111    })
112}
113
114fn vec_or_null<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<String>, D::Error> {
115    Ok(Option::<Vec<String>>::deserialize(d)?.unwrap_or_default())
116}
117
118/// Everything that can go wrong in a lookup.
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub enum Error {
121    /// No key passed and `APPBLOCKLIST_API_KEY` not set.
122    MissingApiKey,
123    /// HTTP 400, or rejected locally before sending.
124    InvalidId(String),
125    /// HTTP 401: missing or invalid key.
126    Unauthorized(String),
127    /// HTTP 403: the key has used its monthly lookups.
128    QuotaExceeded {
129        message: String,
130        monthly_lookups: Option<u64>,
131    },
132    /// HTTP 500 or other 5xx: temporary, retry later.
133    Server { status: u16, message: String },
134    /// Any other status the API does not document.
135    Unexpected { status: u16, message: String },
136    /// Network, TLS or timeout problem.
137    Transport(String),
138    /// The body was not the JSON the API sends.
139    Decode(String),
140}
141
142impl Error {
143    /// HTTP status, when the error came from an answer.
144    pub fn status(&self) -> Option<u16> {
145        match self {
146            Error::InvalidId(_) => Some(400),
147            Error::Unauthorized(_) => Some(401),
148            Error::QuotaExceeded { .. } => Some(403),
149            Error::Server { status, .. } | Error::Unexpected { status, .. } => Some(*status),
150            _ => None,
151        }
152    }
153
154    /// True when retrying the same request later can succeed.
155    pub fn is_temporary(&self) -> bool {
156        matches!(self, Error::Server { .. } | Error::Transport(_))
157    }
158
159    /// True when no further lookups with this key can succeed right now.
160    pub fn is_fatal(&self) -> bool {
161        matches!(
162            self,
163            Error::MissingApiKey | Error::Unauthorized(_) | Error::QuotaExceeded { .. }
164        )
165    }
166}
167
168impl fmt::Display for Error {
169    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
170        match self {
171            Error::MissingApiKey => write!(f, "appblocklist: no API key (set {})", ENV_API_KEY),
172            Error::InvalidId(m) => write!(f, "appblocklist: invalid id: {}", m),
173            Error::Unauthorized(m) => write!(f, "appblocklist: unauthorized: {}", m),
174            Error::QuotaExceeded { message, .. } => {
175                write!(f, "appblocklist: monthly lookups used up: {}", message)
176            }
177            Error::Server { status, message } => {
178                write!(f, "appblocklist: server error {}: {}", status, message)
179            }
180            Error::Unexpected { status, message } => {
181                write!(f, "appblocklist: unexpected HTTP {}: {}", status, message)
182            }
183            Error::Transport(m) => write!(f, "appblocklist: transport: {}", m),
184            Error::Decode(m) => write!(f, "appblocklist: decode: {}", m),
185        }
186    }
187}
188
189impl std::error::Error for Error {}
190
191/// Trim an ID and check it locally: non-empty, at most 200 characters,
192/// no whitespace inside. The API does the full format check.
193pub fn validate_id(id: &str) -> Result<&str, Error> {
194    let id = id.trim();
195    if id.is_empty() {
196        return Err(Error::InvalidId("empty id".into()));
197    }
198    if id.chars().count() > MAX_ID_LENGTH {
199        return Err(Error::InvalidId(format!(
200            "id longer than {} characters",
201            MAX_ID_LENGTH
202        )));
203    }
204    if id.chars().any(char::is_whitespace) {
205        return Err(Error::InvalidId("id contains whitespace".into()));
206    }
207    Ok(id)
208}
209
210/// Turn an HTTP status and body into a result, exactly as [`Client::lookup`]
211/// does. Useful for replaying recorded answers in tests.
212pub fn parse_response(status: u16, body: &str) -> Result<App, Error> {
213    match status {
214        200 => serde_json::from_str::<App>(body).map_err(|e| Error::Decode(e.to_string())),
215        404 => {
216            let mut app = serde_json::from_str::<App>(body).unwrap_or_else(|_| App {
217                message: Some(body.trim().to_string()),
218                ..App::default()
219            });
220            app.found = false;
221            Ok(app)
222        }
223        _ => {
224            let v: serde_json::Value = serde_json::from_str(body).unwrap_or(serde_json::Value::Null);
225            let message = v
226                .get("error")
227                .and_then(|e| e.as_str())
228                .map(str::to_string)
229                .unwrap_or_else(|| body.trim().to_string());
230            Err(match status {
231                400 => Error::InvalidId(message),
232                401 => Error::Unauthorized(message),
233                403 => Error::QuotaExceeded {
234                    message,
235                    monthly_lookups: v.get("monthly_lookups").and_then(|m| {
236                        m.as_u64()
237                            .or_else(|| m.as_str().and_then(|s| s.trim().parse().ok()))
238                    }),
239                },
240                500..=599 => Error::Server { status, message },
241                _ => Error::Unexpected { status, message },
242            })
243        }
244    }
245}
246
247/// Blocking API client. Cheap to clone; clones share a connection pool.
248#[derive(Clone)]
249pub struct Client {
250    api_key: String,
251    base_url: String,
252    agent: ureq::Agent,
253    max_retries: u32,
254    retry_wait: Duration,
255}
256
257impl fmt::Debug for Client {
258    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
259        f.debug_struct("Client")
260            .field("base_url", &self.base_url)
261            .field("max_retries", &self.max_retries)
262            .finish_non_exhaustive()
263    }
264}
265
266impl Client {
267    /// Client with an explicit key and a 30 second timeout.
268    pub fn new(api_key: impl Into<String>) -> Self {
269        Client {
270            api_key: api_key.into().trim().to_string(),
271            base_url: DEFAULT_BASE_URL.to_string(),
272            agent: ureq::AgentBuilder::new()
273                .timeout(Duration::from_secs(30))
274                .user_agent(USER_AGENT)
275                .build(),
276            max_retries: 2,
277            retry_wait: Duration::from_secs(1),
278        }
279    }
280
281    /// Client with the key from `APPBLOCKLIST_API_KEY`.
282    pub fn from_env() -> Result<Self, Error> {
283        match std::env::var(ENV_API_KEY) {
284            Ok(k) if !k.trim().is_empty() => Ok(Client::new(k)),
285            _ => Err(Error::MissingApiKey),
286        }
287    }
288
289    /// Use another API root (for a local mock server, for example).
290    pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
291        self.base_url = base_url.into().trim_end_matches('/').to_string();
292        self
293    }
294
295    /// Use your own `ureq::Agent` (proxy, custom TLS, other timeouts).
296    /// Set a User-Agent on it with `AgentBuilder::user_agent(USER_AGENT)`.
297    pub fn with_agent(mut self, agent: ureq::Agent) -> Self {
298        self.agent = agent;
299        self
300    }
301
302    /// How often a 5xx or network error is retried, and the first pause
303    /// (doubled on every retry).
304    pub fn with_retries(mut self, max_retries: u32, first_wait: Duration) -> Self {
305        self.max_retries = max_retries;
306        self.retry_wait = first_wait;
307        self
308    }
309
310    /// Look up one app by iOS bundle ID, App Store ID or Android package
311    /// name. Each 200 answer counts as one lookup.
312    pub fn lookup(&self, id: &str) -> Result<App, Error> {
313        let id = validate_id(id)?;
314        if self.api_key.is_empty() {
315            return Err(Error::MissingApiKey);
316        }
317        let mut wait = self.retry_wait;
318        let mut attempt = 0;
319        loop {
320            match self.once(id) {
321                Err(e) if e.is_temporary() && attempt < self.max_retries => {
322                    thread::sleep(wait);
323                    wait *= 2;
324                    attempt += 1;
325                }
326                other => return other,
327            }
328        }
329    }
330
331    fn once(&self, id: &str) -> Result<App, Error> {
332        let url = format!("{}/app", self.base_url);
333        let result = self
334            .agent
335            .get(&url)
336            .query("id", id)
337            .set("X-API-Key", &self.api_key)
338            .set("Accept", "application/json")
339            .call();
340        let (status, response) = match result {
341            Ok(r) => (r.status(), r),
342            Err(ureq::Error::Status(code, r)) => (code, r),
343            Err(ureq::Error::Transport(t)) => return Err(Error::Transport(t.to_string())),
344        };
345        let body = response
346            .into_string()
347            .map_err(|e| Error::Transport(e.to_string()))?;
348        parse_response(status, &body)
349    }
350
351    /// Look up many IDs one after another, as the service asks. Blank and
352    /// repeated IDs are skipped. Per-ID errors are kept in the output; a
353    /// fatal error (401, 403, missing key) stops the loop and is returned
354    /// together with the results collected so far.
355    pub fn lookup_many<I, S>(&self, ids: I, pause: Duration) -> (Vec<(String, Result<App, Error>)>, Option<Error>)
356    where
357        I: IntoIterator<Item = S>,
358        S: AsRef<str>,
359    {
360        let mut seen = HashSet::new();
361        let mut out = Vec::new();
362        for raw in ids {
363            let id = raw.as_ref().trim().to_string();
364            if id.is_empty() || !seen.insert(id.clone()) {
365                continue;
366            }
367            if !out.is_empty() && !pause.is_zero() {
368                thread::sleep(pause);
369            }
370            let res = self.lookup(&id);
371            if let Err(e) = &res {
372                if e.is_fatal() {
373                    return (out, Some(e.clone()));
374                }
375            }
376            out.push((id, res));
377        }
378        (out, None)
379    }
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385
386    #[test]
387    fn validates_locally() {
388        assert_eq!(validate_id("  id835599320 ").unwrap(), "id835599320");
389        assert!(matches!(validate_id(""), Err(Error::InvalidId(_))));
390        assert!(matches!(validate_id("com.example app"), Err(Error::InvalidId(_))));
391        assert!(validate_id(&"a".repeat(MAX_ID_LENGTH + 1)).is_err());
392        assert!(validate_id(&"a".repeat(MAX_ID_LENGTH)).is_ok());
393    }
394
395    #[test]
396    fn missing_key_is_reported_before_any_request() {
397        let c = Client::new("").with_base_url("http://127.0.0.1:1");
398        assert_eq!(c.lookup("com.example"), Err(Error::MissingApiKey));
399    }
400}