diff --git a/Cargo.toml b/Cargo.toml index 9cb6673e..811b4e55 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,7 @@ yaup = "0.2.0" either = { version = "1.8.0", features = ["serde"] } thiserror = "1.0.37" meilisearch-index-setting-macro = { path = "meilisearch-index-setting-macro", version = "0.22.1" } - +awc = {version = "3.1.0" } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] futures = "0.3" @@ -37,9 +37,11 @@ web-sys = { version = "0.3", features = ["RequestInit", "Headers", "Window", "Re wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" + [features] default = ["isahc-static-curl"] isahc-static-curl = ["isahc/static-curl"] +awc = ["awc/rustls"] [dev-dependencies] env_logger = "0.10" diff --git a/src/errors.rs b/src/errors.rs index 8c9038cb..ce92ebf6 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -38,10 +38,24 @@ pub enum Error { InvalidTenantToken(#[from] jsonwebtoken::errors::Error), /// The http client encountered an error. + #[cfg(feature = "isahc-static-curl")] #[cfg(not(target_arch = "wasm32"))] #[error("HTTP request failed: {}", .0)] HttpError(isahc::Error), + /// The http client encountered an error. + #[cfg(feature = "awc")] + #[cfg(not(target_arch = "wasm32"))] + #[error("HTTP request failed: {}", .0)] + HttpError(awc::error::SendRequestError), + + /// The http client encountered an error parsing response. + #[cfg(feature = "awc")] + #[cfg(not(target_arch = "wasm32"))] + #[error("HTTP request failed: {}", .0)] + // TODO: ? make two error types (`awc::error::SendRequestError`, `awc::error::JsonPayloadError`) or map jsonerror into parse error? + ResponseParseError(awc::error::JsonPayloadError), + /// The http client encountered an error. #[cfg(target_arch = "wasm32")] #[error("HTTP request failed: {}", .0)] @@ -240,6 +254,7 @@ impl std::fmt::Display for ErrorCode { } } +#[cfg(feature = "isahc-static-curl")] #[cfg(not(target_arch = "wasm32"))] impl From for Error { fn from(error: isahc::Error) -> Error { @@ -250,6 +265,24 @@ impl From for Error { } } } +#[cfg(feature = "awc")] +#[cfg(not(target_arch = "wasm32"))] +impl From for Error { + fn from(error: awc::error::SendRequestError) -> Error { + use awc::error::SendRequestError::*; + match error { + Url(_) => Error::InvalidRequest, + Send(e) => { + if e.kind() == std::io::ErrorKind::ConnectionRefused { + Error::UnreachableServer + } else { + Error::HttpError(awc::error::SendRequestError::Send(e)) + } + } + other => Error::HttpError(other), + } + } +} #[cfg(test)] mod test { @@ -351,11 +384,20 @@ mod test { "Error parsing response JSON: invalid type: map, expected a string at line 2 column 8" ); - let error = Error::HttpError(isahc::post("test_url", "test_body").unwrap_err()); - assert_eq!( - error.to_string(), - "HTTP request failed: failed to resolve host name" - ); + #[cfg(feature = "isahc-static-curl")] + { + let error = Error::HttpError(isahc::post("test_url", "test_body").unwrap_err()); + assert_eq!( + error.to_string(), + "HTTP request failed: failed to resolve host name" + ); + } + + #[cfg(feature = "awc")] + { +// TODO + } + let error = Error::InvalidTenantToken(jsonwebtoken::errors::Error::from(InvalidToken)); assert_eq!( diff --git a/src/indexes.rs b/src/indexes.rs index c37b9817..4423a433 100644 --- a/src/indexes.rs +++ b/src/indexes.rs @@ -596,6 +596,7 @@ impl Index { /// # movie_index.delete().await.unwrap().wait_for_completion(&client, None, None).await.unwrap(); /// # }); /// ``` + #[cfg(feature = "isahc-static-curl")] // AWC: TODO #[cfg(not(target_arch = "wasm32"))] pub async fn add_or_replace_unchecked_payload< T: futures_io::AsyncRead + Send + Sync + 'static, @@ -752,6 +753,7 @@ impl Index { /// # movie_index.delete().await.unwrap().wait_for_completion(&client, None, None).await.unwrap(); /// # }); /// ``` + #[cfg(feature = "isahc-static-curl")] // AWC: TODO #[cfg(not(target_arch = "wasm32"))] pub async fn add_or_update_unchecked_payload< T: futures_io::AsyncRead + Send + Sync + 'static, diff --git a/src/request.rs b/src/request.rs index 81c2a255..b7ee2aa3 100644 --- a/src/request.rs +++ b/src/request.rs @@ -1,6 +1,7 @@ use crate::errors::{Error, MeilisearchError}; use log::{error, trace, warn}; use serde::{de::DeserializeOwned, Serialize}; +#[cfg(not(feature = "awc"))] use serde_json::{from_str, to_string}; #[derive(Debug)] @@ -23,6 +24,7 @@ pub fn add_query_parameters(url: &str, query: &Query) -> Resul } } +#[cfg(feature = "isahc-static-curl")] #[cfg(not(target_arch = "wasm32"))] pub(crate) async fn request< Query: Serialize, @@ -115,6 +117,7 @@ pub(crate) async fn request< parse_response(status, expected_status_code, body) } +#[cfg(feature = "isahc-static-curl")] #[cfg(not(target_arch = "wasm32"))] pub(crate) async fn stream_request< 'a, @@ -318,6 +321,187 @@ pub(crate) async fn request< } } +#[cfg(feature = "awc")] +#[cfg(not(target_arch = "wasm32"))] +pub(crate) async fn request< + Query: Serialize, + Body: Serialize, + Output: DeserializeOwned + 'static, +>( + url: &str, + apikey: &str, + method: Method, + expected_status_code: u16, +) -> Result { + use awc::{error::JsonPayloadError, Client}; + + let client = Client::builder() + .add_default_header(("User-Agent".to_string(), qualified_version())) + .bearer_auth(apikey) + .finish(); + + let mut response = match &method { + Method::Get { query } => { + let url = add_query_parameters(url, query)?; + + client.get(url).send().await? + } + Method::Delete { query } => { + let url = add_query_parameters(url, query)?; + + client.delete(url).send().await? + } + Method::Post { query, body } => { + let url = add_query_parameters(url, query)?; + client.post(url).send_json(body).await? + } + Method::Patch { query, body } => { + let url = add_query_parameters(url, query)?; + client.patch(url).send_json(body).await? + } + Method::Put { query, body } => { + let url = add_query_parameters(url, query)?; + client.put(url).send_json(body).await? + } + }; + + let status_code = response.status().as_u16(); + if status_code == expected_status_code { + response.json::().await.map_err(|e| { + error!("Request succeeded but failed to parse response"); + + match e { + JsonPayloadError::Deserialize(err) => Error::ParseError(err), + other => Error::ResponseParseError(other), + } + }) + } else { + // TODO: create issue where it is clear what the HTTP error is + // ParseError(Error("invalid type: null, expected struct MeilisearchError", line: 1, column: 4)) + + warn!( + "Expected response code {}, got {}", + expected_status_code, status_code + ); + match response.json::().await { + Ok(e) => Err(Error::from(e)), + Err(e) => match e { + JsonPayloadError::Deserialize(err) => Err(Error::ParseError(err)), + other => Err(Error::ResponseParseError(other)), + }, + } + } +} + +#[cfg(target_arch = "wasm32")] +pub fn add_query_parameters( + mut url: String, + query: &Query, +) -> Result { + let query = yaup::to_string(query)?; + + if !query.is_empty() { + url = format!("{}?{}", url, query); + }; + return Ok(url); +} +#[cfg(target_arch = "wasm32")] +pub(crate) async fn request< + Query: Serialize, + Body: Serialize, + Output: DeserializeOwned + 'static, +>( + url: &str, + apikey: &str, + method: Method, + expected_status_code: u16, +) -> Result { + use wasm_bindgen::JsValue; + use wasm_bindgen_futures::JsFuture; + use web_sys::{Headers, RequestInit, Response}; + + const CONTENT_TYPE: &str = "Content-Type"; + const JSON: &str = "application/json"; + + // The 2 following unwraps should not be able to fail + let mut mut_url = url.clone().to_string(); + let headers = Headers::new().unwrap(); + headers + .append("Authorization", format!("Bearer {}", apikey).as_str()) + .unwrap(); + headers + .append("X-Meilisearch-Client", qualified_version().as_str()) + .unwrap(); + + let mut request: RequestInit = RequestInit::new(); + request.headers(&headers); + + match &method { + Method::Get { query } => { + mut_url = add_query_parameters(mut_url, &query)?; + + request.method("GET"); + } + Method::Delete { query } => { + mut_url = add_query_parameters(mut_url, &query)?; + request.method("DELETE"); + } + Method::Patch { query, body } => { + mut_url = add_query_parameters(mut_url, &query)?; + request.method("PATCH"); + headers.append(CONTENT_TYPE, JSON).unwrap(); + request.body(Some(&JsValue::from_str(&to_string(body).unwrap()))); + } + Method::Post { query, body } => { + mut_url = add_query_parameters(mut_url, &query)?; + request.method("POST"); + headers.append(CONTENT_TYPE, JSON).unwrap(); + request.body(Some(&JsValue::from_str(&to_string(body).unwrap()))); + } + Method::Put { query, body } => { + mut_url = add_query_parameters(mut_url, &query)?; + request.method("PUT"); + headers.append(CONTENT_TYPE, JSON).unwrap(); + request.body(Some(&JsValue::from_str(&to_string(body).unwrap()))); + } + } + + let window = web_sys::window().unwrap(); // TODO remove this unwrap + let response = + match JsFuture::from(window.fetch_with_str_and_init(mut_url.as_str(), &request)).await { + Ok(response) => Response::from(response), + Err(e) => { + error!("Network error: {:?}", e); + return Err(Error::UnreachableServer); + } + }; + let status = response.status() as u16; + let text = match response.text() { + Ok(text) => match JsFuture::from(text).await { + Ok(text) => text, + Err(e) => { + error!("Invalid response: {:?}", e); + return Err(Error::HttpError("Invalid response".to_string())); + } + }, + Err(e) => { + error!("Invalid response: {:?}", e); + return Err(Error::HttpError("Invalid response".to_string())); + } + }; + + if let Some(t) = text.as_string() { + if t.is_empty() { + parse_response(status, expected_status_code, String::from("null")) + } else { + parse_response(status, expected_status_code, t) + } + } else { + error!("Invalid response"); + Err(Error::HttpError("Invalid utf8".to_string())) + } +} +#[cfg(not(feature = "awc"))] fn parse_response( status_code: u16, expected_status_code: u16,