diff --git a/rust/agama-cli/src/main.rs b/rust/agama-cli/src/main.rs index 1c0642eddc..01afb47235 100644 --- a/rust/agama-cli/src/main.rs +++ b/rust/agama-cli/src/main.rs @@ -118,28 +118,30 @@ async fn build_manager<'a>() -> anyhow::Result> { Ok(ManagerClient::new(conn).await?) } -async fn run_command(cli: Cli) -> anyhow::Result<()> { +async fn run_command(cli: Cli) -> Result<(), ServiceError> { match cli.command { Commands::Config(subcommand) => { let manager = build_manager().await?; wait_for_services(&manager).await?; - run_config_cmd(subcommand).await + run_config_cmd(subcommand).await? } Commands::Probe => { let manager = build_manager().await?; wait_for_services(&manager).await?; - probe().await + probe().await? } - Commands::Profile(subcommand) => Ok(run_profile_cmd(subcommand).await?), + Commands::Profile(subcommand) => run_profile_cmd(subcommand).await?, Commands::Install => { let manager = build_manager().await?; - install(&manager, 3).await + install(&manager, 3).await? } - Commands::Questions(subcommand) => run_questions_cmd(subcommand).await, - Commands::Logs(subcommand) => run_logs_cmd(subcommand).await, - Commands::Auth(subcommand) => run_auth_cmd(subcommand).await, - Commands::Download { url } => crate::profile::download(&url, std::io::stdout()), - } + Commands::Questions(subcommand) => run_questions_cmd(subcommand).await?, + Commands::Logs(subcommand) => run_logs_cmd(subcommand).await?, + Commands::Auth(subcommand) => run_auth_cmd(subcommand).await?, + Commands::Download { url } => crate::profile::download(&url, std::io::stdout())?, + }; + + Ok(()) } /// Represents the result of execution. diff --git a/rust/agama-cli/src/questions.rs b/rust/agama-cli/src/questions.rs index f066452e22..51513b2a76 100644 --- a/rust/agama-cli/src/questions.rs +++ b/rust/agama-cli/src/questions.rs @@ -1,6 +1,6 @@ -use agama_lib::connection; use agama_lib::proxies::Questions1Proxy; -use anyhow::Context; +use agama_lib::questions::http_client::HTTPClient; +use agama_lib::{connection, error::ServiceError}; use clap::{Args, Subcommand, ValueEnum}; #[derive(Subcommand, Debug)] @@ -19,6 +19,10 @@ pub enum QuestionsCommands { /// Path to a file containing the answers in YAML format. path: String, }, + /// prints list of questions that is waiting for answer in YAML format + List, + /// Ask question from stdin in YAML format and print answer when it is answered. + Ask, } #[derive(Args, Debug)] @@ -35,30 +39,54 @@ pub enum Modes { NonInteractive, } -async fn set_mode(proxy: Questions1Proxy<'_>, value: Modes) -> anyhow::Result<()> { - // TODO: how to print dbus error in that anyhow? +async fn set_mode(proxy: Questions1Proxy<'_>, value: Modes) -> Result<(), ServiceError> { proxy .set_interactive(value == Modes::Interactive) .await - .context("Failed to set mode for answering questions.") + .map_err(|e| e.into()) } -async fn set_answers(proxy: Questions1Proxy<'_>, path: String) -> anyhow::Result<()> { - // TODO: how to print dbus error in that anyhow? +async fn set_answers(proxy: Questions1Proxy<'_>, path: String) -> Result<(), ServiceError> { proxy .add_answer_file(path.as_str()) .await - .context("Failed to set answers from answers file") + .map_err(|e| e.into()) } -pub async fn run(subcommand: QuestionsCommands) -> anyhow::Result<()> { +async fn list_questions() -> Result<(), ServiceError> { + let client = HTTPClient::new().await?; + let questions = client.list_questions().await?; + // FIXME: that conversion to anyhow error is nasty, but we do not expect issue + // when questions are already read from json + // FIXME: if performance is bad, we can skip converting json from http to struct and then + // serialize it, but it won't be pretty string + let questions_json = + serde_json::to_string_pretty(&questions).map_err(Into::::into)?; + println!("{}", questions_json); + Ok(()) +} + +async fn ask_question() -> Result<(), ServiceError> { + let client = HTTPClient::new().await?; + let question = serde_json::from_reader(std::io::stdin())?; + + let created_question = client.create_question(&question).await?; + let answer = client.get_answer(created_question.generic.id).await?; + let answer_json = serde_json::to_string_pretty(&answer).map_err(Into::::into)?; + println!("{}", answer_json); + + client.delete_question(created_question.generic.id).await?; + Ok(()) +} + +pub async fn run(subcommand: QuestionsCommands) -> Result<(), ServiceError> { let connection = connection().await?; - let proxy = Questions1Proxy::new(&connection) - .await - .context("Failed to connect to Questions service")?; + let proxy = Questions1Proxy::new(&connection).await?; match subcommand { QuestionsCommands::Mode(value) => set_mode(proxy, value.value).await, QuestionsCommands::Answers { path } => set_answers(proxy, path).await, + QuestionsCommands::List => list_questions().await, + QuestionsCommands::Ask => ask_question().await, } } diff --git a/rust/agama-lib/src/base_http_client.rs b/rust/agama-lib/src/base_http_client.rs new file mode 100644 index 0000000000..655a1ce5d7 --- /dev/null +++ b/rust/agama-lib/src/base_http_client.rs @@ -0,0 +1,170 @@ +use reqwest::{header, Client, Response}; +use serde::{de::DeserializeOwned, Serialize}; + +use crate::{auth::AuthToken, error::ServiceError}; + +/// Base that all HTTP clients should use. +/// +/// It provides several features including automatic base URL switching, +/// websocket events listening or object constructions. +/// +/// Usage should be just thin layer in domain specific client. +/// +/// ```no_run +/// use agama_lib::questions::model::Question; +/// use agama_lib::base_http_client::BaseHTTPClient; +/// use agama_lib::error::ServiceError; +/// +/// async fn get_questions() -> Result, ServiceError> { +/// let client = BaseHTTPClient::new()?; +/// client.get("/questions").await +/// } +/// ``` +pub struct BaseHTTPClient { + client: Client, + pub base_url: String, +} + +const API_URL: &str = "http://localhost/api"; + +impl BaseHTTPClient { + // if there is need for client without authorization, create new constructor for it + pub fn new() -> Result { + let token = AuthToken::find().ok_or(ServiceError::NotAuthenticated)?; + + let mut headers = header::HeaderMap::new(); + // just use generic anyhow error here as Bearer format is constructed by us, so failures can come only from token + let value = header::HeaderValue::from_str(format!("Bearer {}", token).as_str()) + .map_err(|e| anyhow::Error::new(e))?; + + headers.insert(header::AUTHORIZATION, value); + + let client = Client::builder().default_headers(headers).build()?; + + Ok(Self { + client, + base_url: API_URL.to_string(), // TODO: add support for remote server + }) + } + + /// Simple wrapper around [`Response`] to get object from response. + /// + /// If a complete [`Response`] is needed, use the [`Self::get_response`] method. + /// + /// Arguments: + /// + /// * `path`: path relative to HTTP API like `/questions` + pub async fn get(&self, path: &str) -> Result { + let response = self.get_response(path).await?; + if response.status().is_success() { + response.json::().await.map_err(|e| e.into()) + } else { + Err(self.build_backend_error(response).await) + } + } + + /// Calls GET method on the given path and returns [`Response`] that can be further + /// processed. + /// + /// If only simple object from JSON is required, use method get. + /// + /// Arguments: + /// + /// * `path`: path relative to HTTP API like `/questions` + pub async fn get_response(&self, path: &str) -> Result { + self.client + .get(self.url(path)) + .send() + .await + .map_err(|e| e.into()) + } + + fn url(&self, path: &str) -> String { + self.base_url.clone() + path + } + + /// post object to given path and report error if response is not success + /// + /// Arguments: + /// + /// * `path`: path relative to HTTP API like `/questions` + /// * `object`: Object that can be serialiazed to JSON as body of request. + pub async fn post(&self, path: &str, object: &impl Serialize) -> Result<(), ServiceError> { + let response = self.post_response(path, object).await?; + if response.status().is_success() { + Ok(()) + } else { + Err(self.build_backend_error(response).await) + } + } + + /// post object to given path and returns server response. Reports error only if failed to send + /// request, but if server returns e.g. 500, it will be in Ok result. + /// + /// In general unless specific response handling is needed, simple post should be used. + /// + /// Arguments: + /// + /// * `path`: path relative to HTTP API like `/questions` + /// * `object`: Object that can be serialiazed to JSON as body of request. + pub async fn post_response( + &self, + path: &str, + object: &impl Serialize, + ) -> Result { + self.client + .post(self.url(path)) + .json(object) + .send() + .await + .map_err(|e| e.into()) + } + + /// delete call on given path and report error if failed + /// + /// Arguments: + /// + /// * `path`: path relative to HTTP API like `/questions/1` + pub async fn delete(&self, path: &str) -> Result<(), ServiceError> { + let response = self.delete_response(path).await?; + if response.status().is_success() { + Ok(()) + } else { + Err(self.build_backend_error(response).await) + } + } + + /// delete call on given path and returns server response. Reports error only if failed to send + /// request, but if server returns e.g. 500, it will be in Ok result. + /// + /// In general unless specific response handling is needed, simple delete should be used. + /// TODO: do not need variant with request body? if so, then create additional method. + /// + /// Arguments: + /// + /// * `path`: path relative to HTTP API like `/questions/1` + pub async fn delete_response(&self, path: &str) -> Result { + self.client + .delete(self.url(path)) + .send() + .await + .map_err(|e| e.into()) + } + + const NO_TEXT: &'static str = "(Failed to extract error text from HTTP response)"; + /// Builds [`BackendError`] from response. + /// + /// It contains also processing of response body, that is why it has to be async. + /// + /// Arguments: + /// + /// * `response`: response from which generate error + pub async fn build_backend_error(&self, response: Response) -> ServiceError { + let code = response.status().as_u16(); + let text = response + .text() + .await + .unwrap_or_else(|_| Self::NO_TEXT.to_string()); + ServiceError::BackendError(code, text) + } +} diff --git a/rust/agama-lib/src/error.rs b/rust/agama-lib/src/error.rs index 513381803a..f40278ed7e 100644 --- a/rust/agama-lib/src/error.rs +++ b/rust/agama-lib/src/error.rs @@ -16,6 +16,8 @@ pub enum ServiceError { DBusProtocol(#[from] zbus::fdo::Error), #[error("Unexpected type on D-Bus '{0}'")] ZVariant(#[from] zvariant::Error), + #[error("Failed to communicate with the HTTP backend '{0}'")] + HTTPError(#[from] reqwest::Error), // it's fine to say only "Error" because the original // specific error will be printed too #[error("Error: {0}")] @@ -29,10 +31,16 @@ pub enum ServiceError { FailedRegistration(String), #[error("Failed to find these patterns: {0:?}")] UnknownPatterns(Vec), + #[error("Passed json data is not correct: {0}")] + InvalidJson(#[from] serde_json::Error), #[error("Could not perform action '{0}'")] UnsuccessfulAction(String), - #[error("Unknown installation phase: '{0}")] + #[error("Unknown installation phase: {0}")] UnknownInstallationPhase(u32), + #[error("Backend call failed with status {0} and text '{1}'")] + BackendError(u16, String), + #[error("You are not logged in. Please use: agama auth login")] + NotAuthenticated, } #[derive(Error, Debug)] diff --git a/rust/agama-lib/src/lib.rs b/rust/agama-lib/src/lib.rs index 6435517803..3dfffd3e07 100644 --- a/rust/agama-lib/src/lib.rs +++ b/rust/agama-lib/src/lib.rs @@ -24,6 +24,7 @@ //! As said, those modules might implement additional stuff, like specific types, clients, etc. pub mod auth; +pub mod base_http_client; pub mod error; pub mod install_settings; pub mod localization; diff --git a/rust/agama-lib/src/questions.rs b/rust/agama-lib/src/questions.rs index b0b52d84de..bb71601e70 100644 --- a/rust/agama-lib/src/questions.rs +++ b/rust/agama-lib/src/questions.rs @@ -1,8 +1,13 @@ //! Data model for Agama questions use std::collections::HashMap; +pub mod http_client; +pub mod model; /// Basic generic question that fits question without special needs +/// +/// structs living directly under questions namespace is for D-Bus usage and holds complete questions data +/// for user side data model see questions::model #[derive(Clone, Debug)] pub struct GenericQuestion { /// numeric id used to identify question on D-Bus diff --git a/rust/agama-lib/src/questions/http_client.rs b/rust/agama-lib/src/questions/http_client.rs new file mode 100644 index 0000000000..e498fce21e --- /dev/null +++ b/rust/agama-lib/src/questions/http_client.rs @@ -0,0 +1,69 @@ +use std::time::Duration; + +use reqwest::StatusCode; +use tokio::time::sleep; + +use crate::{base_http_client::BaseHTTPClient, error::ServiceError}; + +use super::model::{self, Answer, Question}; + +pub struct HTTPClient { + client: BaseHTTPClient, +} + +impl HTTPClient { + pub async fn new() -> Result { + Ok(Self { + client: BaseHTTPClient::new()?, + }) + } + + pub async fn list_questions(&self) -> Result, ServiceError> { + self.client.get("/questions").await + } + + /// Creates question and return newly created question including id + pub async fn create_question(&self, question: &Question) -> Result { + let response = self.client.post_response("/questions", question).await?; + if response.status().is_success() { + let question = response.json().await?; + Ok(question) + } else { + Err(self.client.build_backend_error(response).await) + } + } + + /// non blocking varient of checking if question has already answer + pub async fn try_answer(&self, question_id: u32) -> Result, ServiceError> { + let path = format!("/questions/{}/answer", question_id); + let response = self.client.get_response(path.as_str()).await?; + if response.status() == StatusCode::NOT_FOUND { + Ok(None) + } else if response.status().is_success() { + let answer = response.json().await?; + Ok(answer) + } else { + Err(self.client.build_backend_error(response).await) + } + } + + /// Blocking variant of getting answer for given question. + pub async fn get_answer(&self, question_id: u32) -> Result { + loop { + let answer = self.try_answer(question_id).await?; + if let Some(result) = answer { + return Ok(result); + } + let duration = Duration::from_secs(1); + sleep(duration).await; + // TODO: use websocket to get events instead of polling, but be aware of race condition that + // auto answer can answer faster before we connect to socket. So ask for answer + // and meanwhile start checking for events + } + } + + pub async fn delete_question(&self, question_id: u32) -> Result<(), ServiceError> { + let path = format!("/questions/{}/answer", question_id); + self.client.delete(path.as_str()).await + } +} diff --git a/rust/agama-lib/src/questions/model.rs b/rust/agama-lib/src/questions/model.rs new file mode 100644 index 0000000000..6d1a87d72f --- /dev/null +++ b/rust/agama-lib/src/questions/model.rs @@ -0,0 +1,63 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct Question { + pub generic: GenericQuestion, + pub with_password: Option, +} + +/// Facade of agama_lib::questions::GenericQuestion +/// For fields details see it. +/// Reason why it does not use directly GenericQuestion from lib +/// is that it contain both question and answer. It works for dbus +/// API which has both as attributes, but web API separate +/// question and its answer. So here it is split into GenericQuestion +/// and GenericAnswer +#[derive(Clone, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct GenericQuestion { + pub id: u32, + pub class: String, + pub text: String, + pub options: Vec, + pub default_option: String, + pub data: HashMap, +} + +/// Facade of agama_lib::questions::WithPassword +/// For fields details see it. +/// Reason why it does not use directly WithPassword from lib +/// is that it is not composition as used here, but more like +/// child of generic question and contain reference to Base. +/// Here for web API we want to have in json that separation that would +/// allow to compose any possible future specialization of question. +/// Also note that question is empty as QuestionWithPassword does not +/// provide more details for question, but require additional answer. +/// Can be potentionally extended in future e.g. with list of allowed characters? +#[derive(Clone, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct QuestionWithPassword {} + +#[derive(Default, Clone, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct Answer { + pub generic: GenericAnswer, + pub with_password: Option, +} + +/// Answer needed for GenericQuestion +#[derive(Default, Clone, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct GenericAnswer { + pub answer: String, +} + +/// Answer needed for Password specific questions. +#[derive(Clone, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct PasswordAnswer { + pub password: String, +} diff --git a/rust/agama-server/src/questions/web.rs b/rust/agama-server/src/questions/web.rs index 19b4463091..3bae35d0e6 100644 --- a/rust/agama-server/src/questions/web.rs +++ b/rust/agama-server/src/questions/web.rs @@ -9,6 +9,7 @@ use crate::{error::Error, web::Event}; use agama_lib::{ error::ServiceError, proxies::{GenericQuestionProxy, QuestionWithPasswordProxy, Questions1Proxy}, + questions::model::{Answer, GenericQuestion, PasswordAnswer, Question, QuestionWithPassword}, }; use anyhow::Context; use axum::{ @@ -19,7 +20,6 @@ use axum::{ Json, Router, }; use regex::Regex; -use serde::{Deserialize, Serialize}; use std::{collections::HashMap, pin::Pin}; use tokio_stream::{Stream, StreamExt}; use zbus::{ @@ -222,66 +222,6 @@ struct QuestionsState<'a> { questions: QuestionsClient<'a>, } -#[derive(Clone, Serialize, Deserialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct Question { - generic: GenericQuestion, - with_password: Option, -} - -/// Facade of agama_lib::questions::GenericQuestion -/// For fields details see it. -/// Reason why it does not use directly GenericQuestion from lib -/// is that it contain both question and answer. It works for dbus -/// API which has both as attributes, but web API separate -/// question and its answer. So here it is split into GenericQuestion -/// and GenericAnswer -#[derive(Clone, Serialize, Deserialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct GenericQuestion { - id: u32, - class: String, - text: String, - options: Vec, - default_option: String, - data: HashMap, -} - -/// Facade of agama_lib::questions::WithPassword -/// For fields details see it. -/// Reason why it does not use directly WithPassword from lib -/// is that it is not composition as used here, but more like -/// child of generic question and contain reference to Base. -/// Here for web API we want to have in json that separation that would -/// allow to compose any possible future specialization of question. -/// Also note that question is empty as QuestionWithPassword does not -/// provide more details for question, but require additional answer. -/// Can be potentionally extended in future e.g. with list of allowed characters? -#[derive(Clone, Serialize, Deserialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct QuestionWithPassword {} - -#[derive(Default, Clone, Serialize, Deserialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct Answer { - generic: GenericAnswer, - with_password: Option, -} - -/// Answer needed for GenericQuestion -#[derive(Default, Clone, Serialize, Deserialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct GenericAnswer { - answer: String, -} - -/// Answer needed for Password specific questions. -#[derive(Clone, Serialize, Deserialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct PasswordAnswer { - password: String, -} - /// Sets up and returns the axum service for the questions module. pub async fn questions_service(dbus: zbus::Connection) -> Result { let questions = QuestionsClient::new(dbus.clone()).await?; diff --git a/rust/agama-server/src/web/docs.rs b/rust/agama-server/src/web/docs.rs index dcaa1b0680..af9d28eacd 100644 --- a/rust/agama-server/src/web/docs.rs +++ b/rust/agama-server/src/web/docs.rs @@ -100,12 +100,12 @@ use utoipa::OpenApi; schemas(crate::manager::web::InstallerStatus), schemas(crate::network::model::Connection), schemas(crate::network::model::Device), - schemas(crate::questions::web::Answer), - schemas(crate::questions::web::GenericAnswer), - schemas(crate::questions::web::GenericQuestion), - schemas(crate::questions::web::PasswordAnswer), - schemas(crate::questions::web::Question), - schemas(crate::questions::web::QuestionWithPassword), + schemas(agama_lib::questions::model::Answer), + schemas(agama_lib::questions::model::GenericAnswer), + schemas(agama_lib::questions::model::GenericQuestion), + schemas(agama_lib::questions::model::PasswordAnswer), + schemas(agama_lib::questions::model::Question), + schemas(agama_lib::questions::model::QuestionWithPassword), schemas(crate::software::web::SoftwareConfig), schemas(crate::software::web::SoftwareProposal), schemas(crate::storage::web::ProductParams), diff --git a/rust/package/agama.changes b/rust/package/agama.changes index 7b185bc5e5..0df4850cb2 100644 --- a/rust/package/agama.changes +++ b/rust/package/agama.changes @@ -1,3 +1,15 @@ +------------------------------------------------------------------- +Tue Jul 16 11:56:29 UTC 2024 - Josef Reidinger + +- CLI: +-- Add `agama questions list` to get list of unanswered questions +-- Add `agama questions ask` to ask for question and wait for + answer +- agama-lib: +-- Add BaseHTTPClient that is base for clients that communicate + with agama-web-server + (gh#openSUSE/agama#1457) + ------------------------------------------------------------------- Wed Jul 10 20:11:39 UTC 2024 - Josef Reidinger