Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

agama config load/store for "product" uses the HTTP API #1563

Merged
merged 14 commits into from
Sep 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions rust/agama-lib/src/base_http_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ use crate::{auth::AuthToken, error::ServiceError};
/// client.get("/questions").await
/// }
/// ```
#[derive(Clone)]
pub struct BaseHTTPClient {
client: reqwest::Client,
pub base_url: String,
Expand Down
3 changes: 3 additions & 0 deletions rust/agama-lib/src/manager.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
//! This module implements the web API for the manager module.

pub mod http_client;
pub use http_client::ManagerHTTPClient;

use crate::error::ServiceError;
use crate::proxies::ServiceStatusProxy;
use crate::{
Expand Down
23 changes: 23 additions & 0 deletions rust/agama-lib/src/manager/http_client.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
use crate::{base_http_client::BaseHTTPClient, error::ServiceError};

pub struct ManagerHTTPClient {
client: BaseHTTPClient,
}

impl ManagerHTTPClient {
pub fn new() -> Result<Self, ServiceError> {
Ok(Self {
client: BaseHTTPClient::new()?,
})
}

pub fn new_with_base(base: BaseHTTPClient) -> Self {
Self { client: base }
}

pub async fn probe(&self) -> Result<(), ServiceError> {
// BaseHTTPClient did not anticipate POST without request body
// so we pass () which is rendered as `null`
self.client.post_void("/manager/probe_sync", &()).await
}
}
5 changes: 4 additions & 1 deletion rust/agama-lib/src/product.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
//! Implements support for handling the product settings

mod client;
mod http_client;
pub mod proxies;
mod settings;
mod store;

pub use client::{Product, ProductClient, RegistrationRequirement};
pub use crate::software::model::RegistrationRequirement;
pub use client::{Product, ProductClient};
pub use http_client::ProductHTTPClient;
pub use settings::ProductSettings;
pub use store::ProductStore;
32 changes: 2 additions & 30 deletions rust/agama-lib/src/product/client.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
use std::collections::HashMap;

use crate::error::ServiceError;
use crate::software::model::RegistrationRequirement;
use crate::software::proxies::SoftwareProductProxy;
use serde::{Deserialize, Serialize};
use serde::Serialize;
use zbus::Connection;

use super::proxies::RegistrationProxy;
Expand All @@ -20,35 +21,6 @@ pub struct Product {
pub icon: String,
}

#[derive(Clone, Debug, Serialize, Deserialize, utoipa::ToSchema)]
pub enum RegistrationRequirement {
/// Product does not require registration
NotRequired = 0,
/// Product has optional registration
Optional = 1,
/// It is mandatory to register the product
Mandatory = 2,
}

impl TryFrom<u32> for RegistrationRequirement {
type Error = ();

fn try_from(v: u32) -> Result<Self, Self::Error> {
match v {
x if x == RegistrationRequirement::NotRequired as u32 => {
Ok(RegistrationRequirement::NotRequired)
}
x if x == RegistrationRequirement::Optional as u32 => {
Ok(RegistrationRequirement::Optional)
}
x if x == RegistrationRequirement::Mandatory as u32 => {
Ok(RegistrationRequirement::Mandatory)
}
_ => Err(()),
}
}
}

/// D-Bus client for the software service
#[derive(Clone)]
pub struct ProductClient<'a> {
Expand Down
62 changes: 62 additions & 0 deletions rust/agama-lib/src/product/http_client.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
use crate::software::model::RegistrationInfo;
use crate::software::model::RegistrationParams;
use crate::software::model::SoftwareConfig;
use crate::{base_http_client::BaseHTTPClient, error::ServiceError};

pub struct ProductHTTPClient {
client: BaseHTTPClient,
}

impl ProductHTTPClient {
pub fn new() -> Result<Self, ServiceError> {
Ok(Self {
client: BaseHTTPClient::new()?,
})
}

pub fn new_with_base(base: BaseHTTPClient) -> Self {
Self { client: base }
}

pub async fn get_software(&self) -> Result<SoftwareConfig, ServiceError> {
self.client.get("/software/config").await
}

pub async fn set_software(&self, config: &SoftwareConfig) -> Result<(), ServiceError> {
self.client.put_void("/software/config", config).await
}

/// Returns the id of the selected product to install
pub async fn product(&self) -> Result<String, ServiceError> {
let config = self.get_software().await?;
if let Some(product) = config.product {
Ok(product)
} else {
Ok("".to_owned())
}
}

/// Selects the product to install
pub async fn select_product(&self, product_id: &str) -> Result<(), ServiceError> {
let config = SoftwareConfig {
product: Some(product_id.to_owned()),
patterns: None,
};
self.set_software(&config).await
}

pub async fn get_registration(&self) -> Result<RegistrationInfo, ServiceError> {
self.client.get("/software/registration").await
}

/// register product
pub async fn register(&self, key: &str, email: &str) -> Result<(u32, String), ServiceError> {
// note RegistrationParams != RegistrationInfo, fun!
let params = RegistrationParams {
key: key.to_owned(),
email: email.to_owned(),
};

self.client.post("/software/registration", &params).await
}
}
2 changes: 1 addition & 1 deletion rust/agama-lib/src/product/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
use serde::{Deserialize, Serialize};

/// Software settings for installation
#[derive(Debug, Default, Serialize, Deserialize)]
#[derive(Debug, Default, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ProductSettings {
/// ID of the product to install (e.g., "ALP", "Tumbleweed", etc.)
Expand Down
150 changes: 135 additions & 15 deletions rust/agama-lib/src/product/store.rs
Original file line number Diff line number Diff line change
@@ -1,33 +1,38 @@
//! Implements the store for the product settings.

use super::{ProductClient, ProductSettings};
use super::{ProductHTTPClient, ProductSettings};
use crate::error::ServiceError;
use crate::manager::ManagerClient;
use zbus::Connection;
use crate::manager::http_client::ManagerHTTPClient;

/// Loads and stores the product settings from/to the D-Bus service.
pub struct ProductStore<'a> {
product_client: ProductClient<'a>,
manager_client: ManagerClient<'a>,
pub struct ProductStore {
product_client: ProductHTTPClient,
manager_client: ManagerHTTPClient,
}

impl<'a> ProductStore<'a> {
pub async fn new(connection: Connection) -> Result<ProductStore<'a>, ServiceError> {
impl ProductStore {
pub fn new() -> Result<ProductStore, ServiceError> {
Ok(Self {
product_client: ProductClient::new(connection.clone()).await?,
manager_client: ManagerClient::new(connection).await?,
product_client: ProductHTTPClient::new()?,
manager_client: ManagerHTTPClient::new()?,
})
}

fn non_empty_string(s: String) -> Option<String> {
if s.is_empty() {
None
} else {
Some(s)
}
}

pub async fn load(&self) -> Result<ProductSettings, ServiceError> {
let product = self.product_client.product().await?;
let registration_code = self.product_client.registration_code().await?;
let email = self.product_client.email().await?;
let registration_info = self.product_client.get_registration().await?;

Ok(ProductSettings {
id: Some(product),
registration_code: Some(registration_code),
registration_email: Some(email),
registration_code: Self::non_empty_string(registration_info.key),
registration_email: Self::non_empty_string(registration_info.email),
})
}

Expand Down Expand Up @@ -63,3 +68,118 @@ impl<'a> ProductStore<'a> {
Ok(())
}
}

#[cfg(test)]
mod test {
use super::*;
use crate::base_http_client::BaseHTTPClient;
use httpmock::prelude::*;
use std::error::Error;
use tokio::test; // without this, "error: async functions cannot be used for tests"

fn product_store(mock_server_url: String) -> ProductStore {
let mut bhc = BaseHTTPClient::default();
bhc.base_url = mock_server_url;
let p_client = ProductHTTPClient::new_with_base(bhc.clone());
let m_client = ManagerHTTPClient::new_with_base(bhc);
ProductStore {
product_client: p_client,
manager_client: m_client,
}
}

#[test]
async fn test_getting_product() -> Result<(), Box<dyn Error>> {
let server = MockServer::start();
let software_mock = server.mock(|when, then| {
when.method(GET).path("/api/software/config");
then.status(200)
.header("content-type", "application/json")
.body(
r#"{
"patterns": {"xfce":true},
"product": "Tumbleweed"
}"#,
);
});
let registration_mock = server.mock(|when, then| {
when.method(GET).path("/api/software/registration");
then.status(200)
.header("content-type", "application/json")
.body(
r#"{
"key": "",
"email": "",
"requirement": "NotRequired"
}"#,
);
});
let url = server.url("/api");

let store = product_store(url);
let settings = store.load().await?;

let expected = ProductSettings {
id: Some("Tumbleweed".to_owned()),
registration_code: None,
registration_email: None,
};
// main assertion
assert_eq!(settings, expected);

// Ensure the specified mock was called exactly one time (or fail with a detailed error description).
software_mock.assert();
registration_mock.assert();
Ok(())
}

#[test]
async fn test_setting_product_ok() -> Result<(), Box<dyn Error>> {
let server = MockServer::start();
// no product selected at first
let get_software_mock = server.mock(|when, then| {
when.method(GET).path("/api/software/config");
then.status(200)
.header("content-type", "application/json")
.body(
r#"{
"patterns": {},
"product": ""
}"#,
);
});
let software_mock = server.mock(|when, then| {
when.method(PUT)
.path("/api/software/config")
.header("content-type", "application/json")
.body(r#"{"patterns":null,"product":"Tumbleweed"}"#);
then.status(200);
});
let manager_mock = server.mock(|when, then| {
when.method(POST)
.path("/api/manager/probe_sync")
.header("content-type", "application/json")
.body("null");
then.status(200);
});
let url = server.url("/api");

let store = product_store(url);
let settings = ProductSettings {
id: Some("Tumbleweed".to_owned()),
registration_code: None,
registration_email: None,
};

let result = store.store(&settings).await;

// main assertion
result?;

// Ensure the specified mock was called exactly one time (or fail with a detailed error description).
get_software_mock.assert();
software_mock.assert();
manager_mock.assert();
Ok(())
}
}
Loading