Bug 1923689 - Implement basic configuration processing on SearchEngineSelector.

This implements processing the main parts of the search configuration JSON into the Rust structure using serde_json.
Currently the processing applies to:

* The identifier and base properties of the engine records.
* The default engine records.

As variant and environment handling is not yet implemented, the filter function will return all engines defined in the configuration.
This commit is contained in:
Mark Banner 2024-10-03 16:05:56 +01:00
Родитель a486b51698
Коммит 06a5d158b6
8 изменённых файлов: 633 добавлений и 23 удалений

3
Cargo.lock сгенерированный
Просмотреть файл

@ -3965,6 +3965,9 @@ name = "search"
version = "0.1.0"
dependencies = [
"error-support",
"parking_lot",
"serde",
"serde_json",
"thiserror",
"uniffi",
]

Просмотреть файл

@ -8,6 +8,9 @@ license = "MPL-2.0"
[dependencies]
error-support = { path = "../support/error" }
parking_lot = ">=0.11,<=0.12"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "1"
uniffi = { workspace = true }

Просмотреть файл

@ -0,0 +1,135 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
//! This module defines the structures that we use for serde_json to parse
//! the search configuration.
use crate::{SearchEngineClassification, SearchUrlParam};
use serde::Deserialize;
/// The list of possible submission methods for search engine urls.
#[derive(Debug, uniffi::Enum, PartialEq, Deserialize, Clone, Default)]
#[serde(rename_all = "UPPERCASE")]
pub(crate) enum JSONEngineMethod {
Post = 2,
#[serde(other)]
#[default]
Get = 1,
}
impl JSONEngineMethod {
pub fn as_str(&self) -> &'static str {
match self {
JSONEngineMethod::Get => "GET",
JSONEngineMethod::Post => "POST",
}
}
}
/// Defines an individual search engine URL. This is defined separately to
/// `types::SearchEngineUrl` as various fields may be optional in the supplied
/// configuration.
#[derive(Debug, uniffi::Record, PartialEq, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub(crate) struct JSONEngineUrl {
/// The PrePath and FilePath of the URL. May include variables for engines
/// which have a variable FilePath, e.g. `{searchTerm}` for when a search
/// term is within the path of the url.
pub base: String,
/// The HTTP method to use to send the request (`GET` or `POST`).
/// If the engine definition has not specified the method, it defaults to GET.
pub method: Option<JSONEngineMethod>,
/// The parameters for this URL.
pub params: Option<Vec<SearchUrlParam>>,
/// The name of the query parameter for the search term. Automatically
/// appended to the end of the query. This may be skipped if `{searchTerm}`
/// is included in the base.
pub search_term_param_name: Option<String>,
}
/// Reflects `types::SearchEngineUrls`, but using `EngineUrl`.
#[derive(Debug, uniffi::Record, PartialEq, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub(crate) struct JSONEngineUrls {
/// The URL to use for searches.
pub search: JSONEngineUrl,
/// The URL to use for suggestions.
pub suggestions: Option<JSONEngineUrl>,
/// The URL to use for trending suggestions.
pub trending: Option<JSONEngineUrl>,
}
/// Represents the engine base section of the configuration.
#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub(crate) struct JSONEngineBase {
/// A list of aliases for this engine.
pub aliases: Option<Vec<String>>,
/// The character set this engine uses for queries. Defaults to 'UTF=8' if not set.
pub charset: Option<String>,
/// The classification of search engine according to the main search types
/// (e.g. general, shopping, travel, dictionary). Currently, only marking as
/// a general search engine is supported.
pub classification: SearchEngineClassification,
/// The user visible name for the search engine.
pub name: String,
/// The partner code for the engine. This will be inserted into parameters
/// which include `{partnerCode}`.
pub partner_code: Option<String>,
/// The URLs associated with the search engine.
pub urls: JSONEngineUrls,
}
/// Represents an individual engine record in the configuration.
#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub(crate) struct JSONEngineRecord {
pub identifier: String,
pub base: JSONEngineBase,
}
/// Represents the default engines record.
#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub(crate) struct JSONDefaultEnginesRecord {
pub global_default: String,
pub global_default_private: Option<String>,
}
/// Represents the engine orders record.
#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub(crate) struct JSONEngineOrdersRecord {
// TODO: Implementation.
}
/// Represents an individual record in the raw search configuration.
#[derive(Debug, Deserialize, Clone)]
#[serde(tag = "recordType", rename_all = "camelCase")]
pub(crate) enum JSONSearchConfigurationRecords {
DefaultEngines(JSONDefaultEnginesRecord),
Engine(Box<JSONEngineRecord>),
EngineOrders(JSONEngineOrdersRecord),
// Include some flexibilty if we choose to add new record types in future.
// Current versions of the application receiving the configuration will
// ignore the new record types.
#[serde(other)]
Unknown,
}
/// Represents the search configuration as received from remote settings.
#[derive(Debug, Deserialize)]
pub(crate) struct JSONSearchConfiguration {
pub data: Vec<JSONSearchConfigurationRecords>,
}

