Skip to content

Commit

Permalink
Ask cli (#1457)
Browse files Browse the repository at this point in the history
## Problem

There is no CLI command to ask new question. It is needed to ask
questions from autoyast convert script.
Also there is a need to share functionality between various CLI calls
that will use HTTP API as current one in network has limitation and is
not designed as generic http agent that fits agama.


## Solution

Implement needed functionality in questions http client.
Implement BaseHttpClient that shares code for doing http requests on
agama http server.
Implement ask and list subcommand for questions to allow asking
questions and also listing currently unanswered ones.
  • Loading branch information
jreidinger authored Jul 16, 2024
2 parents cfe5a42 + 7eabf7e commit fce3d4c
Show file tree
Hide file tree
Showing 11 changed files with 388 additions and 90 deletions.
22 changes: 12 additions & 10 deletions rust/agama-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,28 +118,30 @@ async fn build_manager<'a>() -> anyhow::Result<ManagerClient<'a>> {
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.
Expand Down
52 changes: 40 additions & 12 deletions rust/agama-cli/src/questions.rs
Original file line number Diff line number Diff line change
@@ -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)]
Expand All @@ -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)]
Expand All @@ -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::<anyhow::Error>::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::<anyhow::Error>::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,
}
}
170 changes: 170 additions & 0 deletions rust/agama-lib/src/base_http_client.rs
Original file line number Diff line number Diff line change
@@ -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<Vec<Question>, 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<Self, ServiceError> {
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<T: DeserializeOwned>(&self, path: &str) -> Result<T, ServiceError> {
let response = self.get_response(path).await?;
if response.status().is_success() {
response.json::<T>().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<Response, ServiceError> {
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<Response, ServiceError> {
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<Response, ServiceError> {
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)
}
}
10 changes: 9 additions & 1 deletion rust/agama-lib/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}")]
Expand All @@ -29,10 +31,16 @@ pub enum ServiceError {
FailedRegistration(String),
#[error("Failed to find these patterns: {0:?}")]
UnknownPatterns(Vec<String>),
#[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)]
Expand Down
1 change: 1 addition & 0 deletions rust/agama-lib/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 5 additions & 0 deletions rust/agama-lib/src/questions.rs
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Loading

0 comments on commit fce3d4c

Please sign in to comment.