use serde::{Deserialize, Deserializer, Serialize};
use std::collections::HashSet;
use std::fmt;
use std::thread;
use std::time::Duration;
pub const VERSION: &str = "1.0.0";
pub const DEFAULT_BASE_URL: &str = "https://www.appblocklist.com/api/v1";
pub const ENV_API_KEY: &str = "APPBLOCKLIST_API_KEY";
pub const MAX_ID_LENGTH: usize = 200;
pub const USER_AGENT: &str = "appblocklist-rust/1.0.0 (+https://www.appblocklist.com)";
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct App {
#[serde(default)]
pub found: bool,
#[serde(default)]
pub name: String,
#[serde(default)]
pub developer: String,
#[serde(default)]
pub bundle_id: String,
#[serde(default, deserialize_with = "string_or_number")]
pub app_store_id: String,
#[serde(default)]
pub android_package: String,
#[serde(default)]
pub app_store_genre: String,
#[serde(default)]
pub age_rating: String,
#[serde(default, deserialize_with = "vec_or_null")]
pub filtering_categories: Vec<String>,
#[serde(default)]
pub developer_domain: String,
#[serde(default)]
pub iab_category: String,
#[serde(default, rename = "error", skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
}
impl App {
pub fn primary_category(&self) -> Option<&str> {
self.filtering_categories.first().map(String::as_str)
}
pub fn in_category<S: AsRef<str>>(&self, categories: &[S]) -> bool {
self.filtering_categories.iter().any(|have| {
categories
.iter()
.any(|want| have.trim().eq_ignore_ascii_case(want.as_ref().trim()))
})
}
pub fn on_google_play(&self) -> bool {
!self.android_package.is_empty()
}
pub fn block_ids(&self) -> Vec<&str> {
let mut ids = Vec::new();
if !self.bundle_id.is_empty() {
ids.push(self.bundle_id.as_str());
}
if !self.android_package.is_empty() && self.android_package != self.bundle_id {
ids.push(self.android_package.as_str());
}
ids
}
}
fn string_or_number<'de, D: Deserializer<'de>>(d: D) -> Result<String, D::Error> {
Ok(match serde_json::Value::deserialize(d)? {
serde_json::Value::String(s) => s,
serde_json::Value::Null => String::new(),
other => other.to_string(),
})
}
fn vec_or_null<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<String>, D::Error> {
Ok(Option::<Vec<String>>::deserialize(d)?.unwrap_or_default())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Error {
MissingApiKey,
InvalidId(String),
Unauthorized(String),
QuotaExceeded {
message: String,
monthly_lookups: Option<u64>,
},
Server { status: u16, message: String },
Unexpected { status: u16, message: String },
Transport(String),
Decode(String),
}
impl Error {
pub fn status(&self) -> Option<u16> {
match self {
Error::InvalidId(_) => Some(400),
Error::Unauthorized(_) => Some(401),
Error::QuotaExceeded { .. } => Some(403),
Error::Server { status, .. } | Error::Unexpected { status, .. } => Some(*status),
_ => None,
}
}
pub fn is_temporary(&self) -> bool {
matches!(self, Error::Server { .. } | Error::Transport(_))
}
pub fn is_fatal(&self) -> bool {
matches!(
self,
Error::MissingApiKey | Error::Unauthorized(_) | Error::QuotaExceeded { .. }
)
}
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Error::MissingApiKey => write!(f, "appblocklist: no API key (set {})", ENV_API_KEY),
Error::InvalidId(m) => write!(f, "appblocklist: invalid id: {}", m),
Error::Unauthorized(m) => write!(f, "appblocklist: unauthorized: {}", m),
Error::QuotaExceeded { message, .. } => {
write!(f, "appblocklist: monthly lookups used up: {}", message)
}
Error::Server { status, message } => {
write!(f, "appblocklist: server error {}: {}", status, message)
}
Error::Unexpected { status, message } => {
write!(f, "appblocklist: unexpected HTTP {}: {}", status, message)
}
Error::Transport(m) => write!(f, "appblocklist: transport: {}", m),
Error::Decode(m) => write!(f, "appblocklist: decode: {}", m),
}
}
}
impl std::error::Error for Error {}
pub fn validate_id(id: &str) -> Result<&str, Error> {
let id = id.trim();
if id.is_empty() {
return Err(Error::InvalidId("empty id".into()));
}
if id.chars().count() > MAX_ID_LENGTH {
return Err(Error::InvalidId(format!(
"id longer than {} characters",
MAX_ID_LENGTH
)));
}
if id.chars().any(char::is_whitespace) {
return Err(Error::InvalidId("id contains whitespace".into()));
}
Ok(id)
}
pub fn parse_response(status: u16, body: &str) -> Result<App, Error> {
match status {
200 => serde_json::from_str::<App>(body).map_err(|e| Error::Decode(e.to_string())),
404 => {
let mut app = serde_json::from_str::<App>(body).unwrap_or_else(|_| App {
message: Some(body.trim().to_string()),
..App::default()
});
app.found = false;
Ok(app)
}
_ => {
let v: serde_json::Value = serde_json::from_str(body).unwrap_or(serde_json::Value::Null);
let message = v
.get("error")
.and_then(|e| e.as_str())
.map(str::to_string)
.unwrap_or_else(|| body.trim().to_string());
Err(match status {
400 => Error::InvalidId(message),
401 => Error::Unauthorized(message),
403 => Error::QuotaExceeded {
message,
monthly_lookups: v.get("monthly_lookups").and_then(|m| {
m.as_u64()
.or_else(|| m.as_str().and_then(|s| s.trim().parse().ok()))
}),
},
500..=599 => Error::Server { status, message },
_ => Error::Unexpected { status, message },
})
}
}
}
#[derive(Clone)]
pub struct Client {
api_key: String,
base_url: String,
agent: ureq::Agent,
max_retries: u32,
retry_wait: Duration,
}
impl fmt::Debug for Client {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Client")
.field("base_url", &self.base_url)
.field("max_retries", &self.max_retries)
.finish_non_exhaustive()
}
}
impl Client {
pub fn new(api_key: impl Into<String>) -> Self {
Client {
api_key: api_key.into().trim().to_string(),
base_url: DEFAULT_BASE_URL.to_string(),
agent: ureq::AgentBuilder::new()
.timeout(Duration::from_secs(30))
.user_agent(USER_AGENT)
.build(),
max_retries: 2,
retry_wait: Duration::from_secs(1),
}
}
pub fn from_env() -> Result<Self, Error> {
match std::env::var(ENV_API_KEY) {
Ok(k) if !k.trim().is_empty() => Ok(Client::new(k)),
_ => Err(Error::MissingApiKey),
}
}
pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
self.base_url = base_url.into().trim_end_matches('/').to_string();
self
}
pub fn with_agent(mut self, agent: ureq::Agent) -> Self {
self.agent = agent;
self
}
pub fn with_retries(mut self, max_retries: u32, first_wait: Duration) -> Self {
self.max_retries = max_retries;
self.retry_wait = first_wait;
self
}
pub fn lookup(&self, id: &str) -> Result<App, Error> {
let id = validate_id(id)?;
if self.api_key.is_empty() {
return Err(Error::MissingApiKey);
}
let mut wait = self.retry_wait;
let mut attempt = 0;
loop {
match self.once(id) {
Err(e) if e.is_temporary() && attempt < self.max_retries => {
thread::sleep(wait);
wait *= 2;
attempt += 1;
}
other => return other,
}
}
}
fn once(&self, id: &str) -> Result<App, Error> {
let url = format!("{}/app", self.base_url);
let result = self
.agent
.get(&url)
.query("id", id)
.set("X-API-Key", &self.api_key)
.set("Accept", "application/json")
.call();
let (status, response) = match result {
Ok(r) => (r.status(), r),
Err(ureq::Error::Status(code, r)) => (code, r),
Err(ureq::Error::Transport(t)) => return Err(Error::Transport(t.to_string())),
};
let body = response
.into_string()
.map_err(|e| Error::Transport(e.to_string()))?;
parse_response(status, &body)
}
pub fn lookup_many<I, S>(&self, ids: I, pause: Duration) -> (Vec<(String, Result<App, Error>)>, Option<Error>)
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let mut seen = HashSet::new();
let mut out = Vec::new();
for raw in ids {
let id = raw.as_ref().trim().to_string();
if id.is_empty() || !seen.insert(id.clone()) {
continue;
}
if !out.is_empty() && !pause.is_zero() {
thread::sleep(pause);
}
let res = self.lookup(&id);
if let Err(e) = &res {
if e.is_fatal() {
return (out, Some(e.clone()));
}
}
out.push((id, res));
}
(out, None)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validates_locally() {
assert_eq!(validate_id(" id835599320 ").unwrap(), "id835599320");
assert!(matches!(validate_id(""), Err(Error::InvalidId(_))));
assert!(matches!(validate_id("com.example app"), Err(Error::InvalidId(_))));
assert!(validate_id(&"a".repeat(MAX_ID_LENGTH + 1)).is_err());
assert!(validate_id(&"a".repeat(MAX_ID_LENGTH)).is_ok());
}
#[test]
fn missing_key_is_reported_before_any_request() {
let c = Client::new("").with_base_url("http://127.0.0.1:1");
assert_eq!(c.lookup("com.example"), Err(Error::MissingApiKey));
}
}