Просмотреть файл

@ -11,8 +11,10 @@ use error_support::{ErrorHandling, GetErrorHandling};
/// application.
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("NotImplemented")]
NotImplemented,
#[error("Search configuration not specified")]
SearchConfigNotSpecified,
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
}
// #[non_exhaustive]

Просмотреть файл

@ -0,0 +1,204 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
use crate::{
error::Error, JSONEngineBase, JSONEngineRecord, JSONEngineUrl, JSONEngineUrls,
JSONSearchConfigurationRecords, RefinedSearchConfig, SearchEngineDefinition, SearchEngineUrl,
SearchEngineUrls, SearchUserEnvironment,
};
impl From<JSONEngineUrl> for SearchEngineUrl {
fn from(url: JSONEngineUrl) -> Self {
Self {
base: url.base,
method: url.method.unwrap_or_default().as_str().to_string(),
params: url.params.unwrap_or_default(),
search_term_param_name: url.search_term_param_name,
}
}
}
impl From<JSONEngineUrls> for SearchEngineUrls {
fn from(urls: JSONEngineUrls) -> Self {
Self {
search: urls.search.into(),
suggestions: None,
trending: None,
}
}
}
impl SearchEngineDefinition {
pub(crate) fn from_configuration_details(
identifier: &str,
base: JSONEngineBase,
) -> SearchEngineDefinition {
SearchEngineDefinition {
aliases: base.aliases.unwrap_or_default(),
charset: base.charset.unwrap_or_else(|| "UTF-8".to_string()),
classification: base.classification,
identifier: identifier.to_string(),
name: base.name,
order_hint: None,
partner_code: base.partner_code.unwrap_or_default(),
telemetry_suffix: None,
urls: base.urls.into(),
}
}
}
pub(crate) fn filter_engine_configuration(
user_environment: SearchUserEnvironment,
configuration: Vec<JSONSearchConfigurationRecords>,
) -> Result<RefinedSearchConfig, Error> {
let mut engines = Vec::new();
let mut default_engine_id: Option<String> = None;
let mut default_private_engine_id: Option<String> = None;
for record in configuration {
match record {
JSONSearchConfigurationRecords::Engine(engine) => {
let result = extract_engine_config(&user_environment, engine);
engines.extend(result);
}
JSONSearchConfigurationRecords::DefaultEngines(default_engines) => {
default_engine_id = Some(default_engines.global_default);
default_private_engine_id.clone_from(&default_engines.global_default_private);
}
JSONSearchConfigurationRecords::EngineOrders(_engine_orders) => {
// TODO: Implementation.
}
JSONSearchConfigurationRecords::Unknown => {
// Prevents panics if a new record type is added in future.
}
}
}
Ok(RefinedSearchConfig {
engines,
app_default_engine_id: default_engine_id.unwrap(),
app_default_private_engine_id: default_private_engine_id,
})
}
fn extract_engine_config(
_user_environment: &SearchUserEnvironment,
record: Box<JSONEngineRecord>,
) -> Option<SearchEngineDefinition> {
// TODO: Variant handling.
Some(SearchEngineDefinition::from_configuration_details(
&record.identifier,
record.base,
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::*;
#[test]
fn test_from_configuration_details_fallsback_to_defaults() {
let result = SearchEngineDefinition::from_configuration_details(
"test",
JSONEngineBase {
aliases: None,
charset: None,
classification: SearchEngineClassification::General,
name: "Test".to_string(),
partner_code: None,
urls: JSONEngineUrls {
search: JSONEngineUrl {
base: "https://example.com".to_string(),
method: None,
params: None,
search_term_param_name: None,
},
suggestions: None,
trending: None,
},
},
);
assert_eq!(
result,
SearchEngineDefinition {
aliases: Vec::new(),
charset: "UTF-8".to_string(),
classification: SearchEngineClassification::General,
identifier: "test".to_string(),
partner_code: String::new(),
name: "Test".to_string(),
order_hint: None,
telemetry_suffix: None,
urls: SearchEngineUrls {
search: SearchEngineUrl {
base: "https://example.com".to_string(),
method: "GET".to_string(),
params: Vec::new(),
search_term_param_name: None,
},
suggestions: None,
trending: None
}
}
)
}
#[test]
fn test_from_configuration_details_uses_values() {
let result = SearchEngineDefinition::from_configuration_details(
"test",
JSONEngineBase {
aliases: Some(vec!["foo".to_string(), "bar".to_string()]),
charset: Some("ISO-8859-15".to_string()),
classification: SearchEngineClassification::Unknown,
name: "Test".to_string(),
partner_code: Some("firefox".to_string()),
urls: JSONEngineUrls {
search: JSONEngineUrl {
base: "https://example.com".to_string(),
method: Some(crate::JSONEngineMethod::Post),
params: Some(vec![SearchUrlParam {
name: "param".to_string(),
value: Some("test param".to_string()),
experiment_config: None,
}]),
search_term_param_name: Some("baz".to_string()),
},
suggestions: None,
trending: None,
},
},
);
assert_eq!(
result,
SearchEngineDefinition {
aliases: vec!["foo".to_string(), "bar".to_string()],
charset: "ISO-8859-15".to_string(),
classification: SearchEngineClassification::Unknown,
identifier: "test".to_string(),
partner_code: "firefox".to_string(),
name: "Test".to_string(),
order_hint: None,
telemetry_suffix: None,
urls: SearchEngineUrls {
search: SearchEngineUrl {
base: "https://example.com".to_string(),
method: "POST".to_string(),
params: vec![SearchUrlParam {
name: "param".to_string(),
value: Some("test param".to_string()),
experiment_config: None,
}],
search_term_param_name: Some("baz".to_string()),
},
suggestions: None,
trending: None
}
}
)
}
}

Просмотреть файл

@ -2,12 +2,15 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
mod configuration_types;
mod error;
mod filter;
pub use error::SearchApiError;
pub mod selector;
pub mod types;
pub(crate) use crate::configuration_types::*;
pub use crate::types::*;
pub use selector::SearchEngineSelector;
pub type SearchApiResult<T> = std::result::Result<T, error::SearchApiError>;

Просмотреть файл

@ -2,20 +2,31 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
use crate::{error::Error, RefinedSearchConfig, SearchApiResult, SearchUserEnvironment};
use crate::filter::filter_engine_configuration;
use crate::{
error::Error, JSONSearchConfiguration, RefinedSearchConfig, SearchApiResult,
SearchUserEnvironment,
};
use error_support::handle_error;
use parking_lot::Mutex;
use std::sync::Arc;
#[derive(Default)]
pub(crate) struct SearchEngineSelectorInner {
configuration: Option<JSONSearchConfiguration>,
}
/// SearchEngineSelector parses the JSON configuration for
/// search engines and returns the applicable engines depending
/// on their region + locale.
#[derive(Default, uniffi::Object)]
pub struct SearchEngineSelector {}
pub struct SearchEngineSelector(Mutex<SearchEngineSelectorInner>);
#[uniffi::export]
impl SearchEngineSelector {
#[uniffi::constructor]
pub fn new() -> Self {
Self::default()
Self(Mutex::default())
}
/// Sets the search configuration from the given string. If the configuration
@ -24,35 +35,158 @@ impl SearchEngineSelector {
/// particularly during test runs where the same configuration may be used
/// repeatedly.
#[handle_error(Error)]
pub fn set_search_config(&self, _configuration: String) -> SearchApiResult<()> {
Err(Error::NotImplemented)
pub fn set_search_config(self: Arc<Self>, configuration: String) -> SearchApiResult<()> {
if configuration.is_empty() {
return Err(Error::SearchConfigNotSpecified);
}
self.0.lock().configuration = serde_json::from_str(&configuration)?;
Ok(())
}
/// Clears the search configuration from memory if it is known that it is
/// not required for a time, e.g. if the configuration will only be re-filtered
/// after an app/environment update.
pub fn clear_search_config(&self) {}
pub fn clear_search_config(self: Arc<Self>) {}
/// Filters the search configuration with the user's given environment,
/// and returns the set of engines and parameters that should be presented
/// to the user.
#[handle_error(Error)]
pub fn filter_engine_configuration(
&self,
_user_environment: SearchUserEnvironment,
self: Arc<Self>,
user_environment: SearchUserEnvironment,
) -> SearchApiResult<RefinedSearchConfig> {
Err(Error::NotImplemented)
let data = match &self.0.lock().configuration {
None => return Err(Error::SearchConfigNotSpecified),
Some(configuration) => configuration.data.clone(),
};
filter_engine_configuration(user_environment, data)
}
}
#[cfg(test)]
mod tests {
use super::{SearchEngineSelector, SearchUserEnvironment};
use super::*;
use crate::types::*;
use serde_json::json;
#[test]
fn test_filter_engine_config_throws() {
let selector = SearchEngineSelector::new();
fn test_set_config_should_allow_basic_config() {
let selector = Arc::new(SearchEngineSelector::new());
let config_result = Arc::clone(&selector).set_search_config(
json!({
"data": [
{
"recordType": "engine",
"identifier": "test",
"base": {
"name": "Test",
"classification": "general",
"urls": {
"search": {
"base": "https://example.com",
"method": "GET"
}
}
}
},
{
"recordType": "defaultEngines",
"globalDefault": "test"
}
]
})
.to_string(),
);
assert!(
config_result.is_ok(),
"Should not have errored: `{config_result:?}`"
);
}
#[test]
fn test_set_config_should_allow_extra_fields() {
let selector = Arc::new(SearchEngineSelector::new());
let config_result = Arc::clone(&selector).set_search_config(
json!({
"data": [
{
"recordType": "engine",
"identifier": "test",
"base": {
"name": "Test",
"classification": "general",
"urls": {
"search": {
"base": "https://example.com",
"method": "GET",
"extraField1": true
}
},
"extraField2": "123"
},
"extraField3": ["foo"]
},
{
"recordType": "defaultEngines",
"globalDefault": "test",
"extraField4": {
"subField1": true
}
}
]
})
.to_string(),
);
assert!(
config_result.is_ok(),
"Should not have errored: `{config_result:?}`"
);
}
#[test]
fn test_set_config_should_ignore_unknown_record_types() {
let selector = Arc::new(SearchEngineSelector::new());
let config_result = Arc::clone(&selector).set_search_config(
json!({
"data": [
{
"recordType": "engine",
"identifier": "test",
"base": {
"name": "Test",
"classification": "general",
"urls": {
"search": {
"base": "https://example.com",
"method": "GET"
}
}
}
},
{
"recordType": "defaultEngines",
"globalDefault": "test"
},
{
"recordType": "unknown"
}
]
})
.to_string(),
);
assert!(
config_result.is_ok(),
"Should not have errored: `{config_result:?}`"
);
}
#[test]
fn test_filter_engine_configuration_throws_without_config() {
let selector = Arc::new(SearchEngineSelector::new());
let result = selector.filter_engine_configuration(SearchUserEnvironment {
locale: "fi".into(),
@ -65,5 +199,122 @@ mod tests {
});
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Search configuration not specified"))
}
#[test]
fn test_filter_engine_configuration_returns_basic_engines() {
let selector = Arc::new(SearchEngineSelector::new());
let config_result = Arc::clone(&selector).set_search_config(
json!({
"data": [
{
"recordType": "engine",
"identifier": "test1",
"base": {
"name": "Test 1",
"classification": "general",
"urls": {
"search": {
"base": "https://example.com/1",
"method": "GET",
"searchTermParamName": "q"
}
}
}
},
{
"recordType": "engine",
"identifier": "test2",
"base": {
"name": "Test 2",
"classification": "general",
"urls": {
"search": {
"base": "https://example.com/2",
"method": "GET",
"searchTermParamName": "search"
}
}
}
},
{
"recordType": "defaultEngines",
"globalDefault": "test1",
"globalDefaultPrivate": "test2"
}
]
})
.to_string(),
);
assert!(
config_result.is_ok(),
"Should not have errored: `{config_result:?}`"
);
let result = selector.filter_engine_configuration(SearchUserEnvironment {
locale: "fi".into(),
region: "FR".into(),
update_channel: SearchUpdateChannel::Default,
distribution_id: String::new(),
experiment: String::new(),
app_name: SearchApplicationName::Firefox,
version: String::new(),
});
assert!(result.is_ok(), "Should not have errored: `{result:?}`");
assert_eq!(
result.unwrap(),
RefinedSearchConfig {
engines: vec!(
SearchEngineDefinition {
aliases: Vec::new(),
charset: "UTF-8".to_string(),
classification: SearchEngineClassification::General,
identifier: "test1".to_string(),
name: "Test 1".to_string(),
order_hint: None,
partner_code: String::new(),
telemetry_suffix: None,
urls: SearchEngineUrls {
search: SearchEngineUrl {
base: "https://example.com/1".to_string(),
method: "GET".to_string(),
params: Vec::new(),
search_term_param_name: Some("q".to_string())
},
suggestions: None,
trending: None
}
},
SearchEngineDefinition {
aliases: Vec::new(),
charset: "UTF-8".to_string(),
classification: SearchEngineClassification::General,
identifier: "test2".to_string(),
name: "Test 2".to_string(),
order_hint: None,
partner_code: String::new(),
telemetry_suffix: None,
urls: SearchEngineUrls {
search: SearchEngineUrl {
base: "https://example.com/2".to_string(),
method: "GET".to_string(),
params: Vec::new(),
search_term_param_name: Some("search".to_string())
},
suggestions: None,
trending: None
}
}
),
app_default_engine_id: "test1".to_string(),
app_default_private_engine_id: Some("test2".to_string())
}
)
}
}

Просмотреть файл

@ -2,6 +2,10 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
//! This module defines the types that we export across the UNIFFI interface.
use serde::Deserialize;
/// The list of possible application names that are currently supported.
#[derive(Debug, uniffi::Enum)]
pub enum SearchApplicationName {
@ -79,7 +83,7 @@ pub struct SearchUserEnvironment {
/// Parameter definitions for search engine URLs. The name property is always
/// specified, along with one of value, experiment_config or search_access_point.
#[derive(Debug, uniffi::Record)]
#[derive(Debug, uniffi::Record, PartialEq, Deserialize, Clone)]
pub struct SearchUrlParam {
/// The name of the parameter in the url.
pub name: String,
@ -94,7 +98,7 @@ pub struct SearchUrlParam {
}
/// Defines an individual search engine URL.
#[derive(Debug, uniffi::Record)]
#[derive(Debug, uniffi::Record, PartialEq, Deserialize, Clone)]
pub struct SearchEngineUrl {
/// The PrePath and FilePath of the URL. May include variables for engines
/// which have a variable FilePath, e.g. `{searchTerm}` for when a search
@ -115,7 +119,7 @@ pub struct SearchEngineUrl {
}
/// The URLs associated with the search engine.
#[derive(Debug, uniffi::Record)]
#[derive(Debug, uniffi::Record, PartialEq, Deserialize, Clone)]
pub struct SearchEngineUrls {
/// The URL to use for searches.
pub search: SearchEngineUrl,
@ -128,10 +132,12 @@ pub struct SearchEngineUrls {
}
/// The list of acceptable classifications for a search engine.
#[derive(Debug, uniffi::Enum)]
#[derive(Debug, uniffi::Enum, PartialEq, Deserialize, Clone)]
#[serde(rename_all = "lowercase")]
pub enum SearchEngineClassification {
Unknown = 1,
General = 2,
#[serde(other)]
Unknown = 1,
}
impl SearchEngineClassification {
@ -144,11 +150,14 @@ impl SearchEngineClassification {
}
/// A definition for an individual search engine to be presented to the user.
#[derive(Debug, uniffi::Record)]
#[derive(Debug, uniffi::Record, PartialEq)]
pub struct SearchEngineDefinition {
/// A list of aliases for this engine.
pub aliases: Vec<String>,
/// The character set this engine uses for queries.
pub charset: String,
/// The classification of search engine according to the main search types
/// (e.g. general, shopping, travel, dictionary). Currently, only marking as
/// a general search engine is supported.
@ -165,8 +174,8 @@ pub struct SearchEngineDefinition {
pub name: String,
/// The partner code for the engine. This will be inserted into parameters
/// which include `{partnerCode}`.
pub partner_code: Option<String>,
/// which include `{partnerCode}`. May be the empty string.
pub partner_code: String,
/// Optional suffix that is appended to the search engine identifier
/// following a dash, i.e. `<identifier>-<suffix>`
@ -185,7 +194,7 @@ pub struct SearchEngineDefinition {
/// Details of the search engines to display to the user, generated as a result
/// of processing the search configuration.
#[derive(Debug, uniffi::Record)]
#[derive(Debug, uniffi::Record, PartialEq)]
pub struct RefinedSearchConfig {
/// A sorted list of engines. Clients may use the engine in the order that
/// this list is specified, or they may implement their own order if they