use std::collections::BTreeMap;
use std::time::SystemTime;
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::Value;
use crate::time::{from_epoch_seconds_clamped, parse_api_timestamp, parse_iso8601_utc, serde_iso8601};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct SubProductSeats {
pub total: i64,
pub used: i64,
pub remaining: i64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SubProduct {
pub id: String,
pub active: bool,
pub trial: bool,
pub revoked: bool,
#[serde(serialize_with = "serde_iso8601::option::serialize", skip_serializing_if = "Option::is_none")]
pub expires_at: Option<SystemTime>,
#[serde(skip_serializing_if = "Option::is_none")]
pub seats: Option<SubProductSeats>,
}
fn optional_flag(value: &Value, key: &str) -> bool {
value.get(key).and_then(Value::as_bool).unwrap_or(false)
}
fn optional_text(value: &Value, key: &str) -> Option<String> {
value.get(key).and_then(Value::as_str).map(str::to_owned)
}
fn number_as_i64(value: &Value) -> Option<i64> {
value.as_i64().or_else(|| value.as_f64().map(|number| number as i64))
}
fn optional_seats(value: &Value, key: &str) -> Option<SubProductSeats> {
let seats = value.get(key).filter(|seats| seats.is_object())?;
Some(SubProductSeats {
total: number_as_i64(seats.get("total")?)?,
used: number_as_i64(seats.get("used")?)?,
remaining: number_as_i64(seats.get("remaining")?)?,
})
}
impl SubProduct {
pub(crate) fn from_claim(entry: &Value) -> Option<Self> {
Some(Self {
id: entry.get("id")?.as_str()?.to_owned(),
active: optional_flag(entry, "active"),
trial: optional_flag(entry, "trial"),
revoked: optional_flag(entry, "revoked"),
expires_at: entry.get("exp").and_then(number_as_i64).map(from_epoch_seconds_clamped),
seats: optional_seats(entry, "seats"),
})
}
fn from_stored(entry: &Value) -> Option<Self> {
let expires_at = match entry.get("expiresAt") {
Some(Value::String(text)) => Some(parse_iso8601_utc(text).ok()?),
_ => None,
};
Some(Self {
id: entry.get("id")?.as_str()?.to_owned(),
active: optional_flag(entry, "active"),
trial: optional_flag(entry, "trial"),
revoked: optional_flag(entry, "revoked"),
expires_at,
seats: optional_seats(entry, "seats"),
})
}
}
pub(crate) fn sub_products_from_claim(payload: &Value) -> Vec<SubProduct> {
payload
.get("sp")
.and_then(Value::as_array)
.map(|entries| entries.iter().filter(|entry| entry.is_object()).filter_map(SubProduct::from_claim).collect())
.unwrap_or_default()
}
pub(crate) fn lenient_sub_products<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<SubProduct>, D::Error> {
let value = Value::deserialize(deserializer)?;
Ok(value.as_array().map(|entries| entries.iter().filter_map(SubProduct::from_stored).collect()).unwrap_or_default())
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SubProductRecurrence {
#[serde(rename = "type")]
pub kind: String,
pub cycle_length: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SubProductPricing {
pub id: String,
pub name: String,
pub original_price: BTreeMap<String, f64>,
pub price: BTreeMap<String, f64>,
pub has_discount: bool,
pub recurrence: Option<SubProductRecurrence>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SubProductCatalogProduct {
pub id: String,
pub name: String,
pub tagline: Option<String>,
pub description: Option<String>,
pub website: Option<String>,
pub icon_url: Option<String>,
pub owned: bool,
pub subscribed: bool,
pub default_variation: Option<SubProductPricing>,
pub variations: Vec<SubProductPricing>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum SubProductTrialStatus {
#[default]
Unavailable,
Available,
Active,
Expired,
}
impl SubProductTrialStatus {
fn parse(value: &str) -> Self {
match value {
"Available" => Self::Available,
"Active" => Self::Active,
"Expired" => Self::Expired,
_ => Self::Unavailable,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SubProductTrialOffer {
pub status: SubProductTrialStatus,
pub number_of_days: Option<u32>,
#[serde(serialize_with = "serde_iso8601::option::serialize")]
pub expires_at: Option<SystemTime>,
pub requires_newsletter_opt_in: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SubProductCatalogEntry {
pub id: String,
pub product: SubProductCatalogProduct,
pub included_in_license: bool,
pub active: bool,
pub revoked: bool,
#[serde(serialize_with = "serde_iso8601::option::serialize")]
pub expires_at: Option<SystemTime>,
pub seats: Option<SubProductSeats>,
pub trial: Option<SubProductTrialOffer>,
pub buy_url: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SubProductCatalog {
pub currency: String,
pub sub_products: Vec<SubProductCatalogEntry>,
}
type Parsed<T> = Result<T, String>;
fn required<'a>(value: &'a Value, key: &str) -> Parsed<&'a Value> {
value.get(key).ok_or_else(|| format!("missing {key}"))
}
fn required_text(value: &Value, key: &str) -> Parsed<String> {
required(value, key)?.as_str().map(str::to_owned).ok_or_else(|| format!("{key} is not a string"))
}
fn required_flag(value: &Value, key: &str) -> Parsed<bool> {
required(value, key)?.as_bool().ok_or_else(|| format!("{key} is not a boolean"))
}
fn optional_api_time(value: &Value, key: &str) -> Parsed<Option<SystemTime>> {
match value.get(key) {
None | Some(Value::Null) => Ok(None),
Some(Value::String(text)) => parse_api_timestamp(text).map(Some).map_err(|_| format!("{key} is not a time")),
Some(_) => Err(format!("{key} is not a time")),
}
}
fn price_by_currency(value: &Value, key: &str) -> Parsed<BTreeMap<String, f64>> {
let prices = required(value, key)?;
Ok(prices
.as_object()
.map(|prices| {
prices
.iter()
.filter_map(|(currency, amount)| amount.as_f64().map(|amount| (currency.clone(), amount)))
.collect()
})
.unwrap_or_default())
}
fn parse_recurrence(value: &Value) -> Parsed<SubProductRecurrence> {
Ok(SubProductRecurrence { kind: required_text(value, "type")?, cycle_length: optional_text(value, "cycleLength") })
}
fn parse_pricing(value: &Value) -> Parsed<SubProductPricing> {
Ok(SubProductPricing {
id: required_text(value, "id")?,
name: required_text(value, "name")?,
original_price: price_by_currency(value, "originalPrice")?,
price: price_by_currency(value, "price")?,
has_discount: required_flag(value, "hasDiscount")?,
recurrence: match value.get("recurrence") {
Some(recurrence) if recurrence.is_object() => Some(parse_recurrence(recurrence)?),
_ => None,
},
})
}
fn parse_product(value: &Value) -> Parsed<SubProductCatalogProduct> {
Ok(SubProductCatalogProduct {
id: required_text(value, "id")?,
name: required_text(value, "name")?,
tagline: optional_text(value, "tagline"),
description: optional_text(value, "description"),
website: optional_text(value, "website"),
icon_url: optional_text(value, "iconUrl"),
owned: required_flag(value, "owned")?,
subscribed: required_flag(value, "subscribed")?,
default_variation: match value.get("defaultVariation") {
None | Some(Value::Null) => None,
Some(pricing) => Some(parse_pricing(pricing)?),
},
variations: match value.get("variations") {
None | Some(Value::Null) => Vec::new(),
Some(Value::Array(variations)) => variations.iter().map(parse_pricing).collect::<Parsed<_>>()?,
Some(_) => return Err("variations is not a list".into()),
},
})
}
fn parse_trial(value: &Value) -> Parsed<SubProductTrialOffer> {
Ok(SubProductTrialOffer {
status: SubProductTrialStatus::parse(value.get("status").and_then(Value::as_str).unwrap_or_default()),
number_of_days: value
.get("numberOfDays")
.and_then(number_as_i64)
.map(|days| u32::try_from(days.max(0)).unwrap_or(u32::MAX)),
expires_at: optional_api_time(value, "expiresAt")?,
requires_newsletter_opt_in: optional_flag(value, "requiresNewsletterOptIn"),
})
}
fn parse_entry(value: &Value) -> Parsed<SubProductCatalogEntry> {
Ok(SubProductCatalogEntry {
id: required_text(value, "id")?,
product: parse_product(required(value, "product")?)?,
included_in_license: required_flag(value, "includedInLicense")?,
active: required_flag(value, "active")?,
revoked: optional_flag(value, "revoked"),
expires_at: optional_api_time(value, "expiresAt")?,
seats: optional_seats(value, "seats"),
trial: match value.get("trial") {
Some(trial) if trial.is_object() => Some(parse_trial(trial)?),
_ => None,
},
buy_url: optional_text(value, "buyUrl").filter(|url| !url.is_empty()),
})
}
pub(crate) fn parse_catalog(value: &Value) -> Parsed<SubProductCatalog> {
let entries = required(value, "subProducts")?.as_array().ok_or("subProducts is not a list")?;
Ok(SubProductCatalog {
currency: required_text(value, "currency")?,
sub_products: entries.iter().map(parse_entry).collect::<Parsed<_>>()?,
})
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn the_sp_claim_is_read_leniently() {
let read = sub_products_from_claim(&json!({"sp": [
{"active": true},
"pack",
{"id": 5, "active": true},
{"id": "strings", "active": true, "trial": "yes", "future": {"field": 1}, "seats": {"total": 2}},
]}));
assert_eq!(read.len(), 1);
assert_eq!(read[0].id, "strings");
assert!(read[0].active);
assert!(!read[0].trial, "not a boolean");
assert_eq!(read[0].seats, None, "incomplete");
assert!(sub_products_from_claim(&json!({})).is_empty());
assert!(sub_products_from_claim(&json!({"sp": {"id": "pack"}})).is_empty());
}
}