diff --git a/Cargo.toml b/Cargo.toml index f83289f..6a5fcb2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ base64 = "0.13" base64-serde = "0.6" chrono = { version = "0.4", features = ["serde"] } futures = "0.3" +indexmap = { version = "1", features = ["serde"] } humantime-serde = "1" log = "0.4" serde = { version = "1", features = ["derive"] } diff --git a/src/admin/mod.rs b/src/admin/mod.rs new file mode 100644 index 0000000..3d0512b --- /dev/null +++ b/src/admin/mod.rs @@ -0,0 +1,2 @@ +//! Application administration API. +pub mod v1; diff --git a/src/admin/v1/client.rs b/src/admin/v1/client.rs new file mode 100644 index 0000000..c77156e --- /dev/null +++ b/src/admin/v1/client.rs @@ -0,0 +1,156 @@ +use super::data::*; +use crate::error::ClientError; +use crate::openid::TokenProvider; +use crate::util::Client as TraitClient; +use std::fmt::Debug; +use tracing::instrument; +use url::Url; + +/// A client for drogue cloud application administration API, backed by reqwest. +#[derive(Clone, Debug)] +pub struct Client +where + TP: TokenProvider, +{ + client: reqwest::Client, + api_url: Url, + token_provider: TP, +} + +enum AdministrationOperation { + Transfer, + Accept, + Members, +} + +type ClientResult = Result>; + +impl TraitClient for Client +where + TP: TokenProvider, +{ + fn client(&self) -> &reqwest::Client { + &self.client + } + + fn token_provider(&self) -> &TP { + &self.token_provider + } +} + +impl Client +where + TP: TokenProvider, +{ + /// Create a new client instance. + pub fn new(client: reqwest::Client, api_url: Url, token_provider: TP) -> Self { + Self { + client, + api_url, + token_provider, + } + } + + fn url(&self, application: &str, operation: AdministrationOperation) -> ClientResult { + let mut url = self.api_url.clone(); + + { + let mut path = url + .path_segments_mut() + .map_err(|_| ClientError::Request("Failed to get paths".into()))?; + + path.extend(&["api", "admin", "v1alpha1", "apps"]); + if !application.is_empty() { + path.push(application); + } + match operation { + AdministrationOperation::Transfer => path.push("transfer-ownership"), + AdministrationOperation::Accept => path.push("accept-ownership"), + AdministrationOperation::Members => path.push("members"), + }; + } + + Ok(url) + } + + /// Get the application members and their roles + #[instrument] + pub async fn get_members(&self, application: A) -> ClientResult> + where + A: AsRef + Debug, + { + self.read(self.url(application.as_ref(), AdministrationOperation::Members)?) + .await + } + + /// Update the application members and their roles + #[instrument] + pub async fn update_members(&self, application: A, members: Members) -> ClientResult + where + A: AsRef + Debug, + { + self.update( + self.url(application.as_ref(), AdministrationOperation::Members)?, + Some(members), + ) + .await + } + + /// Transfer the application ownership to another user + #[instrument] + pub async fn initiate_app_transfer( + &self, + application: A, + username: U, + ) -> ClientResult + where + A: AsRef + Debug, + U: AsRef + Debug, + { + let payload = TransferOwnership { + new_user: username.as_ref().to_string(), + }; + + self.update( + self.url(application.as_ref(), AdministrationOperation::Transfer)?, + Some(payload), + ) + .await + } + + /// Cancel the application ownership transfer + #[instrument] + pub async fn cancel_app_transfer(&self, application: A) -> ClientResult + where + A: AsRef + Debug, + { + self.delete(self.url(application.as_ref(), AdministrationOperation::Transfer)?) + .await + } + + /// Accept the application ownership transfer + #[instrument] + pub async fn accept_app_transfer(&self, application: A) -> ClientResult + where + A: AsRef + Debug, + { + self.update( + self.url(application.as_ref(), AdministrationOperation::Accept)?, + None::<()>, + ) + .await + } + + /// Read the application ownership transfer state + #[instrument] + pub async fn read_app_transfer( + &self, + application: A, + ) -> ClientResult> + where + A: AsRef + Debug, + { + self.read(self.url(application.as_ref(), AdministrationOperation::Transfer)?) + .await + } +} diff --git a/src/admin/v1/data.rs b/src/admin/v1/data.rs new file mode 100644 index 0000000..116c8ab --- /dev/null +++ b/src/admin/v1/data.rs @@ -0,0 +1,59 @@ +use core::fmt::{Display, Formatter}; +use indexmap::IndexMap; +use serde::{Deserialize, Serialize}; +use std::str::FromStr; + +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct TransferOwnership { + pub new_user: String, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct Members { + #[serde(skip_serializing_if = "Option::is_none")] + pub resource_version: Option, + #[serde(default)] + pub members: IndexMap, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct MemberEntry { + pub role: Role, +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub enum Role { + /// Allow everything, including changing members + Admin, + /// Allow reading and writing, but not changing members. + Manager, + /// Allow reading only. + Reader, +} + +impl Display for Role { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::Admin => write!(f, "Administrator"), + Self::Manager => write!(f, "Manager"), + Self::Reader => write!(f, "Reader"), + } + } +} + +impl FromStr for Role { + type Err = (); + + fn from_str(input: &str) -> Result { + match input { + "Admin" | "admin" => Ok(Role::Admin), + "Manager" | "manager" => Ok(Role::Manager), + "Reader" | "reader" => Ok(Role::Reader), + _ => Err(()), + } + } +} diff --git a/src/admin/v1/mod.rs b/src/admin/v1/mod.rs new file mode 100644 index 0000000..6c82ec1 --- /dev/null +++ b/src/admin/v1/mod.rs @@ -0,0 +1,7 @@ +#[cfg(feature = "reqwest")] +mod client; +mod data; + +#[cfg(feature = "reqwest")] +pub use client::*; +pub use data::*; diff --git a/src/lib.rs b/src/lib.rs index 4367244..3ab784d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,13 +1,16 @@ //! A client for the Drogue IoT Cloud APIs. +pub mod admin; pub mod core; pub mod error; pub mod meta; #[cfg(feature = "openid")] pub mod openid; pub mod registry; +pub mod tokens; mod serde; mod translator; +mod util; pub use translator::*; diff --git a/src/registry/v1/client.rs b/src/registry/v1/client.rs index 8aa9dc9..20d0d7b 100644 --- a/src/registry/v1/client.rs +++ b/src/registry/v1/client.rs @@ -1,15 +1,14 @@ use super::data::*; use crate::core::WithTracing; -use crate::openid::TokenProvider; -use crate::{error::ClientError, openid::TokenInjector, Translator}; +use crate::openid::{TokenInjector, TokenProvider}; +use crate::util::Client as ClientTrait; +use crate::{error::ClientError, Translator}; use futures::{stream, StreamExt, TryStreamExt}; -use reqwest::{Response, StatusCode}; -use serde::de::DeserializeOwned; use std::fmt::Debug; use tracing::instrument; use url::Url; -/// A device registry client, backed by reqwest. +/// A device registry client #[derive(Clone, Debug)] pub struct Client where @@ -22,6 +21,19 @@ where type ClientResult = Result>; +impl ClientTrait for Client +where + TP: TokenProvider, +{ + fn client(&self) -> &reqwest::Client { + &self.client + } + + fn token_provider(&self) -> &TP { + &self.token_provider + } +} + impl Client where TP: TokenProvider, @@ -35,6 +47,7 @@ where } } + /// craft url for the registry fn url(&self, application: Option<&str>, device: Option<&str>) -> ClientResult { let mut url = self.registry_url.clone(); @@ -63,21 +76,41 @@ where /// List applications. /// + /// Optionally pass a list of labels selectors to filter the list. + /// /// If no applications exists, this function will return an empty Vec, otherwise it will return /// a list of applications. /// /// If the user does not have access to the API, the server side may return "not found" /// as a response instead of "forbidden". #[instrument] - pub async fn list_apps(&self) -> ClientResult>> { - let req = self - .client - .get(self.url(None, None)?) + pub async fn list_apps(&self, labels: Option) -> ClientResult>> + where + L: IntoIterator + Debug, + L::Item: AsRef, + { + let mut req = self.client().get(self.url(None, None)?); + + // todo it would be cool to have a programmatic way to construct labels selectors + // using drogue-cloud-service-api::labels::LabelSelector + // Also, allocating strings from the `&str` we have is terrible, + // but using only `as_ref()` from the iter was dropping the reference after the loop. + if let Some(labels) = labels { + let label_string = labels + .into_iter() + .map(|item| item.as_ref().to_string()) + .collect::>() + .join(","); + + req = req.query(&[("labels", label_string)]); + } + + let req = req .propagate_current_context() - .inject_token(&self.token_provider) + .inject_token(self.token_provider()) .await?; - Self::get_response(req.send().await?).await + Self::read_response(req.send().await?).await } /// Get an application by name. @@ -92,14 +125,7 @@ where where A: AsRef + Debug, { - let req = self - .client - .get(self.url(Some(application.as_ref()), None)?) - .propagate_current_context() - .inject_token(&self.token_provider) - .await?; - - Self::get_response(req.send().await?).await + self.read(self.url(Some(application.as_ref()), None)?).await } /// Get a device by name. @@ -115,14 +141,8 @@ where A: AsRef + Debug, D: AsRef + Debug, { - let req = self - .client - .get(self.url(Some(application.as_ref()), Some(device.as_ref()))?) - .propagate_current_context() - .inject_token(&self.token_provider) - .await?; - - Self::get_response(req.send().await?).await + self.read(self.url(Some(application.as_ref()), Some(device.as_ref()))?) + .await } /// Get a list of devices. @@ -158,15 +178,10 @@ where A: AsRef + Debug, D: AsRef + Debug, { - let req = self - .client - .get(self.url(Some(application.as_ref()), Some(device.as_ref()))?) - .propagate_current_context() - .inject_token(&self.token_provider) + let device: Option = self + .read(self.url(Some(application.as_ref()), Some(device.as_ref()))?) .await?; - let device: Option = Self::get_response(req.send().await?).await?; - if let Some(device) = device { let gateways = if let Some(gw_sel) = device .section::() @@ -185,13 +200,47 @@ where } } - async fn get_response(response: Response) -> ClientResult> { - log::debug!("Eval get response: {:#?}", response); - match response.status() { - StatusCode::OK => Ok(Some(response.json().await?)), - StatusCode::NOT_FOUND => Ok(None), - _ => Self::default_response(response).await, + /// List devices. + /// + /// Optionally pass a list of labels selectors to filter the list. + /// + /// If no devices exists, this function will return an empty Vec, otherwise it will return + /// a list of devices. + /// + /// If the user does not have access to the API, the server side may return "not found" + /// as a response instead of "forbidden". + #[instrument] + pub async fn list_devices( + &self, + application: A, + labels: Option, + ) -> ClientResult>> + where + A: AsRef + Debug, + L: IntoIterator + Debug, + L::Item: AsRef, + { + let mut req = self + .client() + .get(self.url(Some(application.as_ref()), Some(""))?); + + // todo refactor this duplicated code + if let Some(labels) = labels { + let label_string = labels + .into_iter() + .map(|item| item.as_ref().to_string()) + .collect::>() + .join(","); + + req = req.query(&[("labels", label_string)]); } + + let req = req + .propagate_current_context() + .inject_token(self.token_provider()) + .await?; + + Self::read_response(req.send().await?).await } /// Update (overwrite) an application. @@ -199,65 +248,32 @@ where /// The application must exist, otherwise `false` is returned. #[instrument] pub async fn update_app(&self, application: &Application) -> ClientResult { - let req = self - .client - .put(self.url(Some(&application.metadata.name), None)?) - .json(&application) - .propagate_current_context() - .inject_token(&self.token_provider) - .await?; - - Self::update_response(req.send().await?).await + self.update( + self.url(Some(application.metadata.name.as_str()), None)?, + Some(application), + ) + .await } /// Update (overwrite) a device. /// - /// The application must exist, otherwise `false` is returned. + /// The application and device must exist, otherwise `false` is returned. #[instrument] pub async fn update_device(&self, device: &Device) -> ClientResult { - let req = self - .client - .put(self.url( - Some(&device.metadata.application), - Some(&device.metadata.name), - )?) - .json(&device) - .propagate_current_context() - .inject_token(&self.token_provider) - .await?; - - Self::update_response(req.send().await?).await - } - - async fn update_response(response: Response) -> ClientResult { - log::debug!("Eval update response: {:#?}", response); - match response.status() { - StatusCode::OK | StatusCode::NO_CONTENT => Ok(true), - StatusCode::NOT_FOUND => Ok(false), - _ => Self::default_response(response).await, - } - } - - async fn delete_response(response: Response) -> ClientResult { - log::debug!("Eval delete response: {:#?}", response); - match response.status() { - StatusCode::OK | StatusCode::NO_CONTENT => Ok(true), - StatusCode::NOT_FOUND => Ok(false), - _ => Self::default_response(response).await, - } + self.update( + self.url( + Some(device.metadata.application.as_str()), + Some(device.metadata.name.as_str()), + )?, + Some(device), + ) + .await } /// Create a new application. #[instrument] - pub async fn create_app(&self, app: &Application) -> ClientResult<()> { - let req = self - .client - .post(self.url(None, None)?) - .json(&app) - .inject_token(&self.token_provider) - .await?; - - Self::create_response(req.send().await?).await + pub async fn create_app(&self, app: &Application) -> ClientResult> { + self.create(self.url(None, None)?, Some(app)).await } #[instrument] @@ -265,26 +281,18 @@ where where A: AsRef + Debug, { - let req = self - .client - .delete(self.url(Some(application.as_ref()), None)?) - .inject_token(&self.token_provider) - .await?; - - Self::delete_response(req.send().await?).await + self.delete(self.url(Some(application.as_ref()), None)?) + .await } /// Create a new device. #[instrument] - pub async fn create_device(&self, device: &Device) -> ClientResult<()> { - let req = self - .client - .post(self.url(Some(&device.metadata.application), Some(""))?) - .json(&device) - .inject_token(&self.token_provider) - .await?; - - Self::create_response(req.send().await?).await + pub async fn create_device(&self, device: &Device) -> ClientResult> { + self.create( + self.url(Some(device.metadata.application.as_str()), Some(""))?, + Some(device), + ) + .await } #[instrument] @@ -293,28 +301,8 @@ where A: AsRef + Debug, D: AsRef + Debug, { - let req = self - .client - .delete(self.url(Some(application.as_ref()), Some(device.as_ref()))?) - .inject_token(&self.token_provider) - .await?; - - Self::delete_response(req.send().await?).await - } - - async fn create_response(response: Response) -> ClientResult<()> { - log::debug!("Eval create response: {:#?}", response); - match response.status() { - StatusCode::CREATED => Ok(()), - _ => Self::default_response(response).await, - } - } - - async fn default_response(response: Response) -> ClientResult { - match response.status() { - code if code.is_client_error() => Err(ClientError::Service(response.json().await?)), - code => Err(ClientError::Request(format!("Unexpected code {:?}", code))), - } + self.delete(self.url(Some(application.as_ref()), Some(device.as_ref()))?) + .await } } diff --git a/src/tokens/mod.rs b/src/tokens/mod.rs new file mode 100644 index 0000000..f339013 --- /dev/null +++ b/src/tokens/mod.rs @@ -0,0 +1,2 @@ +//! Access Token client API. +pub mod v1; diff --git a/src/tokens/v1/client.rs b/src/tokens/v1/client.rs new file mode 100644 index 0000000..9978983 --- /dev/null +++ b/src/tokens/v1/client.rs @@ -0,0 +1,113 @@ +use super::data::*; +use crate::error::ClientError; +use crate::openid::{TokenInjector, TokenProvider}; +use crate::util::Client as ClientTrait; +use std::fmt::Debug; +use tracing::instrument; +use url::Url; + +/// A client for the token management API, backed by reqwest. +#[derive(Clone, Debug)] +pub struct Client +where + TP: TokenProvider, +{ + client: reqwest::Client, + api_url: Url, + token_provider: TP, +} + +type ClientResult = Result>; + +impl ClientTrait for Client +where + TP: TokenProvider, +{ + fn client(&self) -> &reqwest::Client { + &self.client + } + + fn token_provider(&self) -> &TP { + &self.token_provider + } +} + +impl Client +where + TP: TokenProvider, +{ + /// Create a new client instance. + pub fn new(client: reqwest::Client, api_url: Url, token_provider: TP) -> Self { + Self { + client, + api_url, + token_provider, + } + } + + fn url(&self, prefix: Option<&str>) -> ClientResult { + let mut url = self.api_url.clone(); + + { + let mut path = url + .path_segments_mut() + .map_err(|_| ClientError::Request("Failed to get paths".into()))?; + + path.extend(&["api", "tokens", "v1alpha1"]); + if let Some(prefix) = prefix { + if !prefix.is_empty() { + path.push(prefix); + } + } + } + + Ok(url) + } + + /// Get a list of active access tokens for this user. + /// + /// The full token won't be disclosed, as it is secret and unknown by the server. + /// The result contains the prefix and creation date for each active token. + #[instrument] + pub async fn get_tokens(&self) -> ClientResult>> { + self.read(self.url(Some(""))?).await + } + + /// Create a new access token for this user. + /// + /// The result will contain the full token. This value is only available once. + #[instrument] + pub async fn create_token( + &self, + description: Option, + ) -> ClientResult> + where + D: AsRef + Debug, + { + // here we need to override the trait method as we need to add a query parameter with the description. + let url = self.url(Some(""))?; + + let mut query: Vec<(&str, &str)> = Vec::new(); + if let Some(description) = description.as_ref() { + query.push(("description", description.as_ref())) + } + + let req = self + .client() + .post(url) + .query(&query) + .inject_token(self.token_provider()) + .await?; + + Self::create_response(req.send().await?).await + } + + /// Delete an existing token for this user. + #[instrument] + pub async fn delete_token

