diff --git a/src/console/commands/seeder/api.rs b/src/console/commands/seeder/api.rs index 499a8605..5ddee1d7 100644 --- a/src/console/commands/seeder/api.rs +++ b/src/console/commands/seeder/api.rs @@ -35,7 +35,13 @@ pub async fn upload_torrent(client: &Client, upload_torrent_form: UploadTorrentM add_category(client, &upload_torrent_form.category).await; } - let response = client.upload_torrent(upload_torrent_form.into()).await; + // todo: if we receive timeout error we should retry later. Otherwise we + // have to restart the seeder manually. + + let response = client + .upload_torrent(upload_torrent_form.into()) + .await + .expect("API should return a response"); debug!(target:"seeder", "response: {}", response.status); @@ -68,7 +74,8 @@ pub async fn login(client: &Client, username: &str, password: &str) -> LoggedInU login: username.to_owned(), password: password.to_owned(), }) - .await; + .await + .expect("API should return a response"); let res: SuccessfulLoginResponse = serde_json::from_str(&response.body).unwrap_or_else(|_| { panic!( @@ -86,7 +93,7 @@ pub async fn login(client: &Client, username: &str, password: &str) -> LoggedInU /// /// Panics if the response body is not a valid JSON. pub async fn get_categories(client: &Client) -> Vec { - let response = client.get_categories().await; + let response = client.get_categories().await.expect("API should return a response"); let res: ListResponse = serde_json::from_str(&response.body).unwrap(); @@ -94,6 +101,10 @@ pub async fn get_categories(client: &Client) -> Vec { } /// It adds a new category. +/// +/// # Panics +/// +/// Will panic if it doesn't get a response form the API. pub async fn add_category(client: &Client, name: &str) -> TextResponse { client .add_category(AddCategoryForm { @@ -101,6 +112,7 @@ pub async fn add_category(client: &Client, name: &str) -> TextResponse { icon: None, }) .await + .expect("API should return a response") } /// It checks if the category list contains the given category. diff --git a/src/console/commands/seeder/app.rs b/src/console/commands/seeder/app.rs index 8297e071..84ab55cf 100644 --- a/src/console/commands/seeder/app.rs +++ b/src/console/commands/seeder/app.rs @@ -15,7 +15,7 @@ //! //! ```text //! cargo run --bin seeder -- \ -//! --api-base-url "localhost:3001" \ +//! --api-base-url "http://localhost:3001" \ //! --number-of-torrents 1000 \ //! --user admin \ //! --password 12345678 \ @@ -127,12 +127,14 @@ //! //! As you can see the `info` dictionary is exactly the same, which produces //! the same info-hash for the torrent. +use std::str::FromStr; use std::thread::sleep; use std::time::Duration; use anyhow::Context; use clap::Parser; use log::{debug, info, LevelFilter}; +use reqwest::Url; use text_colorizer::Colorize; use uuid::Uuid; @@ -173,9 +175,11 @@ pub async fn run() -> anyhow::Result<()> { let args = Args::parse(); - let api_user = login_index_api(&args.api_base_url, &args.user, &args.password).await; + let api_url = Url::from_str(&args.api_base_url).context("failed to parse API base URL")?; - let api_client = Client::authenticated(&args.api_base_url, &api_user.token); + let api_user = login_index_api(&api_url, &args.user, &args.password).await; + + let api_client = Client::authenticated(&api_url, &api_user.token); info!(target:"seeder", "Uploading { } random torrents to the Torrust Index with a { } seconds interval...", args.number_of_torrents.to_string().yellow(), args.interval.to_string().yellow()); @@ -202,7 +206,7 @@ pub async fn run() -> anyhow::Result<()> { } /// It logs in a user in the Index API. -pub async fn login_index_api(api_url: &str, username: &str, password: &str) -> LoggedInUserData { +pub async fn login_index_api(api_url: &Url, username: &str, password: &str) -> LoggedInUserData { let unauthenticated_client = Client::unauthenticated(api_url); info!(target:"seeder", "Trying to login with username: {} ...", username.yellow()); diff --git a/src/web/api/client/v1/client.rs b/src/web/api/client/v1/client.rs index 59176203..99069a19 100644 --- a/src/web/api/client/v1/client.rs +++ b/src/web/api/client/v1/client.rs @@ -1,5 +1,4 @@ -use reqwest::multipart; -use serde::Serialize; +use reqwest::{multipart, Url}; use super::connection_info::ConnectionInfo; use super::contexts::category::forms::{AddCategoryForm, DeleteCategoryForm}; @@ -7,8 +6,19 @@ use super::contexts::tag::forms::{AddTagForm, DeleteTagForm}; use super::contexts::torrent::forms::UpdateTorrentForm; use super::contexts::torrent::requests::InfoHash; use super::contexts::user::forms::{LoginForm, RegistrationForm, TokenRenewalForm, TokenVerificationForm, Username}; -use super::http::{Query, ReqwestQuery}; -use super::responses::{self, BinaryResponse, TextResponse}; +use super::http::{Http, Query}; +use super::responses::{self, TextResponse}; + +#[derive(Debug)] +pub enum Error { + HttpError(reqwest::Error), +} + +impl From for Error { + fn from(err: reqwest::Error) -> Self { + Error::HttpError(err) + } +} /// API Client pub struct Client { @@ -16,20 +26,18 @@ pub struct Client { } impl Client { - // todo: forms in POST requests can be passed by reference. - fn base_path() -> String { "/v1".to_string() } #[must_use] - pub fn unauthenticated(bind_address: &str) -> Self { - Self::new(ConnectionInfo::anonymous(bind_address, &Self::base_path())) + pub fn unauthenticated(base_url: &Url) -> Self { + Self::new(ConnectionInfo::anonymous(base_url, &Self::base_path())) } #[must_use] - pub fn authenticated(bind_address: &str, token: &str) -> Self { - Self::new(ConnectionInfo::new(bind_address, &Self::base_path(), token)) + pub fn authenticated(base_url: &Url, token: &str) -> Self { + Self::new(ConnectionInfo::new(base_url, &Self::base_path(), token)) } #[must_use] @@ -47,287 +55,228 @@ impl Client { // Context: about - pub async fn about(&self) -> TextResponse { - self.http_client.get("/about", Query::empty()).await + /// # Errors + /// + /// Will return an error if the request fails. + pub async fn about(&self) -> Result { + self.http_client.get("/about", Query::empty()).await.map_err(Error::from) } - pub async fn license(&self) -> TextResponse { - self.http_client.get("/about/license", Query::empty()).await + /// # Errors + /// + /// Will return an error if the request fails. + pub async fn license(&self) -> Result { + self.http_client + .get("/about/license", Query::empty()) + .await + .map_err(Error::from) } // Context: category - pub async fn get_categories(&self) -> TextResponse { - self.http_client.get("/category", Query::empty()).await + /// # Errors + /// + /// Will return an error if the request fails. + pub async fn get_categories(&self) -> Result { + self.http_client.get("/category", Query::empty()).await.map_err(Error::from) } - pub async fn add_category(&self, add_category_form: AddCategoryForm) -> TextResponse { - self.http_client.post("/category", &add_category_form).await + /// # Errors + /// + /// Will return an error if the request fails. + pub async fn add_category(&self, add_category_form: AddCategoryForm) -> Result { + self.http_client + .post("/category", &add_category_form) + .await + .map_err(Error::from) } - pub async fn delete_category(&self, delete_category_form: DeleteCategoryForm) -> TextResponse { - self.http_client.delete_with_body("/category", &delete_category_form).await + /// # Errors + /// + /// Will return an error if the request fails. + pub async fn delete_category(&self, delete_category_form: DeleteCategoryForm) -> Result { + self.http_client + .delete_with_body("/category", &delete_category_form) + .await + .map_err(Error::from) } // Context: tag - pub async fn get_tags(&self) -> TextResponse { - // code-review: some endpoint are using plural - // (for instance, `get_categories`) and some singular. - self.http_client.get("/tags", Query::empty()).await + /// # Errors + /// + /// Will return an error if the request fails. + pub async fn get_tags(&self) -> Result { + self.http_client.get("/tags", Query::empty()).await.map_err(Error::from) } - pub async fn add_tag(&self, add_tag_form: AddTagForm) -> TextResponse { - self.http_client.post("/tag", &add_tag_form).await + /// # Errors + /// + /// Will return an error if the request fails. + pub async fn add_tag(&self, add_tag_form: AddTagForm) -> Result { + self.http_client.post("/tag", &add_tag_form).await.map_err(Error::from) } - pub async fn delete_tag(&self, delete_tag_form: DeleteTagForm) -> TextResponse { - self.http_client.delete_with_body("/tag", &delete_tag_form).await + /// # Errors + /// + /// Will return an error if the request fails. + pub async fn delete_tag(&self, delete_tag_form: DeleteTagForm) -> Result { + self.http_client + .delete_with_body("/tag", &delete_tag_form) + .await + .map_err(Error::from) } // Context: root - pub async fn root(&self) -> TextResponse { - self.http_client.get("", Query::empty()).await + /// # Errors + /// + /// Will return an error if the request fails. + pub async fn root(&self) -> Result { + self.http_client.get("", Query::empty()).await.map_err(Error::from) } // Context: settings - pub async fn get_public_settings(&self) -> TextResponse { - self.http_client.get("/settings/public", Query::empty()).await + /// # Errors + /// + /// Will return an error if the request fails. + pub async fn get_public_settings(&self) -> Result { + self.http_client + .get("/settings/public", Query::empty()) + .await + .map_err(Error::from) } - pub async fn get_site_name(&self) -> TextResponse { - self.http_client.get("/settings/name", Query::empty()).await + /// # Errors + /// + /// Will return an error if the request fails. + pub async fn get_site_name(&self) -> Result { + self.http_client + .get("/settings/name", Query::empty()) + .await + .map_err(Error::from) } - pub async fn get_settings(&self) -> TextResponse { - self.http_client.get("/settings", Query::empty()).await + /// # Errors + /// + /// Will return an error if the request fails. + pub async fn get_settings(&self) -> Result { + self.http_client.get("/settings", Query::empty()).await.map_err(Error::from) } // Context: torrent - pub async fn get_torrents(&self, params: Query) -> TextResponse { - self.http_client.get("/torrents", params).await + /// # Errors + /// + /// Will return an error if the request fails. + pub async fn get_torrents(&self, params: Query) -> Result { + self.http_client.get("/torrents", params).await.map_err(Error::from) } - pub async fn get_torrent(&self, info_hash: &InfoHash) -> TextResponse { - self.http_client.get(&format!("/torrent/{info_hash}"), Query::empty()).await + /// # Errors + /// + /// Will return an error if the request fails. + pub async fn get_torrent(&self, info_hash: &InfoHash) -> Result { + self.http_client + .get(&format!("/torrent/{info_hash}"), Query::empty()) + .await + .map_err(Error::from) } - pub async fn delete_torrent(&self, info_hash: &InfoHash) -> TextResponse { - self.http_client.delete(&format!("/torrent/{info_hash}")).await + /// # Errors + /// + /// Will return an error if the request fails. + pub async fn delete_torrent(&self, info_hash: &InfoHash) -> Result { + self.http_client + .delete(&format!("/torrent/{info_hash}")) + .await + .map_err(Error::from) } - pub async fn update_torrent(&self, info_hash: &InfoHash, update_torrent_form: UpdateTorrentForm) -> TextResponse { + /// # Errors + /// + /// Will return an error if the request fails. + pub async fn update_torrent( + &self, + info_hash: &InfoHash, + update_torrent_form: UpdateTorrentForm, + ) -> Result { self.http_client .put(&format!("/torrent/{info_hash}"), &update_torrent_form) .await + .map_err(Error::from) } - pub async fn upload_torrent(&self, form: multipart::Form) -> TextResponse { - self.http_client.post_multipart("/torrent/upload", form).await + /// # Errors + /// + /// Will return an error if the request fails. + pub async fn upload_torrent(&self, form: multipart::Form) -> Result { + self.http_client + .post_multipart("/torrent/upload", form) + .await + .map_err(Error::from) } - pub async fn download_torrent(&self, info_hash: &InfoHash) -> responses::BinaryResponse { + /// # Errors + /// + /// Will return an error if the request fails. + pub async fn download_torrent(&self, info_hash: &InfoHash) -> Result { self.http_client .get_binary(&format!("/torrent/download/{info_hash}"), Query::empty()) .await + .map_err(Error::from) } // Context: user - pub async fn register_user(&self, registration_form: RegistrationForm) -> TextResponse { - self.http_client.post("/user/register", ®istration_form).await - } - - pub async fn login_user(&self, registration_form: LoginForm) -> TextResponse { - self.http_client.post("/user/login", ®istration_form).await - } - - pub async fn verify_token(&self, token_verification_form: TokenVerificationForm) -> TextResponse { - self.http_client.post("/user/token/verify", &token_verification_form).await - } - - pub async fn renew_token(&self, token_verification_form: TokenRenewalForm) -> TextResponse { - self.http_client.post("/user/token/renew", &token_verification_form).await - } - - pub async fn ban_user(&self, username: Username) -> TextResponse { - self.http_client.delete(&format!("/user/ban/{}", &username.value)).await - } -} - -/// Generic HTTP Client -struct Http { - connection_info: ConnectionInfo, -} - -impl Http { - pub fn new(connection_info: ConnectionInfo) -> Self { - Self { connection_info } - } - - pub async fn get(&self, path: &str, params: Query) -> TextResponse { - let response = match &self.connection_info.token { - Some(token) => reqwest::Client::builder() - .build() - .unwrap() - .get(self.base_url(path).clone()) - .query(&ReqwestQuery::from(params)) - .bearer_auth(token) - .send() - .await - .unwrap(), - None => reqwest::Client::builder() - .build() - .unwrap() - .get(self.base_url(path).clone()) - .query(&ReqwestQuery::from(params)) - .send() - .await - .unwrap(), - }; - TextResponse::from(response).await - } - - pub async fn get_binary(&self, path: &str, params: Query) -> BinaryResponse { - let response = match &self.connection_info.token { - Some(token) => reqwest::Client::builder() - .build() - .unwrap() - .get(self.base_url(path).clone()) - .query(&ReqwestQuery::from(params)) - .bearer_auth(token) - .send() - .await - .unwrap(), - None => reqwest::Client::builder() - .build() - .unwrap() - .get(self.base_url(path).clone()) - .query(&ReqwestQuery::from(params)) - .send() - .await - .unwrap(), - }; - // todo: If the response is a JSON, it returns the JSON body in a byte - // array. This is not the expected behavior. - // - Rename BinaryResponse to BinaryTorrentResponse - // - Return an error if the response is not a bittorrent file - BinaryResponse::from(response).await - } - - pub async fn inner_get(&self, path: &str) -> Result { - reqwest::Client::builder() - .build() - .unwrap() - .get(self.base_url(path).clone()) - .send() + /// # Errors + /// + /// Will return an error if the request fails. + pub async fn register_user(&self, registration_form: RegistrationForm) -> Result { + self.http_client + .post("/user/register", ®istration_form) .await + .map_err(Error::from) } - pub async fn post(&self, path: &str, form: &T) -> TextResponse { - let response = match &self.connection_info.token { - Some(token) => reqwest::Client::new() - .post(self.base_url(path).clone()) - .bearer_auth(token) - .json(&form) - .send() - .await - .unwrap(), - None => reqwest::Client::new() - .post(self.base_url(path).clone()) - .json(&form) - .send() - .await - .unwrap(), - }; - TextResponse::from(response).await - } - - pub async fn post_multipart(&self, path: &str, form: multipart::Form) -> TextResponse { - let response = match &self.connection_info.token { - Some(token) => reqwest::Client::builder() - .build() - .unwrap() - .post(self.base_url(path).clone()) - .multipart(form) - .bearer_auth(token) - .send() - .await - .expect("failed to send multipart request with token"), - None => reqwest::Client::builder() - .build() - .unwrap() - .post(self.base_url(path).clone()) - .multipart(form) - .send() - .await - .expect("failed to send multipart request without token"), - }; - TextResponse::from(response).await - } - - pub async fn put(&self, path: &str, form: &T) -> TextResponse { - let response = match &self.connection_info.token { - Some(token) => reqwest::Client::new() - .put(self.base_url(path).clone()) - .bearer_auth(token) - .json(&form) - .send() - .await - .unwrap(), - None => reqwest::Client::new() - .put(self.base_url(path).clone()) - .json(&form) - .send() - .await - .unwrap(), - }; - TextResponse::from(response).await + /// # Errors + /// + /// Will return an error if the request fails. + pub async fn login_user(&self, registration_form: LoginForm) -> Result { + self.http_client + .post("/user/login", ®istration_form) + .await + .map_err(Error::from) } - async fn delete(&self, path: &str) -> TextResponse { - let response = match &self.connection_info.token { - Some(token) => reqwest::Client::new() - .delete(self.base_url(path).clone()) - .bearer_auth(token) - .send() - .await - .unwrap(), - None => reqwest::Client::new() - .delete(self.base_url(path).clone()) - .send() - .await - .unwrap(), - }; - TextResponse::from(response).await + /// # Errors + /// + /// Will return an error if the request fails. + pub async fn verify_token(&self, token_verification_form: TokenVerificationForm) -> Result { + self.http_client + .post("/user/token/verify", &token_verification_form) + .await + .map_err(Error::from) } - async fn delete_with_body(&self, path: &str, form: &T) -> TextResponse { - let response = match &self.connection_info.token { - Some(token) => reqwest::Client::new() - .delete(self.base_url(path).clone()) - .bearer_auth(token) - .json(&form) - .send() - .await - .unwrap(), - None => reqwest::Client::new() - .delete(self.base_url(path).clone()) - .json(&form) - .send() - .await - .unwrap(), - }; - TextResponse::from(response).await + /// # Errors + /// + /// Will return an error if the request fails. + pub async fn renew_token(&self, token_verification_form: TokenRenewalForm) -> Result { + self.http_client + .post("/user/token/renew", &token_verification_form) + .await + .map_err(Error::from) } - fn base_url(&self, path: &str) -> String { - format!( - "http://{}{}{path}", - &self.connection_info.bind_address, &self.connection_info.base_path - ) + /// # Errors + /// + /// Will return an error if the request fails. + pub async fn ban_user(&self, username: Username) -> Result { + self.http_client + .delete(&format!("/user/ban/{}", &username.value)) + .await + .map_err(Error::from) } } diff --git a/src/web/api/client/v1/connection_info.rs b/src/web/api/client/v1/connection_info.rs index 2183f4b9..8c186732 100644 --- a/src/web/api/client/v1/connection_info.rs +++ b/src/web/api/client/v1/connection_info.rs @@ -1,24 +1,71 @@ +use std::fmt; +use std::str::FromStr; + +use reqwest::Url; + +#[derive(Clone)] +pub enum Scheme { + Http, + Https, +} + +impl fmt::Display for Scheme { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Scheme::Http => write!(f, "http"), + Scheme::Https => write!(f, "https"), + } + } +} + +impl FromStr for Scheme { + type Err = (); + + fn from_str(s: &str) -> Result { + match s { + "http" => Ok(Scheme::Http), + "https" => Ok(Scheme::Https), + _ => Err(()), + } + } +} + #[derive(Clone)] pub struct ConnectionInfo { + pub scheme: Scheme, pub bind_address: String, pub base_path: String, pub token: Option, } impl ConnectionInfo { + /// # Panics + /// + /// Will panic if the the base URL does not have a valid scheme: `http` or `https`. #[must_use] - pub fn new(bind_address: &str, base_path: &str, token: &str) -> Self { + pub fn new(base_url: &Url, base_path: &str, token: &str) -> Self { Self { - bind_address: bind_address.to_string(), + scheme: base_url + .scheme() + .parse() + .expect("base API URL scheme should be 'http' or 'https"), + bind_address: base_url.authority().to_string(), base_path: base_path.to_string(), token: Some(token.to_string()), } } + /// # Panics + /// + /// Will panic if the the base URL does not have a valid scheme: `http` or `https`. #[must_use] - pub fn anonymous(bind_address: &str, base_path: &str) -> Self { + pub fn anonymous(base_url: &Url, base_path: &str) -> Self { Self { - bind_address: bind_address.to_string(), + scheme: base_url + .scheme() + .parse() + .expect("base API URL scheme should be 'http' or 'https"), + bind_address: base_url.authority().to_string(), base_path: base_path.to_string(), token: None, } diff --git a/src/web/api/client/v1/http.rs b/src/web/api/client/v1/http.rs index a3b172dc..ca42d462 100644 --- a/src/web/api/client/v1/http.rs +++ b/src/web/api/client/v1/http.rs @@ -1,3 +1,11 @@ +use std::time::Duration; + +use reqwest::{multipart, Error}; +use serde::Serialize; + +use super::connection_info::ConnectionInfo; +use super::responses::{BinaryResponse, TextResponse}; + pub type ReqwestQuery = Vec; pub type ReqwestQueryParam = (String, String); @@ -55,3 +63,232 @@ impl From for ReqwestQueryParam { (param.name, param.value) } } + +/// Generic HTTP Client +pub struct Http { + connection_info: ConnectionInfo, + /// The timeout is applied from when the request starts connecting until the + /// response body has finished. + timeout: Duration, +} + +impl Http { + #[must_use] + pub fn new(connection_info: ConnectionInfo) -> Self { + Self { + connection_info, + timeout: Duration::from_secs(5), + } + } + + /// # Errors + /// + /// Will return an error if there was an error while sending request, + /// redirect loop was detected or redirect limit was exhausted. + pub async fn get(&self, path: &str, params: Query) -> Result { + let response = match &self.connection_info.token { + Some(token) => { + reqwest::Client::builder() + .timeout(self.timeout) + .build()? + .get(self.base_url(path).clone()) + .query(&ReqwestQuery::from(params)) + .bearer_auth(token) + .send() + .await? + } + None => { + reqwest::Client::builder() + .timeout(self.timeout) + .build()? + .get(self.base_url(path).clone()) + .query(&ReqwestQuery::from(params)) + .send() + .await? + } + }; + + Ok(TextResponse::from(response).await) + } + + /// # Errors + /// + /// Will return an error if there was an error while sending request, + /// redirect loop was detected or redirect limit was exhausted. + pub async fn get_binary(&self, path: &str, params: Query) -> Result { + let response = match &self.connection_info.token { + Some(token) => { + reqwest::Client::builder() + .timeout(self.timeout) + .build()? + .get(self.base_url(path).clone()) + .query(&ReqwestQuery::from(params)) + .bearer_auth(token) + .send() + .await? + } + None => { + reqwest::Client::builder() + .timeout(self.timeout) + .build()? + .get(self.base_url(path).clone()) + .query(&ReqwestQuery::from(params)) + .send() + .await? + } + }; + + // todo: If the response is a JSON, it returns the JSON body in a byte + // array. This is not the expected behavior. + // - Rename BinaryResponse to BinaryTorrentResponse + // - Return an error if the response is not a bittorrent file + Ok(BinaryResponse::from(response).await) + } + + /// # Errors + /// + /// Will return an error if there was an error while sending request, + /// redirect loop was detected or redirect limit was exhausted. + pub async fn inner_get(&self, path: &str) -> Result { + reqwest::Client::builder() + .timeout(self.timeout) + .build()? + .get(self.base_url(path).clone()) + .send() + .await + } + + /// # Errors + /// + /// Will return an error if there was an error while sending request, + /// redirect loop was detected or redirect limit was exhausted. + pub async fn post(&self, path: &str, form: &T) -> Result { + let response = match &self.connection_info.token { + Some(token) => { + reqwest::Client::new() + .post(self.base_url(path).clone()) + .bearer_auth(token) + .json(&form) + .send() + .await? + } + None => { + reqwest::Client::new() + .post(self.base_url(path).clone()) + .json(&form) + .send() + .await? + } + }; + + Ok(TextResponse::from(response).await) + } + + /// # Errors + /// + /// Will return an error if there was an error while sending request, + /// redirect loop was detected or redirect limit was exhausted. + pub async fn post_multipart(&self, path: &str, form: multipart::Form) -> Result { + let response = match &self.connection_info.token { + Some(token) => { + reqwest::Client::builder() + .timeout(self.timeout) + .build()? + .post(self.base_url(path).clone()) + .multipart(form) + .bearer_auth(token) + .send() + .await? + } + None => { + reqwest::Client::builder() + .timeout(self.timeout) + .build()? + .post(self.base_url(path).clone()) + .multipart(form) + .send() + .await? + } + }; + + Ok(TextResponse::from(response).await) + } + + /// # Errors + /// + /// Will return an error if there was an error while sending request, + /// redirect loop was detected or redirect limit was exhausted. + pub async fn put(&self, path: &str, form: &T) -> Result { + let response = match &self.connection_info.token { + Some(token) => { + reqwest::Client::new() + .put(self.base_url(path).clone()) + .bearer_auth(token) + .json(&form) + .send() + .await? + } + None => { + reqwest::Client::new() + .put(self.base_url(path).clone()) + .json(&form) + .send() + .await? + } + }; + + Ok(TextResponse::from(response).await) + } + + /// # Errors + /// + /// Will return an error if there was an error while sending request, + /// redirect loop was detected or redirect limit was exhausted. + pub async fn delete(&self, path: &str) -> Result { + let response = match &self.connection_info.token { + Some(token) => { + reqwest::Client::new() + .delete(self.base_url(path).clone()) + .bearer_auth(token) + .send() + .await? + } + None => reqwest::Client::new().delete(self.base_url(path).clone()).send().await?, + }; + + Ok(TextResponse::from(response).await) + } + + /// # Errors + /// + /// Will return an error if there was an error while sending request, + /// redirect loop was detected or redirect limit was exhausted. + pub async fn delete_with_body(&self, path: &str, form: &T) -> Result { + let response = match &self.connection_info.token { + Some(token) => { + reqwest::Client::new() + .delete(self.base_url(path).clone()) + .bearer_auth(token) + .json(&form) + .send() + .await? + } + None => { + reqwest::Client::new() + .delete(self.base_url(path).clone()) + .json(&form) + .send() + .await? + } + }; + + Ok(TextResponse::from(response).await) + } + + fn base_url(&self, path: &str) -> String { + format!( + "{}://{}{}{path}", + &self.connection_info.scheme, &self.connection_info.bind_address, &self.connection_info.base_path + ) + } +} diff --git a/src/web/api/server/v1/routes.rs b/src/web/api/server/v1/routes.rs index a6049f49..9f33c816 100644 --- a/src/web/api/server/v1/routes.rs +++ b/src/web/api/server/v1/routes.rs @@ -20,6 +20,7 @@ pub const API_VERSION_URL_PREFIX: &str = "v1"; #[allow(clippy::needless_pass_by_value)] pub fn router(app_data: Arc) -> Router { // code-review: should we use plural for the resource prefix: `users`, `categories`, `tags`? + // Some endpoint are using plural (for instance, `get_categories`) and some singular. // See: https://stackoverflow.com/questions/6845772/should-i-use-singular-or-plural-name-convention-for-rest-resources let v1_api_routes = Router::new() diff --git a/tests/common/client.rs b/tests/common/client.rs index 97216bfa..1e938d1d 100644 --- a/tests/common/client.rs +++ b/tests/common/client.rs @@ -1,3 +1,5 @@ +use std::time::Duration; + use reqwest::multipart; use serde::Serialize; @@ -158,16 +160,23 @@ impl Client { /// Generic HTTP Client struct Http { connection_info: ConnectionInfo, + /// The timeout is applied from when the request starts connecting until the + /// response body has finished. + timeout: Duration, } impl Http { pub fn new(connection_info: ConnectionInfo) -> Self { - Self { connection_info } + Self { + connection_info, + timeout: Duration::from_secs(5), + } } pub async fn get(&self, path: &str, params: Query) -> TextResponse { let response = match &self.connection_info.token { Some(token) => reqwest::Client::builder() + .timeout(self.timeout) .build() .unwrap() .get(self.base_url(path).clone()) @@ -177,6 +186,7 @@ impl Http { .await .unwrap(), None => reqwest::Client::builder() + .timeout(self.timeout) .build() .unwrap() .get(self.base_url(path).clone()) @@ -191,6 +201,7 @@ impl Http { pub async fn get_binary(&self, path: &str, params: Query) -> BinaryResponse { let response = match &self.connection_info.token { Some(token) => reqwest::Client::builder() + .timeout(self.timeout) .build() .unwrap() .get(self.base_url(path).clone()) @@ -200,6 +211,7 @@ impl Http { .await .unwrap(), None => reqwest::Client::builder() + .timeout(self.timeout) .build() .unwrap() .get(self.base_url(path).clone()) @@ -217,6 +229,7 @@ impl Http { pub async fn inner_get(&self, path: &str) -> Result { reqwest::Client::builder() + .timeout(self.timeout) .build() .unwrap() .get(self.base_url(path).clone()) @@ -246,6 +259,7 @@ impl Http { pub async fn post_multipart(&self, path: &str, form: multipart::Form) -> TextResponse { let response = match &self.connection_info.token { Some(token) => reqwest::Client::builder() + .timeout(self.timeout) .build() .unwrap() .post(self.base_url(path).clone()) @@ -255,6 +269,7 @@ impl Http { .await .expect("failed to send multipart request with token"), None => reqwest::Client::builder() + .timeout(self.timeout) .build() .unwrap() .post(self.base_url(path).clone())