Skip to content

Commit

Permalink
agama config load/store for "product" uses the HTTP API (#1563)
Browse files Browse the repository at this point in the history
## Problem

**Product** and **registration** part of the migration of the CLI from
D-Bus API to HTTP API:

-
https://trello.com/c/hvPtBtMD/3719-5-replace-d-bus-with-http-based-clients


## Solution

- Added `ProductHTTPClient`
- Kept (D-Bus) `ProductClient` because it serves as the backend for the
above
- Added `ManagerHTTPClient`, `/api/manager/probe_sync` is called after
the product changes


## Testing

- Added a new unit test
- Tested manually

## Screenshots

No
  • Loading branch information
mvidner authored Sep 10, 2024
2 parents 9d30030 + 11c4c5e commit ff3cf07
Show file tree
Hide file tree
Showing 15 changed files with 322 additions and 79 deletions.
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

0 comments on commit ff3cf07

Please sign in to comment.