(&self, prefix: P) -> ClientResult + where + P: AsRef + Debug, + { + self.delete(self.url(Some(prefix.as_ref()))?).await + } +} diff --git a/src/tokens/v1/data.rs b/src/tokens/v1/data.rs new file mode 100644 index 0000000..cbdc5d8 --- /dev/null +++ b/src/tokens/v1/data.rs @@ -0,0 +1,23 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)] +pub struct AccessToken { + /// The creation date of the access token + pub created: DateTime, + /// The access token prefix + pub prefix: String, + /// The access token description + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)] +pub struct CreatedAccessToken { + /// The complete access token + #[serde(default)] + pub token: String, + /// The access token prefix + #[serde(default)] + pub prefix: String, +} diff --git a/src/tokens/v1/mod.rs b/src/tokens/v1/mod.rs new file mode 100644 index 0000000..6c82ec1 --- /dev/null +++ b/src/tokens/v1/mod.rs @@ -0,0 +1,7 @@ +#[cfg(feature = "reqwest")] +mod client; +mod data; + +#[cfg(feature = "reqwest")] +pub use client::*; +pub use data::*; diff --git a/src/util/client/mod.rs b/src/util/client/mod.rs new file mode 100644 index 0000000..7aed920 --- /dev/null +++ b/src/util/client/mod.rs @@ -0,0 +1,164 @@ +use crate::core::WithTracing; +use crate::openid::TokenProvider; +use crate::{error::ClientError, openid::TokenInjector}; + +use async_trait::async_trait; +use reqwest::{Response, StatusCode}; +use serde::de::DeserializeOwned; +use serde::Serialize; +use std::marker::Send; +use url::Url; + +/// A drogue HTTP client, backed by reqwest. + +#[async_trait] +pub trait Client +where + TP: TokenProvider, +{ + /// Retrieve the http client + fn client(&self) -> &reqwest::Client; + + /// Retrieve the token provider + fn token_provider(&self) -> &TP; + + /// Execute a GET request to read a resouce content or to list resources + /// + /// The correct authentication and tracing headers will be added to the request. + #[doc(hidden)] + async fn read(&self, url: Url) -> Result, ClientError> + where + Self: std::marker::Send, + T: DeserializeOwned, + { + let req = self + .client() + .get(url) + .propagate_current_context() + .inject_token(self.token_provider()) + .await?; + + Self::read_response(req.send().await?).await + } + + async fn read_response( + response: Response, + ) -> Result, ClientError> { + log::debug!("Eval get response: {:#?}", response); + match response.status() { + StatusCode::OK => Ok(Some(response.json().await?)), + StatusCode::NOT_FOUND => Ok(None), + _ => Self::default_response(response).await, + } + } + + /// Execute a PUT request to update an existing resource. + /// + /// A payload with the updated resource can be passed. + /// The resource must exist, otherwise `false` is returned. + /// + /// The correct authentication and tracing headers will be added to the request. + #[doc(hidden)] + async fn update( + &self, + url: Url, + payload: Option, + ) -> Result> + where + Self: std::marker::Send, + A: Serialize + Send + Sync, + { + let req = if let Some(p) = payload { + self.client().put(url).json(&p) + } else { + self.client().put(url) + } + .propagate_current_context() + .inject_token(self.token_provider()) + .await?; + + Self::update_response(req.send().await?).await + } + + async fn update_response(response: Response) -> Result> { + log::debug!("Eval update response: {:#?}", response); + match response.status() { + StatusCode::OK | StatusCode::NO_CONTENT | StatusCode::ACCEPTED => Ok(true), + StatusCode::NOT_FOUND => Ok(false), + _ => Self::default_response(response).await, + } + } + + /// Execute a DELETE request to delete an existing resource. + /// + /// The resource must exist, otherwise `false` is returned. + /// + /// The correct authentication and tracing headers will be added to the request. + #[doc(hidden)] + async fn delete(&self, url: Url) -> Result> + where + Self: std::marker::Send, + { + let req = self + .client() + .delete(url) + .inject_token(self.token_provider()) + .await?; + + Self::delete_response(req.send().await?).await + } + + async fn delete_response(response: Response) -> Result> { + log::debug!("Eval delete response: {:#?}", response); + match response.status() { + StatusCode::OK | StatusCode::NO_CONTENT => Ok(true), + StatusCode::NOT_FOUND => Ok(false), + _ => Self::default_response(response).await, + } + } + + /// Execute a POST request to create a resource. + /// + /// The correct authentication and tracing headers will be added to the request. + #[doc(hidden)] + async fn create( + &self, + url: Url, + payload: Option

, + ) -> Result, ClientError> + where + Self: std::marker::Send, + P: Serialize + Send + Sync, + T: DeserializeOwned, + { + let req = if let Some(p) = payload { + self.client().post(url).json(&p) + } else { + self.client().post(url) + } + .propagate_current_context() + .inject_token(self.token_provider()) + .await?; + + Self::create_response(req.send().await?).await + } + + async fn create_response( + response: Response, + ) -> Result, ClientError> { + log::debug!("Eval create response: {:#?}", response); + match response.status() { + StatusCode::CREATED => Ok(None), + // the token API responds 200 on token creations, sending back the content. + StatusCode::OK => Ok(Some(response.json().await?)), + _ => Self::default_response(response).await, + } + } + + async fn default_response(response: Response) -> Result> { + match response.status() { + code if code.is_client_error() => Err(ClientError::Service(response.json().await?)), + code => Err(ClientError::Request(format!("Unexpected code {:?}", code))), + } + } +} diff --git a/src/util/mod.rs b/src/util/mod.rs new file mode 100644 index 0000000..be50984 --- /dev/null +++ b/src/util/mod.rs @@ -0,0 +1,3 @@ +mod client; + +pub use client::*;