-
Notifications
You must be signed in to change notification settings - Fork 43
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
first draft of questions API in http #1091
Changes from 4 commits
cf8ce2e
76caa2f
9de4004
bc5d034
eebbbd4
22a936c
281e053
ba5a4dd
da4a343
46abecb
189f24e
bccf78c
3646c0b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,210 @@ | ||
//! This module implements the web API for the questions module. | ||
//! | ||
//! The module offers two public functions: | ||
//! | ||
//! * `questions_service` which returns the Axum service. | ||
//! * `questions_stream` which offers an stream that emits questions related signals. | ||
|
||
use std::collections::HashMap; | ||
use crate::{error::Error, web::Event}; | ||
use agama_lib::{ | ||
error::ServiceError, proxies::{GenericQuestionProxy, QuestionWithPasswordProxy}, | ||
}; | ||
use anyhow::Context; | ||
use axum::{ | ||
extract::{State, Path}, | ||
http::StatusCode, | ||
response::{IntoResponse, Response}, | ||
routing::{get, put}, | ||
Json, Router, | ||
}; | ||
use tokio_stream::{Stream, StreamExt}; | ||
use zbus::{fdo::ObjectManagerProxy, names::{InterfaceName, OwnedInterfaceName}}; | ||
use zbus::zvariant::OwnedObjectPath; | ||
use zbus::zvariant::ObjectPath; | ||
use thiserror::Error; | ||
use serde::{Deserialize, Serialize}; | ||
use serde_json::json; | ||
|
||
// TODO: move to lib | ||
#[derive(Clone)] | ||
struct QuestionsClient<'a> { | ||
connection: zbus::Connection, | ||
objects_proxy: ObjectManagerProxy<'a>, | ||
} | ||
|
||
impl<'a> QuestionsClient<'a> { | ||
pub async fn new(dbus: zbus::Connection) -> Result<Self, zbus::Error> { | ||
Ok(Self { | ||
connection: dbus.clone(), | ||
objects_proxy: ObjectManagerProxy::new(&dbus).await? | ||
}) | ||
} | ||
|
||
pub async fn questions(&self) -> Result<Vec<Question>, ServiceError> { | ||
let objects = self.objects_proxy.get_managed_objects().await | ||
.context("failed to get managed object with Object Manager")?; | ||
let mut result: Vec<Question> = Vec::with_capacity(objects.len()); | ||
let password_interface = OwnedInterfaceName::from( | ||
InterfaceName::from_static_str("org.opensuse.Agama1.Questions.WithPassword") | ||
.context("Failed to create interface name for question with password")? | ||
); | ||
for (path, interfaces_hash) in objects.iter() { | ||
if interfaces_hash.contains_key(&password_interface) { | ||
result.push(self.create_question_with_password(&path).await?) | ||
} else { | ||
result.push(self.create_generic_question(&path).await?) | ||
} | ||
} | ||
Ok(result) | ||
} | ||
|
||
async fn create_generic_question(&self, path: &OwnedObjectPath) -> Result<Question, ServiceError> { | ||
let dbus_question = GenericQuestionProxy::builder(&self.connection) | ||
.path(path)?.cache_properties(zbus::CacheProperties::No).build().await?; | ||
let result = Question { | ||
generic: GenericQuestion { | ||
id: dbus_question.id().await?, | ||
class: dbus_question.class().await?, | ||
text: dbus_question.text().await?, | ||
options: dbus_question.options().await?, | ||
default_option: dbus_question.default_option().await?, | ||
data: dbus_question.data().await? | ||
}, | ||
with_password: None | ||
}; | ||
|
||
Ok(result) | ||
} | ||
|
||
async fn create_question_with_password(&self, path: &OwnedObjectPath) -> Result<Question, ServiceError> { | ||
let dbus_question = QuestionWithPasswordProxy::builder(&self.connection) | ||
.path(path)?.cache_properties(zbus::CacheProperties::No).build().await?; | ||
let mut result = self.create_generic_question(path).await?; | ||
result.with_password = Some(QuestionWithPassword{ | ||
password: dbus_question.password().await? | ||
}); | ||
|
||
Ok(result) | ||
} | ||
|
||
pub async fn answer(&self, id: u32, answer: Answer) -> Result<(), ServiceError> { | ||
let question_path = OwnedObjectPath::from( | ||
ObjectPath::try_from(format!("/org/opensuse/Agama1/Questions/{}", id)) | ||
.context("Failed to create dbus path")? | ||
); | ||
if let Some(password) = answer.with_password { | ||
let dbus_password = QuestionWithPasswordProxy::builder(&self.connection) | ||
.path(&question_path)?.cache_properties(zbus::CacheProperties::No).build().await?; | ||
dbus_password.set_password(password.password.as_str()).await? | ||
} | ||
let dbus_generic = GenericQuestionProxy::builder(&self.connection) | ||
.path(&question_path)?.cache_properties(zbus::CacheProperties::No).build().await?; | ||
dbus_generic.set_answer(answer.generic.answer.as_str()).await?; | ||
Ok(()) | ||
} | ||
} | ||
|
||
#[derive(Error, Debug)] | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This error do not bring any value. As we already did in the software layer, you can directly use crate::error::Error. |
||
pub enum QuestionsError { | ||
#[error("Question service error: {0}")] | ||
Error(#[from] ServiceError), | ||
} | ||
|
||
impl IntoResponse for QuestionsError { | ||
fn into_response(self) -> Response { | ||
let body = json!({ | ||
"error": self.to_string() | ||
}); | ||
(StatusCode::BAD_REQUEST, Json(body)).into_response() | ||
} | ||
} | ||
|
||
#[derive(Clone)] | ||
struct QuestionsState<'a> { | ||
questions: QuestionsClient<'a>, | ||
} | ||
|
||
#[derive(Clone, Serialize, Deserialize, utoipa::ToSchema)] | ||
pub struct Question { | ||
generic: GenericQuestion, | ||
with_password: Option<QuestionWithPassword>, | ||
} | ||
|
||
/// Facade of agama_lib::questions::GenericQuestion | ||
/// For fields details see it. | ||
#[derive(Clone, Serialize, Deserialize, utoipa::ToSchema)] | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why do you need a facade? If it is because of the However, having to use a facade just because of our documentation library feels wrong too. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No, it is not related to utoipa at all. It is because I do not want to break dbus code and I like more the approach with generic question and composition of question parts. Original code contain QuestionWithPassword that contain link to GenericQuestion, which I do not like much. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Well, as soon as you do not break the D-Bus external API, you can refactor the internals if you wish. If you decide to keep the facade, please, write down the reason in the comment. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. well, I will write it to comment. In DBus we have attributes and in question is included also answer attribute. Which is not what I want to http API. I have there two parts: 1. question and 2. answer. So original one struct is split into two and the first one is used as output and the second as expected input. At least that is my idea. As said I will write it to comment |
||
pub struct GenericQuestion { | ||
id: u32, | ||
class: String, | ||
text: String, | ||
options: Vec<String>, | ||
default_option: String, | ||
data: HashMap<String, String> | ||
} | ||
|
||
/// Facade of agama_lib::questions::WithPassword | ||
/// For fields details see it. | ||
#[derive(Clone, Serialize, Deserialize, utoipa::ToSchema)] | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same than above. |
||
pub struct QuestionWithPassword { | ||
password: String | ||
} | ||
|
||
#[derive(Clone, Serialize, Deserialize, utoipa::ToSchema)] | ||
pub struct Answer { | ||
generic: GenericAnswer, | ||
with_password: Option<PasswordAnswer>, | ||
} | ||
|
||
/// Answer needed for GenericQuestion | ||
#[derive(Clone, Serialize, Deserialize, utoipa::ToSchema)] | ||
pub struct GenericAnswer { | ||
answer: String | ||
} | ||
|
||
/// Answer needed for Password specific questions. | ||
#[derive(Clone, Serialize, Deserialize, utoipa::ToSchema)] | ||
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<Router, ServiceError> { | ||
let questions = QuestionsClient::new(dbus.clone()).await?; | ||
let state = QuestionsState { questions }; | ||
let router = Router::new() | ||
.route("/questions", get(list_questions)) | ||
.route("/questions/:id/answer", put(answer)) | ||
.with_state(state); | ||
Ok(router) | ||
} | ||
|
||
pub async fn questions_stream(dbus: zbus::Connection) -> Result<impl Stream<Item = Event>, Error> { | ||
let proxy = ObjectManagerProxy::new(&dbus).await?; | ||
let add_stream = proxy | ||
.receive_interfaces_added() | ||
.await? | ||
.then(|_| async move { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I do not know what your plan is here, but to simplify things a bit, you could include the question number (taken from the D-Bus path) and let the client (on the JavaScript side) retrieve the question. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. well, current API does not allow to retrieve single question. That is different to DBus.
Reason why I design it this way is that common workflow is question arise, answer is provided and then another question arise. I think that having multiple unanswered question is quite rare situation, so I optimize API for that single case, but do not prevent in future to extend it e.g. to also get single question if there will be many questions. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. OK, I get the idea. About the answers file, we are not using it right now, so you can skip that part. |
||
Event::QuestionsChanged | ||
}); | ||
let remove_stream = proxy | ||
.receive_interfaces_removed() | ||
.await? | ||
.then(|_| async move { | ||
Event::QuestionsChanged | ||
}); | ||
Ok(StreamExt::merge(add_stream, remove_stream)) | ||
} | ||
|
||
async fn list_questions(State(state): State<QuestionsState<'_>> | ||
) -> Result<Json<Vec<Question>>, QuestionsError> { | ||
Ok(Json(state.questions.questions().await?)) | ||
} | ||
|
||
async fn answer( | ||
State(state): State<QuestionsState<'_>>, | ||
Path(question_id): Path<u32>, | ||
Json(answer): Json<Answer> | ||
) -> Result<(), QuestionsError> { | ||
state.questions.answer(question_id, answer).await?; | ||
Ok(()) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
only tricky part with moving to lib is that structs for web does not live there...There are GenericQuestion struct, so after move I will need to implement some
Into
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I still think that the Client should be 1) agnostic from the web layer and 2) live in
agama-lib
. However, given that we are short on time, we could add a Trello card listing things to improve and refactor. After all, we are still learning how to organize our code.