1use serde::{Deserialize, Deserializer, Serialize};
19use std::collections::HashSet;
20use std::fmt;
21use std::thread;
22use std::time::Duration;
23
24pub const VERSION: &str = "1.0.0";
26pub const DEFAULT_BASE_URL: &str = "https://www.appblocklist.com/api/v1";
28pub const ENV_API_KEY: &str = "APPBLOCKLIST_API_KEY";
30pub const MAX_ID_LENGTH: usize = 200;
32pub const USER_AGENT: &str = "appblocklist-rust/1.0.0 (+https://www.appblocklist.com)";
34
35#[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 #[serde(default, deserialize_with = "string_or_number")]
52 pub app_store_id: String,
53 #[serde(default)]
55 pub android_package: String,
56 #[serde(default)]
57 pub app_store_genre: String,
58 #[serde(default)]
60 pub age_rating: String,
61 #[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 #[serde(default, rename = "error", skip_serializing_if = "Option::is_none")]
70 pub message: Option<String>,
71}
72
73impl App {
74 pub fn primary_category(&self) -> Option<&str> {
76 self.filtering_categories.first().map(String::as_str)
77 }
78
79 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 pub fn on_google_play(&self) -> bool {
90 !self.android_package.is_empty()
91 }
92
93 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#[derive(Debug, Clone, PartialEq, Eq)]
120pub enum Error {
121 MissingApiKey,
123 InvalidId(String),
125 Unauthorized(String),
127 QuotaExceeded {
129 message: String,
130 monthly_lookups: Option<u64>,
131 },
132 Server { status: u16, message: String },
134 Unexpected { status: u16, message: String },
136 Transport(String),
138 Decode(String),
140}
141
142impl Error {
143 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 pub fn is_temporary(&self) -> bool {
156 matches!(self, Error::Server { .. } | Error::Transport(_))
157 }
158
159 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
191pub 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
210pub 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#[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 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 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 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 pub fn with_agent(mut self, agent: ureq::Agent) -> Self {
298 self.agent = agent;
299 self
300 }
301
302 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 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 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}