use std::{fmt, sync::Arc};
use remote_settings::{
Attachment, RemoteSettingsClient, RemoteSettingsError, RemoteSettingsRecord,
RemoteSettingsService,
};
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::{Map, Value};
use crate::{error::Error, query::full_keywords_to_fts_content, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Collection {
Amp,
Fakespot,
Other,
}
impl Collection {
pub fn name(&self) -> &'static str {
match self {
Self::Amp => "quicksuggest-amp",
Self::Fakespot => "fakespot-suggest-products",
Self::Other => "quicksuggest-other",
}
}
}
pub(crate) trait Client {
fn get_records(&self, collection: Collection) -> Result<Vec<Record>>;
fn download_attachment(&self, record: &Record) -> Result<Vec<u8>>;
}
pub struct SuggestRemoteSettingsClient {
amp_client: Arc<RemoteSettingsClient>,
other_client: Arc<RemoteSettingsClient>,
fakespot_client: Arc<RemoteSettingsClient>,
}
impl SuggestRemoteSettingsClient {
pub fn new(rs_service: &RemoteSettingsService) -> Result<Self> {
Ok(Self {
amp_client: rs_service.make_client(Collection::Amp.name().to_owned())?,
other_client: rs_service.make_client(Collection::Other.name().to_owned())?,
fakespot_client: rs_service.make_client(Collection::Fakespot.name().to_owned())?,
})
}
fn client_for_collection(&self, collection: Collection) -> &RemoteSettingsClient {
match collection {
Collection::Amp => &self.amp_client,
Collection::Other => &self.other_client,
Collection::Fakespot => &self.fakespot_client,
}
}
}
impl Client for SuggestRemoteSettingsClient {
fn get_records(&self, collection: Collection) -> Result<Vec<Record>> {
let client = self.client_for_collection(collection);
client.sync()?;
let response = client.get_records(false);
match response {
Some(r) => Ok(r
.into_iter()
.filter_map(|r| Record::new(r, collection).ok())
.collect()),
None => Err(Error::RemoteSettings(RemoteSettingsError::Other {
reason: "Unable to get records".to_owned(),
})),
}
}
fn download_attachment(&self, record: &Record) -> Result<Vec<u8>> {
let converted_record: RemoteSettingsRecord = record.clone().into();
match &record.attachment {
Some(_) => Ok(self
.client_for_collection(record.collection)
.get_attachment(&converted_record)?),
None => Err(Error::MissingAttachment(record.id.to_string())),
}
}
}
#[derive(Clone, Debug)]
pub(crate) struct Record {
pub id: SuggestRecordId,
pub last_modified: u64,
pub attachment: Option<Attachment>,
pub payload: SuggestRecord,
pub collection: Collection,
}
impl Record {
pub fn new(record: RemoteSettingsRecord, collection: Collection) -> Result<Self> {
Ok(Self {
id: SuggestRecordId::new(record.id),
last_modified: record.last_modified,
attachment: record.attachment,
payload: serde_json::from_value(serde_json::Value::Object(record.fields))?,
collection,
})
}
pub fn record_type(&self) -> SuggestRecordType {
(&self.payload).into()
}
}
impl From<Record> for RemoteSettingsRecord {
fn from(record: Record) -> Self {
RemoteSettingsRecord {
id: record.id.to_string(),
last_modified: record.last_modified,
deleted: false,
attachment: record.attachment.clone(),
fields: record.payload.to_json_map(),
}
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(tag = "type")]
pub(crate) enum SuggestRecord {
#[serde(rename = "icon")]
Icon,
#[serde(rename = "amp")]
Amp,
#[serde(rename = "wikipedia")]
Wikipedia,
#[serde(rename = "amo-suggestions")]
Amo,
#[serde(rename = "pocket-suggestions")]
Pocket,
#[serde(rename = "yelp-suggestions")]
Yelp,
#[serde(rename = "mdn-suggestions")]
Mdn,
#[serde(rename = "weather")]
Weather,
#[serde(rename = "configuration")]
GlobalConfig(DownloadedGlobalConfig),
#[serde(rename = "fakespot-suggestions")]
Fakespot,
#[serde(rename = "exposure-suggestions")]
Exposure(DownloadedExposureRecord),
#[serde(rename = "geonames")]
Geonames,
}
impl SuggestRecord {
fn to_json_map(&self) -> Map<String, Value> {
match serde_json::to_value(self) {
Ok(Value::Object(map)) => map,
_ => unreachable!(),
}
}
}
#[derive(Copy, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)]
pub enum SuggestRecordType {
Icon,
Amp,
Wikipedia,
Amo,
Pocket,
Yelp,
Mdn,
Weather,
GlobalConfig,
Fakespot,
Exposure,
Geonames,
}
impl From<&SuggestRecord> for SuggestRecordType {
fn from(suggest_record: &SuggestRecord) -> Self {
match suggest_record {
SuggestRecord::Amo => Self::Amo,
SuggestRecord::Amp => Self::Amp,
SuggestRecord::Wikipedia => Self::Wikipedia,
SuggestRecord::Icon => Self::Icon,
SuggestRecord::Mdn => Self::Mdn,
SuggestRecord::Pocket => Self::Pocket,
SuggestRecord::Weather => Self::Weather,
SuggestRecord::Yelp => Self::Yelp,
SuggestRecord::GlobalConfig(_) => Self::GlobalConfig,
SuggestRecord::Fakespot => Self::Fakespot,
SuggestRecord::Exposure(_) => Self::Exposure,
SuggestRecord::Geonames => Self::Geonames,
}
}
}
impl fmt::Display for SuggestRecordType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.as_str())
}
}
impl SuggestRecordType {
#[cfg(test)]
pub fn all() -> &'static [SuggestRecordType] {
&[
Self::Icon,
Self::Amp,
Self::Wikipedia,
Self::Amo,
Self::Pocket,
Self::Yelp,
Self::Mdn,
Self::Weather,
Self::GlobalConfig,
Self::Fakespot,
Self::Exposure,
Self::Geonames,
]
}
pub fn as_str(&self) -> &str {
match self {
Self::Icon => "icon",
Self::Amp => "amp",
Self::Wikipedia => "wikipedia",
Self::Amo => "amo-suggestions",
Self::Pocket => "pocket-suggestions",
Self::Yelp => "yelp-suggestions",
Self::Mdn => "mdn-suggestions",
Self::Weather => "weather",
Self::GlobalConfig => "configuration",
Self::Fakespot => "fakespot-suggestions",
Self::Exposure => "exposure-suggestions",
Self::Geonames => "geonames",
}
}
}
#[derive(Clone, Debug, Deserialize)]
#[serde(untagged)]
enum OneOrMany<T> {
One(T),
Many(Vec<T>),
}
#[derive(Clone, Debug, Deserialize)]
#[serde(transparent)]
pub(crate) struct SuggestAttachment<T>(OneOrMany<T>);
impl<T> SuggestAttachment<T> {
pub fn suggestions(&self) -> &[T] {
match &self.0 {
OneOrMany::One(value) => std::slice::from_ref(value),
OneOrMany::Many(values) => values,
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd)]
#[serde(transparent)]
pub(crate) struct SuggestRecordId(String);
impl SuggestRecordId {
pub fn new(id: String) -> Self {
Self(id)
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn as_icon_id(&self) -> Option<&str> {
self.0.strip_prefix("icon-")
}
}
impl fmt::Display for SuggestRecordId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Clone, Debug, Default, Deserialize)]
pub(crate) struct DownloadedAmpSuggestion {
pub keywords: Vec<String>,
pub title: String,
pub url: String,
pub score: Option<f64>,
#[serde(default)]
pub full_keywords: Vec<(String, usize)>,
pub advertiser: String,
#[serde(rename = "id")]
pub block_id: i32,
pub iab_category: String,
pub click_url: String,
pub impression_url: String,
#[serde(rename = "icon")]
pub icon_id: String,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub(crate) struct DownloadedWikipediaSuggestion {
pub keywords: Vec<String>,
pub title: String,
pub url: String,
pub score: Option<f64>,
#[serde(default)]
pub full_keywords: Vec<(String, usize)>,
#[serde(rename = "icon")]
pub icon_id: String,
}
pub fn iterate_keywords<'a>(
keywords: &'a [String],
full_keywords: &'a [(String, usize)],
) -> impl Iterator<Item = AmpKeyword<'a>> {
let full_keywords_iter = full_keywords
.iter()
.flat_map(|(full_keyword, repeat_for)| {
std::iter::repeat(Some(full_keyword.as_str())).take(*repeat_for)
})
.chain(std::iter::repeat(None)); keywords
.iter()
.zip(full_keywords_iter)
.enumerate()
.map(move |(i, (keyword, full_keyword))| AmpKeyword {
rank: i,
keyword,
full_keyword,
})
}
impl DownloadedAmpSuggestion {
pub fn keywords(&self) -> impl Iterator<Item = AmpKeyword<'_>> {
iterate_keywords(&self.keywords, &self.full_keywords)
}
pub fn full_keywords_fts_column(&self) -> String {
full_keywords_to_fts_content(self.full_keywords.iter().map(|(s, _)| s.as_str()))
}
}
impl DownloadedWikipediaSuggestion {
pub fn keywords(&self) -> impl Iterator<Item = AmpKeyword<'_>> {
iterate_keywords(&self.keywords, &self.full_keywords)
}
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct AmpKeyword<'a> {
pub rank: usize,
pub keyword: &'a str,
pub full_keyword: Option<&'a str>,
}
#[derive(Clone, Debug, Deserialize)]
pub(crate) struct DownloadedAmoSuggestion {
pub description: String,
pub url: String,
pub guid: String,
#[serde(rename = "icon")]
pub icon_url: String,
pub rating: Option<String>,
pub number_of_ratings: i64,
pub title: String,
pub keywords: Vec<String>,
pub score: f64,
}
#[derive(Clone, Debug, Deserialize)]
pub(crate) struct DownloadedPocketSuggestion {
pub url: String,
pub title: String,
#[serde(rename = "lowConfidenceKeywords")]
pub low_confidence_keywords: Vec<String>,
#[serde(rename = "highConfidenceKeywords")]
pub high_confidence_keywords: Vec<String>,
pub score: f64,
}
#[derive(Clone, Debug, Deserialize)]
pub(crate) struct DownloadedYelpLocationSign {
pub keyword: String,
#[serde(rename = "needLocation")]
pub need_location: bool,
}
#[derive(Clone, Debug, Deserialize)]
pub(crate) struct DownloadedYelpSuggestion {
pub subjects: Vec<String>,
#[serde(rename = "preModifiers")]
pub pre_modifiers: Vec<String>,
#[serde(rename = "postModifiers")]
pub post_modifiers: Vec<String>,
#[serde(rename = "locationSigns")]
pub location_signs: Vec<DownloadedYelpLocationSign>,
#[serde(rename = "yelpModifiers")]
pub yelp_modifiers: Vec<String>,
#[serde(rename = "icon")]
pub icon_id: String,
pub score: f64,
}
#[derive(Clone, Debug, Deserialize)]
pub(crate) struct DownloadedMdnSuggestion {
pub url: String,
pub title: String,
pub description: String,
pub keywords: Vec<String>,
pub score: f64,
}
#[derive(Clone, Debug, Deserialize)]
pub(crate) struct DownloadedFakespotSuggestion {
pub fakespot_grade: String,
pub product_id: String,
pub keywords: String,
pub product_type: String,
pub rating: f64,
pub score: f64,
pub title: String,
pub total_reviews: i64,
pub url: String,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub(crate) struct DownloadedExposureRecord {
pub suggestion_type: String,
}
#[derive(Clone, Debug, Deserialize)]
pub(crate) struct DownloadedExposureSuggestion {
keywords: Vec<FullOrPrefixKeywords<String>>,
}
impl DownloadedExposureSuggestion {
pub fn keywords(&self) -> impl Iterator<Item = String> + '_ {
self.keywords.iter().flat_map(|e| e.keywords())
}
}
#[derive(Clone, Debug, Deserialize)]
#[serde(untagged)]
enum FullOrPrefixKeywords<T> {
Full(T),
Prefix((T, Vec<T>)),
}
impl<T> From<T> for FullOrPrefixKeywords<T> {
fn from(full_keyword: T) -> Self {
Self::Full(full_keyword)
}
}
impl<T> From<(T, Vec<T>)> for FullOrPrefixKeywords<T> {
fn from(prefix_suffixes: (T, Vec<T>)) -> Self {
Self::Prefix(prefix_suffixes)
}
}
impl FullOrPrefixKeywords<String> {
pub fn keywords(&self) -> Box<dyn Iterator<Item = String> + '_> {
match self {
FullOrPrefixKeywords::Full(kw) => Box::new(std::iter::once(kw.to_owned())),
FullOrPrefixKeywords::Prefix((prefix, suffixes)) => Box::new(
std::iter::once(prefix.to_owned()).chain(suffixes.iter().flat_map(|suffix| {
let mut kw = prefix.clone();
suffix.chars().map(move |c| {
kw.push(c);
kw.clone()
})
})),
),
}
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub(crate) struct DownloadedGlobalConfig {
pub configuration: DownloadedGlobalConfigInner,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub(crate) struct DownloadedGlobalConfigInner {
pub show_less_frequently_cap: i32,
}
pub(crate) fn deserialize_f64_or_default<'de, D>(
deserializer: D,
) -> std::result::Result<f64, D::Error>
where
D: Deserializer<'de>,
{
String::deserialize(deserializer).map(|s| s.parse().ok().unwrap_or_default())
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn test_full_keywords() {
let suggestion = DownloadedAmpSuggestion {
keywords: vec![
String::from("f"),
String::from("fo"),
String::from("foo"),
String::from("foo b"),
String::from("foo ba"),
String::from("foo bar"),
],
full_keywords: vec![(String::from("foo"), 3), (String::from("foo bar"), 3)],
..DownloadedAmpSuggestion::default()
};
assert_eq!(
Vec::from_iter(suggestion.keywords()),
vec![
AmpKeyword {
rank: 0,
keyword: "f",
full_keyword: Some("foo"),
},
AmpKeyword {
rank: 1,
keyword: "fo",
full_keyword: Some("foo"),
},
AmpKeyword {
rank: 2,
keyword: "foo",
full_keyword: Some("foo"),
},
AmpKeyword {
rank: 3,
keyword: "foo b",
full_keyword: Some("foo bar"),
},
AmpKeyword {
rank: 4,
keyword: "foo ba",
full_keyword: Some("foo bar"),
},
AmpKeyword {
rank: 5,
keyword: "foo bar",
full_keyword: Some("foo bar"),
},
],
);
}
#[test]
fn test_missing_full_keywords() {
let suggestion = DownloadedAmpSuggestion {
keywords: vec![
String::from("f"),
String::from("fo"),
String::from("foo"),
String::from("foo b"),
String::from("foo ba"),
String::from("foo bar"),
],
full_keywords: vec![(String::from("foo"), 3)],
..DownloadedAmpSuggestion::default()
};
assert_eq!(
Vec::from_iter(suggestion.keywords()),
vec![
AmpKeyword {
rank: 0,
keyword: "f",
full_keyword: Some("foo"),
},
AmpKeyword {
rank: 1,
keyword: "fo",
full_keyword: Some("foo"),
},
AmpKeyword {
rank: 2,
keyword: "foo",
full_keyword: Some("foo"),
},
AmpKeyword {
rank: 3,
keyword: "foo b",
full_keyword: None,
},
AmpKeyword {
rank: 4,
keyword: "foo ba",
full_keyword: None,
},
AmpKeyword {
rank: 5,
keyword: "foo bar",
full_keyword: None,
},
],
);
}
fn full_or_prefix_keywords_to_owned(
kws: Vec<FullOrPrefixKeywords<&str>>,
) -> Vec<FullOrPrefixKeywords<String>> {
kws.iter()
.map(|val| match val {
FullOrPrefixKeywords::Full(s) => FullOrPrefixKeywords::Full(s.to_string()),
FullOrPrefixKeywords::Prefix((prefix, suffixes)) => FullOrPrefixKeywords::Prefix((
prefix.to_string(),
suffixes.iter().map(|s| s.to_string()).collect(),
)),
})
.collect()
}
#[test]
fn test_exposure_keywords() {
let suggestion = DownloadedExposureSuggestion {
keywords: full_or_prefix_keywords_to_owned(vec![
"no suffixes".into(),
("empty suffixes", vec![]).into(),
("empty string suffix", vec![""]).into(),
("choco", vec!["", "bo", "late"]).into(),
"duplicate 1".into(),
"duplicate 1".into(),
("dup", vec!["licate 1", "licate 2"]).into(),
("dup", vec!["lo", "licate 2", "licate 3"]).into(),
("duplic", vec!["ate 3", "ar", "ate 4"]).into(),
("du", vec!["plicate 4", "plicate 5", "nk"]).into(),
]),
};
assert_eq!(
Vec::from_iter(suggestion.keywords()),
vec![
"no suffixes",
"empty suffixes",
"empty string suffix",
"choco",
"chocob",
"chocobo",
"chocol",
"chocola",
"chocolat",
"chocolate",
"duplicate 1",
"duplicate 1",
"dup",
"dupl",
"dupli",
"duplic",
"duplica",
"duplicat",
"duplicate",
"duplicate ",
"duplicate 1",
"dupl",
"dupli",
"duplic",
"duplica",
"duplicat",
"duplicate",
"duplicate ",
"duplicate 2",
"dup",
"dupl",
"duplo",
"dupl",
"dupli",
"duplic",
"duplica",
"duplicat",
"duplicate",
"duplicate ",
"duplicate 2",
"dupl",
"dupli",
"duplic",
"duplica",
"duplicat",
"duplicate",
"duplicate ",
"duplicate 3",
"duplic",
"duplica",
"duplicat",
"duplicate",
"duplicate ",
"duplicate 3",
"duplica",
"duplicar",
"duplica",
"duplicat",
"duplicate",
"duplicate ",
"duplicate 4",
"du",
"dup",
"dupl",
"dupli",
"duplic",
"duplica",
"duplicat",
"duplicate",
"duplicate ",
"duplicate 4",
"dup",
"dupl",
"dupli",
"duplic",
"duplica",
"duplicat",
"duplicate",
"duplicate ",
"duplicate 5",
"dun",
"dunk",
],
);
}
}