use crate::error::Error::GenericError; use crate::error::Result; use crate::render::render_http_request; use crate::response_err; use http::header::{ACCEPT, USER_AGENT}; use http::{HeaderMap, HeaderName, HeaderValue}; use log::{debug, error, warn}; use mime_guess::Mime; use reqwest::redirect::Policy; use reqwest::{Method, NoProxy, Response}; use reqwest::{Proxy, Url, multipart}; use serde_json::Value; use std::collections::BTreeMap; use std::path::PathBuf; use std::str::FromStr; use std::sync::Arc; use std::time::Duration; use tauri::{Manager, Runtime, WebviewWindow}; use tokio::fs; use tokio::fs::{File, create_dir_all}; use tokio::io::AsyncWriteExt; use tokio::sync::watch::Receiver; use tokio::sync::{Mutex, oneshot}; use yaak_models::models::{ Cookie, CookieJar, Environment, HttpRequest, HttpResponse, HttpResponseHeader, HttpResponseState, ProxySetting, ProxySettingAuth, }; use yaak_models::query_manager::QueryManagerExt; use yaak_models::util::UpdateSource; use yaak_plugins::events::{ CallHttpAuthenticationRequest, HttpHeader, PluginWindowContext, RenderPurpose, }; use yaak_plugins::manager::PluginManager; use yaak_plugins::template_callback::PluginTemplateCallback; pub async fn send_http_request( window: &WebviewWindow, unrendered_request: &HttpRequest, og_response: &HttpResponse, environment: Option, cookie_jar: Option, cancelled_rx: &mut Receiver, ) -> Result { let app_handle = window.app_handle().clone(); let plugin_manager = app_handle.state::(); let settings = window.db().get_settings(); let workspace = window.db().get_workspace(&unrendered_request.workspace_id)?; let environment_id = environment.map(|e| e.id); let environment_chain = window.db().resolve_environments( &unrendered_request.workspace_id, unrendered_request.folder_id.as_deref(), environment_id.as_deref(), )?; let response_id = og_response.id.clone(); let response = Arc::new(Mutex::new(og_response.clone())); let update_source = UpdateSource::from_window(window); let (resolved_request, auth_context_id) = match resolve_http_request(window, unrendered_request) { Ok(r) => r, Err(e) => { return Ok(response_err( &app_handle, &*response.lock().await, e.to_string(), &update_source, )); } }; let cb = PluginTemplateCallback::new( window.app_handle(), &PluginWindowContext::new(window), RenderPurpose::Send, ); let request = match render_http_request(&resolved_request, environment_chain, &cb).await { Ok(r) => r, Err(e) => { return Ok(response_err( &app_handle, &*response.lock().await, e.to_string(), &update_source, )); } }; let mut url_string = request.url.clone(); url_string = ensure_proto(&url_string); if !url_string.starts_with("http://") && !url_string.starts_with("https://") { url_string = format!("http://{}", url_string); } debug!("Sending request to {} {url_string}", request.method); let mut client_builder = reqwest::Client::builder() .redirect(match workspace.setting_follow_redirects { true => Policy::limited(10), // TODO: Handle redirects natively false => Policy::none(), }) .connection_verbose(true) .gzip(true) .brotli(true) .deflate(true) .referer(false) .tls_info(true); let tls_config = yaak_http::tls::get_config(workspace.setting_validate_certificates, true); client_builder = client_builder.use_preconfigured_tls(tls_config); match settings.proxy { Some(ProxySetting::Disabled) => client_builder = client_builder.no_proxy(), Some(ProxySetting::Enabled { http, https, auth, disabled, bypass, }) if !disabled => { debug!("Using proxy http={http} https={https} bypass={bypass}"); if !http.is_empty() { match Proxy::http(http) { Ok(mut proxy) => { if let Some(ProxySettingAuth { user, password }) = auth.clone() { debug!("Using http proxy auth"); proxy = proxy.basic_auth(user.as_str(), password.as_str()); } proxy = proxy.no_proxy(NoProxy::from_string(&bypass)); client_builder = client_builder.proxy(proxy); } Err(e) => { warn!("Failed to apply http proxy {e:?}"); } }; } if !https.is_empty() { match Proxy::https(https) { Ok(mut proxy) => { if let Some(ProxySettingAuth { user, password }) = auth { debug!("Using https proxy auth"); proxy = proxy.basic_auth(user.as_str(), password.as_str()); } proxy = proxy.no_proxy(NoProxy::from_string(&bypass)); client_builder = client_builder.proxy(proxy); } Err(e) => { warn!("Failed to apply https proxy {e:?}"); } }; } } _ => {} // Nothing to do for this one, as it is the default } // Add cookie store if specified let maybe_cookie_manager = match cookie_jar.clone() { Some(CookieJar { id, .. }) => { // NOTE: WE need to refetch the cookie jar because a chained request might have // updated cookies when we rendered the request. let cj = window.db().get_cookie_jar(&id)?; // HACK: Can't construct Cookie without serde, so we have to do this let cookies = cj .cookies .iter() .filter_map(|cookie| { let json_cookie = serde_json::to_value(cookie).ok()?; serde_json::from_value(json_cookie).ok()? }) .map(|c| Ok(c)) .collect::>>(); let store = reqwest_cookie_store::CookieStore::from_cookies(cookies, true)?; let cookie_store = reqwest_cookie_store::CookieStoreMutex::new(store); let cookie_store = Arc::new(cookie_store); client_builder = client_builder.cookie_provider(Arc::clone(&cookie_store)); Some((cookie_store, cj)) } None => None, }; if workspace.setting_request_timeout > 0 { client_builder = client_builder.timeout(Duration::from_millis( workspace.setting_request_timeout.unsigned_abs() as u64, )); } let client = client_builder.build()?; // Render query parameters let mut query_params = Vec::new(); for p in request.url_parameters.clone() { if !p.enabled || p.name.is_empty() { continue; } query_params.push((p.name, p.value)); } let url = match Url::from_str(&url_string) { Ok(u) => u, Err(e) => { return Ok(response_err( &app_handle, &*response.lock().await, format!("Failed to parse URL \"{}\": {}", url_string, e.to_string()), &update_source, )); } }; let m = Method::from_str(&request.method.to_uppercase()) .map_err(|e| GenericError(e.to_string()))?; let mut request_builder = client.request(m, url).query(&query_params); let mut headers = HeaderMap::new(); headers.insert(USER_AGENT, HeaderValue::from_static("yaak")); headers.insert(ACCEPT, HeaderValue::from_static("*/*")); // TODO: Set cookie header ourselves once we also handle redirects. We need to do this // because reqwest doesn't give us a way to inspect the headers it sent (we have to do // everything manually to know that). // if let Some(cookie_store) = maybe_cookie_store.clone() { // let values1 = cookie_store.get_request_values(&url); // let raw_value = cookie_store.get_request_values(&url) // .map(|(name, value)| format!("{}={}", name, value)) // .collect::>() // .join("; "); // headers.insert( // COOKIE, // HeaderValue::from_str(&raw_value).expect("Failed to create cookie header"), // ); // } for h in request.headers.clone() { if h.name.is_empty() && h.value.is_empty() { continue; } if !h.enabled { continue; } let header_name = match HeaderName::from_str(&h.name) { Ok(n) => n, Err(e) => { error!("Failed to create header name: {}", e); continue; } }; let header_value = match HeaderValue::from_str(&h.value) { Ok(n) => n, Err(e) => { error!("Failed to create header value: {}", e); continue; } }; headers.insert(header_name, header_value); } let request_body = request.body.clone(); if let Some(body_type) = &request.body_type.clone() { if body_type == "graphql" { let query = get_str_h(&request_body, "query"); let variables = get_str_h(&request_body, "variables"); if request.method.to_lowercase() == "get" { request_builder = request_builder.query(&[("query", query)]); if !variables.trim().is_empty() { request_builder = request_builder.query(&[("variables", variables)]); } } else { let body = if variables.trim().is_empty() { format!(r#"{{"query":{}}}"#, serde_json::to_string(query).unwrap_or_default()) } else { format!( r#"{{"query":{},"variables":{variables}}}"#, serde_json::to_string(query).unwrap_or_default() ) }; request_builder = request_builder.body(body.to_owned()); } } else if body_type == "application/x-www-form-urlencoded" && request_body.contains_key("form") { let mut form_params = Vec::new(); let form = request_body.get("form"); if let Some(f) = form { match f.as_array() { None => {} Some(a) => { for p in a { let enabled = get_bool(p, "enabled", true); let name = get_str(p, "name"); if !enabled || name.is_empty() { continue; } let value = get_str(p, "value"); form_params.push((name, value)); } } } } request_builder = request_builder.form(&form_params); } else if body_type == "binary" && request_body.contains_key("filePath") { let file_path = request_body .get("filePath") .ok_or(GenericError("filePath not set".to_string()))? .as_str() .unwrap_or_default(); match fs::read(file_path).await.map_err(|e| e.to_string()) { Ok(f) => { request_builder = request_builder.body(f); } Err(e) => { return Ok(response_err( &app_handle, &*response.lock().await, e, &update_source, )); } } } else if body_type == "multipart/form-data" && request_body.contains_key("form") { let mut multipart_form = multipart::Form::new(); if let Some(form_definition) = request_body.get("form") { match form_definition.as_array() { None => {} Some(fd) => { for p in fd { let enabled = get_bool(p, "enabled", true); let name = get_str(p, "name").to_string(); if !enabled || name.is_empty() { continue; } let file_path = get_str(p, "file").to_owned(); let value = get_str(p, "value").to_owned(); let mut part = if file_path.is_empty() { multipart::Part::text(value.clone()) } else { match fs::read(file_path.clone()).await { Ok(f) => multipart::Part::bytes(f), Err(e) => { return Ok(response_err( &app_handle, &*response.lock().await, e.to_string(), &update_source, )); } } }; let content_type = get_str(p, "contentType"); // Set or guess mimetype if !content_type.is_empty() { part = match part.mime_str(content_type) { Ok(p) => p, Err(e) => { return Ok(response_err( &app_handle, &*response.lock().await, format!("Invalid mime for multi-part entry {e:?}"), &update_source, )); } }; } else if !file_path.is_empty() { let default_mime = Mime::from_str("application/octet-stream").unwrap(); let mime = mime_guess::from_path(file_path.clone()).first_or(default_mime); part = match part.mime_str(mime.essence_str()) { Ok(p) => p, Err(e) => { return Ok(response_err( &app_handle, &*response.lock().await, format!("Invalid mime for multi-part entry {e:?}"), &update_source, )); } }; } // Set a file path if it is not empty if !file_path.is_empty() { let filename = PathBuf::from(file_path) .file_name() .unwrap_or_default() .to_string_lossy() .to_string(); part = part.file_name(filename); } multipart_form = multipart_form.part(name, part); } } } } headers.remove("Content-Type"); // reqwest will add this automatically request_builder = request_builder.multipart(multipart_form); } else if request_body.contains_key("text") { let body = get_str_h(&request_body, "text"); request_builder = request_builder.body(body.to_owned()); } else { warn!("Unsupported body type: {}", body_type); } } else { // No body set let method = request.method.to_ascii_lowercase(); let is_body_method = method == "post" || method == "put" || method == "patch"; // Add Content-Length for methods that commonly accept a body because some servers // will error if they don't receive it. if is_body_method && !headers.contains_key("content-length") { headers.insert("Content-Length", HeaderValue::from_static("0")); } } // Add headers last, because previous steps may modify them request_builder = request_builder.headers(headers); let mut sendable_req = match request_builder.build() { Ok(r) => r, Err(e) => { warn!("Failed to build request builder {e:?}"); return Ok(response_err( &app_handle, &*response.lock().await, e.to_string(), &update_source, )); } }; match request.authentication_type { None => { // No authentication found. Not even inherited } Some(authentication_type) if authentication_type == "none" => { // Explicitly no authentication } Some(authentication_type) => { let req = CallHttpAuthenticationRequest { context_id: format!("{:x}", md5::compute(auth_context_id)), values: serde_json::from_value(serde_json::to_value(&request.authentication)?)?, url: sendable_req.url().to_string(), method: sendable_req.method().to_string(), headers: sendable_req .headers() .iter() .map(|(name, value)| HttpHeader { name: name.to_string(), value: value.to_str().unwrap_or_default().to_string(), }) .collect(), }; let auth_result = plugin_manager.call_http_authentication(&window, &authentication_type, req).await; let plugin_result = match auth_result { Ok(r) => r, Err(e) => { return Ok(response_err( &app_handle, &*response.lock().await, e.to_string(), &update_source, )); } }; let headers = sendable_req.headers_mut(); for header in plugin_result.set_headers.unwrap_or_default() { match (HeaderName::from_str(&header.name), HeaderValue::from_str(&header.value)) { (Ok(name), Ok(value)) => { headers.insert(name, value); } _ => continue, }; } if let Some(params) = plugin_result.set_query_parameters { let mut query_pairs = sendable_req.url_mut().query_pairs_mut(); for p in params { query_pairs.append_pair(&p.name, &p.value); } } } } let (resp_tx, resp_rx) = oneshot::channel::>(); let (done_tx, done_rx) = oneshot::channel::(); let start = std::time::Instant::now(); tokio::spawn(async move { let _ = resp_tx.send(client.execute(sendable_req).await); }); let raw_response = tokio::select! { Ok(r) = resp_rx => r, _ = cancelled_rx.changed() => { let mut r = response.lock().await; r.elapsed_headers = start.elapsed().as_millis() as i32; r.elapsed = start.elapsed().as_millis() as i32; return Ok(response_err(&app_handle, &r, "Request was cancelled".to_string(), &update_source)); } }; { let app_handle = app_handle.clone(); let window = window.clone(); let cancelled_rx = cancelled_rx.clone(); let response_id = response_id.clone(); let response = response.clone(); let update_source = update_source.clone(); tokio::spawn(async move { match raw_response { Ok(mut v) => { let content_length = v.content_length(); let response_headers = v.headers().clone(); let dir = app_handle.path().app_data_dir().unwrap(); let base_dir = dir.join("responses"); create_dir_all(base_dir.clone()).await.expect("Failed to create responses dir"); let body_path = if response_id.is_empty() { base_dir.join(uuid::Uuid::new_v4().to_string()) } else { base_dir.join(response_id.clone()) }; { let mut r = response.lock().await; r.body_path = Some(body_path.to_str().unwrap().to_string()); r.elapsed_headers = start.elapsed().as_millis() as i32; r.elapsed = start.elapsed().as_millis() as i32; r.status = v.status().as_u16() as i32; r.status_reason = v.status().canonical_reason().map(|s| s.to_string()); r.headers = response_headers .iter() .map(|(k, v)| HttpResponseHeader { name: k.as_str().to_string(), value: v.to_str().unwrap_or_default().to_string(), }) .collect(); r.url = v.url().to_string(); r.remote_addr = v.remote_addr().map(|a| a.to_string()); r.version = match v.version() { reqwest::Version::HTTP_09 => Some("HTTP/0.9".to_string()), reqwest::Version::HTTP_10 => Some("HTTP/1.0".to_string()), reqwest::Version::HTTP_11 => Some("HTTP/1.1".to_string()), reqwest::Version::HTTP_2 => Some("HTTP/2".to_string()), reqwest::Version::HTTP_3 => Some("HTTP/3".to_string()), _ => None, }; r.state = HttpResponseState::Connected; app_handle .db() .update_http_response_if_id(&r, &update_source) .expect("Failed to update response after connected"); } // Write body to FS let mut f = File::options() .create(true) .truncate(true) .write(true) .open(&body_path) .await .expect("Failed to open file"); let mut written_bytes: usize = 0; loop { let chunk = v.chunk().await; if *cancelled_rx.borrow() { // Request was canceled return; } match chunk { Ok(Some(bytes)) => { let mut r = response.lock().await; r.elapsed = start.elapsed().as_millis() as i32; f.write_all(&bytes).await.expect("Failed to write to file"); f.flush().await.expect("Failed to flush file"); written_bytes += bytes.len(); r.content_length = Some(written_bytes as i32); app_handle .db() .update_http_response_if_id(&r, &update_source) .expect("Failed to update response"); } Ok(None) => { break; } Err(e) => { response_err( &app_handle, &*response.lock().await, e.to_string(), &update_source, ); break; } } } // Set the final content length { let mut r = response.lock().await; r.content_length = match content_length { Some(l) => Some(l as i32), None => Some(written_bytes as i32), }; r.state = HttpResponseState::Closed; app_handle .db() .update_http_response_if_id(&r, &UpdateSource::from_window(&window)) .expect("Failed to update response"); }; // Add cookie store if specified if let Some((cookie_store, mut cookie_jar)) = maybe_cookie_manager { // let cookies = response_headers.get_all(SET_COOKIE).iter().map(|h| { // println!("RESPONSE COOKIE: {}", h.to_str().unwrap()); // cookie_store::RawCookie::from_str(h.to_str().unwrap()) // .expect("Failed to parse cookie") // }); // store.store_response_cookies(cookies, &url); let json_cookies: Vec = cookie_store .lock() .unwrap() .iter_any() .map(|c| { let json_cookie = serde_json::to_value(&c).expect("Failed to serialize cookie"); serde_json::from_value(json_cookie) .expect("Failed to deserialize cookie") }) .collect::>(); cookie_jar.cookies = json_cookies; if let Err(e) = app_handle .db() .upsert_cookie_jar(&cookie_jar, &UpdateSource::from_window(&window)) { error!("Failed to update cookie jar: {}", e); }; } } Err(e) => { warn!("Failed to execute request {e}"); response_err( &app_handle, &*response.lock().await, format!("{e} → {e:?}"), &update_source, ); } }; let r = response.lock().await.clone(); done_tx.send(r).unwrap(); }); }; let app_handle = app_handle.clone(); Ok(tokio::select! { Ok(r) = done_rx => r, _ = cancelled_rx.changed() => { match app_handle.with_db(|c| c.get_http_response(&response_id)) { Ok(mut r) => { r.state = HttpResponseState::Closed; r.elapsed = start.elapsed().as_millis() as i32; r.elapsed_headers = start.elapsed().as_millis() as i32; app_handle.db().update_http_response_if_id(&r, &UpdateSource::from_window(window)) .expect("Failed to update response") }, _ => { response_err(&app_handle, &*response.lock().await, "Ephemeral request was cancelled".to_string(), &update_source) }.clone(), } } }) } pub fn resolve_http_request( window: &WebviewWindow, request: &HttpRequest, ) -> Result<(HttpRequest, String)> { let mut new_request = request.clone(); let (authentication_type, authentication, authentication_context_id) = window.db().resolve_auth_for_http_request(request)?; new_request.authentication_type = authentication_type; new_request.authentication = authentication; let headers = window.db().resolve_headers_for_http_request(request)?; new_request.headers = headers; Ok((new_request, authentication_context_id)) } fn ensure_proto(url_str: &str) -> String { if url_str.starts_with("http://") || url_str.starts_with("https://") { return url_str.to_string(); } // Url::from_str will fail without a proto, so add one let parseable_url = format!("http://{}", url_str); if let Ok(u) = Url::from_str(parseable_url.as_str()) { match u.host() { Some(host) => { let h = host.to_string(); // These TLDs force HTTPS if h.ends_with(".app") || h.ends_with(".dev") || h.ends_with(".page") { return format!("https://{url_str}"); } } None => {} } } format!("http://{url_str}") } fn get_bool(v: &Value, key: &str, fallback: bool) -> bool { match v.get(key) { None => fallback, Some(v) => v.as_bool().unwrap_or(fallback), } } fn get_str<'a>(v: &'a Value, key: &str) -> &'a str { match v.get(key) { None => "", Some(v) => v.as_str().unwrap_or_default(), } } fn get_str_h<'a>(v: &'a BTreeMap, key: &str) -> &'a str { match v.get(key) { None => "", Some(v) => v.as_str().unwrap_or_default(), } }