diff --git a/Cargo.lock b/Cargo.lock index 5a06b414..f822e0d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10399,6 +10399,7 @@ dependencies = [ "urlencoding", "yaak-common", "yaak-models", + "yaak-templates", "yaak-tls", "zstd", ] diff --git a/crates-cli/yaak-cli/src/plugin_events.rs b/crates-cli/yaak-cli/src/plugin_events.rs index 1e7f9f36..cadc42dc 100644 --- a/crates-cli/yaak-cli/src/plugin_events.rs +++ b/crates-cli/yaak-cli/src/plugin_events.rs @@ -3,7 +3,7 @@ use arboard::Clipboard; use console::Term; use inquire::{Confirm, Editor, Password, PasswordDisplayMode, Select, Text}; use serde_json::Value; -use std::collections::{BTreeMap, HashMap}; +use std::collections::HashMap; use std::io::IsTerminal; use std::path::PathBuf; use std::sync::Arc; @@ -11,11 +11,11 @@ use tokio::task::JoinHandle; use yaak::plugin_events::{ GroupedPluginEvent, HostRequest, SharedPluginEventContext, handle_shared_plugin_event, }; -use yaak::render::render_http_request; +use yaak::render::{render_grpc_request, render_http_request}; use yaak::send::{SendHttpRequestWithPluginsParams, send_http_request_with_plugins}; use yaak_crypto::manager::EncryptionManager; use yaak_models::blob_manager::BlobManager; -use yaak_models::models::{Environment, GrpcRequest, HttpRequestHeader}; +use yaak_models::models::Environment; use yaak_models::queries::any_request::AnyRequest; use yaak_models::query_manager::QueryManager; use yaak_models::render::make_vars_hashmap; @@ -29,7 +29,7 @@ use yaak_plugins::events::{ }; use yaak_plugins::manager::PluginManager; use yaak_plugins::template_callback::PluginTemplateCallback; -use yaak_templates::{RenderOptions, TemplateCallback, parse_and_render, render_json_value_raw}; +use yaak_templates::{RenderOptions, TemplateCallback, render_json_value_raw}; pub struct CliPluginEventBridge { rx_id: String, @@ -269,7 +269,7 @@ async fn build_plugin_reply( ); let render_options = RenderOptions::throw(); - match render_grpc_request_for_cli( + match render_grpc_request( &grpc_request, environment_chain, &template_callback, @@ -532,60 +532,6 @@ async fn render_json_value_for_cli( render_json_value_raw(value, vars, cb, opt).await } -async fn render_grpc_request_for_cli( - grpc_request: &GrpcRequest, - environment_chain: Vec, - cb: &T, - opt: &RenderOptions, -) -> yaak_templates::error::Result { - let vars = &make_vars_hashmap(environment_chain); - - let mut metadata = Vec::new(); - for p in grpc_request.metadata.clone() { - if !p.enabled { - continue; - } - metadata.push(HttpRequestHeader { - enabled: p.enabled, - name: parse_and_render(p.name.as_str(), vars, cb, opt).await?, - value: parse_and_render(p.value.as_str(), vars, cb, opt).await?, - id: p.id, - }) - } - - let authentication = { - let mut disabled = false; - let mut auth = BTreeMap::new(); - match grpc_request.authentication.get("disabled") { - Some(Value::Bool(true)) => { - disabled = true; - } - Some(Value::String(tmpl)) => { - disabled = parse_and_render(tmpl.as_str(), vars, cb, opt) - .await - .unwrap_or_default() - .is_empty(); - } - _ => {} - } - if disabled { - auth.insert("disabled".to_string(), Value::Bool(true)); - } else { - for (k, v) in grpc_request.authentication.clone() { - if k == "disabled" { - auth.insert(k, Value::Bool(false)); - } else { - auth.insert(k, render_json_value_raw(v, vars, cb, opt).await?); - } - } - } - auth - }; - - let url = parse_and_render(grpc_request.url.as_str(), vars, cb, opt).await?; - - Ok(GrpcRequest { url, metadata, authentication, ..grpc_request.to_owned() }) -} fn parse_cookie_name_value(raw_cookie: &str) -> Option<(String, String)> { let first_part = raw_cookie.split(';').next()?.trim(); diff --git a/crates-tauri/yaak-app/src/lib.rs b/crates-tauri/yaak-app/src/lib.rs index 480995dd..23d357a1 100644 --- a/crates-tauri/yaak-app/src/lib.rs +++ b/crates-tauri/yaak-app/src/lib.rs @@ -34,6 +34,7 @@ use tokio::time; use yaak_common::command::new_checked_command; use yaak_crypto::manager::EncryptionManager; use yaak_grpc::manager::{GrpcConfig, GrpcHandle}; +use yaak_templates::strip_json_comments::strip_json_comments; use yaak_grpc::{Code, ServiceDefinition, serialize_message}; use yaak_mac_window::AppHandleMacWindowExt; use yaak_models::models::{ @@ -433,6 +434,7 @@ async fn cmd_grpc_go( result.expect("Failed to render template") }) }); + let msg = strip_json_comments(&msg); in_msg_tx.try_send(msg.clone()).unwrap(); } Ok(IncomingMsg::Commit) => { @@ -468,6 +470,7 @@ async fn cmd_grpc_go( &RenderOptions { error_behavior: RenderErrorBehavior::Throw }, ) .await?; + let msg = strip_json_comments(&msg); app_handle.db().upsert_grpc_event( &GrpcEvent { diff --git a/crates-tauri/yaak-app/src/render.rs b/crates-tauri/yaak-app/src/render.rs index 8cb3fd00..a6abe0d3 100644 --- a/crates-tauri/yaak-app/src/render.rs +++ b/crates-tauri/yaak-app/src/render.rs @@ -1,8 +1,6 @@ -use log::info; use serde_json::Value; -use std::collections::BTreeMap; -pub use yaak::render::render_http_request; -use yaak_models::models::{Environment, GrpcRequest, HttpRequestHeader}; +pub use yaak::render::{render_grpc_request, render_http_request}; +use yaak_models::models::Environment; use yaak_models::render::make_vars_hashmap; use yaak_templates::{RenderOptions, TemplateCallback, parse_and_render, render_json_value_raw}; @@ -25,61 +23,3 @@ pub async fn render_json_value( let vars = &make_vars_hashmap(environment_chain); render_json_value_raw(value, vars, cb, opt).await } - -pub async fn render_grpc_request( - r: &GrpcRequest, - environment_chain: Vec, - cb: &T, - opt: &RenderOptions, -) -> yaak_templates::error::Result { - let vars = &make_vars_hashmap(environment_chain); - - let mut metadata = Vec::new(); - for p in r.metadata.clone() { - if !p.enabled { - continue; - } - metadata.push(HttpRequestHeader { - enabled: p.enabled, - name: parse_and_render(p.name.as_str(), vars, cb, &opt).await?, - value: parse_and_render(p.value.as_str(), vars, cb, &opt).await?, - id: p.id, - }) - } - - let authentication = { - let mut disabled = false; - let mut auth = BTreeMap::new(); - match r.authentication.get("disabled") { - Some(Value::Bool(true)) => { - disabled = true; - } - Some(Value::String(tmpl)) => { - disabled = parse_and_render(tmpl.as_str(), vars, cb, &opt) - .await - .unwrap_or_default() - .is_empty(); - info!( - "Rendering authentication.disabled as a template: {disabled} from \"{tmpl}\"" - ); - } - _ => {} - } - if disabled { - auth.insert("disabled".to_string(), Value::Bool(true)); - } else { - for (k, v) in r.authentication.clone() { - if k == "disabled" { - auth.insert(k, Value::Bool(false)); - } else { - auth.insert(k, render_json_value_raw(v, vars, cb, &opt).await?); - } - } - } - auth - }; - - let url = parse_and_render(r.url.as_str(), vars, cb, &opt).await?; - - Ok(GrpcRequest { url, metadata, authentication, ..r.to_owned() }) -} diff --git a/crates-tauri/yaak-app/src/ws_ext.rs b/crates-tauri/yaak-app/src/ws_ext.rs index 28580f93..96b6f438 100644 --- a/crates-tauri/yaak-app/src/ws_ext.rs +++ b/crates-tauri/yaak-app/src/ws_ext.rs @@ -24,6 +24,7 @@ use yaak_models::util::UpdateSource; use yaak_plugins::events::{CallHttpAuthenticationRequest, HttpHeader, RenderPurpose}; use yaak_plugins::manager::PluginManager; use yaak_plugins::template_callback::PluginTemplateCallback; +use yaak_templates::strip_json_comments::maybe_strip_json_comments; use yaak_templates::{RenderErrorBehavior, RenderOptions}; use yaak_tls::find_client_certificate; use yaak_ws::{WebsocketManager, render_websocket_request}; @@ -72,8 +73,10 @@ pub async fn cmd_ws_send( ) .await?; + let message = maybe_strip_json_comments(&request.message); + let mut ws_manager = ws_manager.lock().await; - ws_manager.send(&connection.id, Message::Text(request.message.clone().into())).await?; + ws_manager.send(&connection.id, Message::Text(message.clone().into())).await?; app_handle.db().upsert_websocket_event( &WebsocketEvent { @@ -82,7 +85,7 @@ pub async fn cmd_ws_send( workspace_id: connection.workspace_id.clone(), is_server: false, message_type: WebsocketEventType::Text, - message: request.message.into(), + message: message.into(), ..Default::default() }, &UpdateSource::from_window_label(window.label()), diff --git a/crates/yaak-common/src/serde.rs b/crates/yaak-common/src/serde.rs index 683cc25d..49146aaa 100644 --- a/crates/yaak-common/src/serde.rs +++ b/crates/yaak-common/src/serde.rs @@ -21,3 +21,10 @@ pub fn get_str_map<'a>(v: &'a BTreeMap, key: &str) -> &'a str { Some(v) => v.as_str().unwrap_or_default(), } } + +pub fn get_bool_map(v: &BTreeMap, key: &str, fallback: bool) -> bool { + match v.get(key) { + None => fallback, + Some(v) => v.as_bool().unwrap_or(fallback), + } +} diff --git a/crates/yaak-http/Cargo.toml b/crates/yaak-http/Cargo.toml index 20183503..34ebba4b 100644 --- a/crates/yaak-http/Cargo.toml +++ b/crates/yaak-http/Cargo.toml @@ -29,4 +29,5 @@ tower-service = "0.3.3" urlencoding = "2.1.3" yaak-common = { workspace = true } yaak-models = { workspace = true } +yaak-templates = { workspace = true } yaak-tls = { workspace = true } diff --git a/crates/yaak-http/src/types.rs b/crates/yaak-http/src/types.rs index 3ca14e15..b1e6e655 100644 --- a/crates/yaak-http/src/types.rs +++ b/crates/yaak-http/src/types.rs @@ -9,8 +9,9 @@ use std::collections::BTreeMap; use std::pin::Pin; use std::time::Duration; use tokio::io::AsyncRead; -use yaak_common::serde::{get_bool, get_str, get_str_map}; +use yaak_common::serde::{get_bool, get_bool_map, get_str, get_str_map}; use yaak_models::models::HttpRequest; +use yaak_templates::strip_json_comments::{maybe_strip_json_comments, strip_json_comments}; pub(crate) const MULTIPART_BOUNDARY: &str = "------YaakFormBoundary"; @@ -134,16 +135,69 @@ pub fn append_query_params(url: &str, params: Vec<(String, String)>) -> String { result } +fn strip_query_params(url: &str, names: &[&str]) -> String { + // Split off fragment + let (base_and_query, fragment) = if let Some(hash_pos) = url.find('#') { + (&url[..hash_pos], Some(&url[hash_pos..])) + } else { + (url, None) + }; + + let result = if let Some(q_pos) = base_and_query.find('?') { + let base = &base_and_query[..q_pos]; + let query = &base_and_query[q_pos + 1..]; + let filtered: Vec<&str> = query + .split('&') + .filter(|pair| { + let key = pair.split('=').next().unwrap_or(""); + let decoded = urlencoding::decode(key).unwrap_or_default(); + !names.contains(&decoded.as_ref()) + }) + .collect(); + if filtered.is_empty() { + base.to_string() + } else { + format!("{}?{}", base, filtered.join("&")) + } + } else { + base_and_query.to_string() + }; + + match fragment { + Some(f) => format!("{}{}", result, f), + None => result, + } +} + fn build_url(r: &HttpRequest) -> String { let (url_string, params) = apply_path_placeholders(&ensure_proto(&r.url), &r.url_parameters); - append_query_params( + let mut url = append_query_params( &url_string, params .iter() .filter(|p| p.enabled && !p.name.is_empty()) .map(|p| (p.name.clone(), p.value.clone())) .collect(), - ) + ); + + // GraphQL GET requests encode query/variables as URL query parameters + if r.method.to_lowercase() == "get" && r.body_type.as_deref() == Some("graphql") { + url = append_graphql_query_params(&url, &r.body); + } + + url +} + +fn append_graphql_query_params(url: &str, body: &BTreeMap) -> String { + let query = get_str_map(body, "query").to_string(); + let variables = strip_json_comments(&get_str_map(body, "variables")); + let mut params = vec![("query".to_string(), query)]; + if !variables.trim().is_empty() { + params.push(("variables".to_string(), variables)); + } + // Strip existing query/variables params to avoid duplicates + let url = strip_query_params(url, &["query", "variables"]); + append_query_params(&url, params) } fn build_headers(r: &HttpRequest) -> Vec<(String, String)> { @@ -177,7 +231,7 @@ async fn build_body( (build_form_body(&body), Some("application/x-www-form-urlencoded".to_string())) } "multipart/form-data" => build_multipart_body(&body, &headers).await?, - _ if body.contains_key("text") => (build_text_body(&body), None), + _ if body.contains_key("text") => (build_text_body(&body, body_type), None), t => { warn!("Unsupported body type: {}", t); (None, None) @@ -252,13 +306,20 @@ async fn build_binary_body( })) } -fn build_text_body(body: &BTreeMap) -> Option { +fn build_text_body(body: &BTreeMap, body_type: &str) -> Option { let text = get_str_map(body, "text"); if text.is_empty() { - None - } else { - Some(SendableBodyWithMeta::Bytes(Bytes::from(text.to_string()))) + return None; } + + let send_comments = get_bool_map(body, "sendJsonComments", false); + let text = if !send_comments && body_type == "application/json" { + maybe_strip_json_comments(text) + } else { + text.to_string() + }; + + Some(SendableBodyWithMeta::Bytes(Bytes::from(text))) } fn build_graphql_body( @@ -266,7 +327,7 @@ fn build_graphql_body( body: &BTreeMap, ) -> Option { let query = get_str_map(body, "query"); - let variables = get_str_map(body, "variables"); + let variables = strip_json_comments(&get_str_map(body, "variables")); if method.to_lowercase() == "get" { // GraphQL GET requests use query parameters, not a body @@ -684,7 +745,7 @@ mod tests { let mut body = BTreeMap::new(); body.insert("text".to_string(), json!("Hello, World!")); - let result = build_text_body(&body); + let result = build_text_body(&body, "application/json"); match result { Some(SendableBodyWithMeta::Bytes(bytes)) => { assert_eq!(bytes, Bytes::from("Hello, World!")) @@ -698,7 +759,7 @@ mod tests { let mut body = BTreeMap::new(); body.insert("text".to_string(), json!("")); - let result = build_text_body(&body); + let result = build_text_body(&body, "application/json"); assert!(result.is_none()); } @@ -706,10 +767,57 @@ mod tests { async fn test_text_body_missing() { let body = BTreeMap::new(); - let result = build_text_body(&body); + let result = build_text_body(&body, "application/json"); assert!(result.is_none()); } + #[tokio::test] + async fn test_text_body_strips_json_comments_by_default() { + let mut body = BTreeMap::new(); + body.insert("text".to_string(), json!("{\n // comment\n \"foo\": \"bar\"\n}")); + + let result = build_text_body(&body, "application/json"); + match result { + Some(SendableBodyWithMeta::Bytes(bytes)) => { + let text = String::from_utf8_lossy(&bytes); + assert!(!text.contains("// comment")); + assert!(text.contains("\"foo\": \"bar\"")); + } + _ => panic!("Expected Some(SendableBody::Bytes)"), + } + } + + #[tokio::test] + async fn test_text_body_send_json_comments_when_opted_in() { + let mut body = BTreeMap::new(); + body.insert("text".to_string(), json!("{\n // comment\n \"foo\": \"bar\"\n}")); + body.insert("sendJsonComments".to_string(), json!(true)); + + let result = build_text_body(&body, "application/json"); + match result { + Some(SendableBodyWithMeta::Bytes(bytes)) => { + let text = String::from_utf8_lossy(&bytes); + assert!(text.contains("// comment")); + } + _ => panic!("Expected Some(SendableBody::Bytes)"), + } + } + + #[tokio::test] + async fn test_text_body_no_strip_for_non_json() { + let mut body = BTreeMap::new(); + body.insert("text".to_string(), json!("// not json\nsome text")); + + let result = build_text_body(&body, "text/plain"); + match result { + Some(SendableBodyWithMeta::Bytes(bytes)) => { + let text = String::from_utf8_lossy(&bytes); + assert!(text.contains("// not json")); + } + _ => panic!("Expected Some(SendableBody::Bytes)"), + } + } + #[tokio::test] async fn test_form_urlencoded_body() -> Result<()> { let mut body = BTreeMap::new(); diff --git a/crates/yaak-templates/src/format_json.rs b/crates/yaak-templates/src/format_json.rs index 1a7b5cd4..98f1a90e 100644 --- a/crates/yaak-templates/src/format_json.rs +++ b/crates/yaak-templates/src/format_json.rs @@ -11,6 +11,7 @@ pub fn format_json(text: &str, tab: &str) -> String { let mut new_json = "".to_string(); let mut depth = 0; let mut state = FormatState::None; + let mut saw_newline_in_whitespace = false; loop { let rest_of_chars = chars.clone(); @@ -61,6 +62,62 @@ pub fn format_json(text: &str, tab: &str) -> String { continue; } + // Handle line comments (//) + if current_char == '/' && chars.peek() == Some(&'/') { + chars.next(); // Skip second / + // Collect the rest of the comment until newline + let mut comment = String::from("//"); + loop { + match chars.peek() { + Some(&'\n') | None => break, + Some(_) => comment.push(chars.next().unwrap()), + } + } + // Check if the comma handler already added \n + indent + let trimmed = new_json.trim_end_matches(|c: char| c == ' ' || c == '\t'); + if trimmed.ends_with(",\n") && !saw_newline_in_whitespace { + // Trailing comment on the same line as comma (e.g. "foo",// comment) + new_json.truncate(trimmed.len() - 1); + new_json.push(' '); + } else if !trimmed.ends_with('\n') && !new_json.is_empty() { + // Trailing comment after a value (no newline before us) + new_json.push(' '); + } + new_json.push_str(&comment); + new_json.push('\n'); + new_json.push_str(tab.to_string().repeat(depth).as_str()); + saw_newline_in_whitespace = false; + continue; + } + + // Handle block comments (/* ... */) + if current_char == '/' && chars.peek() == Some(&'*') { + chars.next(); // Skip * + let mut comment = String::from("/*"); + loop { + match chars.next() { + None => break, + Some('*') if chars.peek() == Some(&'/') => { + chars.next(); // Skip / + comment.push_str("*/"); + break; + } + Some(c) => comment.push(c), + } + } + // If we're not already on a fresh line, add newline + indent before comment + let trimmed = new_json.trim_end_matches(|c: char| c == ' ' || c == '\t'); + if !trimmed.is_empty() && !trimmed.ends_with('\n') { + new_json.push('\n'); + new_json.push_str(tab.to_string().repeat(depth).as_str()); + } + new_json.push_str(&comment); + // After block comment, add newline + indent for the next content + new_json.push('\n'); + new_json.push_str(tab.to_string().repeat(depth).as_str()); + continue; + } + match current_char { ',' => { new_json.push(current_char); @@ -125,20 +182,37 @@ pub fn format_json(text: &str, tab: &str) -> String { || current_char == '\t' || current_char == '\r' { + if current_char == '\n' { + saw_newline_in_whitespace = true; + } // Don't add these } else { + saw_newline_in_whitespace = false; new_json.push(current_char); } } } } - // Replace only lines containing whitespace with nothing - new_json - .lines() - .filter(|line| !line.trim().is_empty()) // Filter out whitespace-only lines - .collect::>() // Collect the non-empty lines into a vector - .join("\n") // Join the lines back into a single string + // Filter out whitespace-only lines, but preserve empty lines inside block comments + let mut result_lines: Vec<&str> = Vec::new(); + let mut in_block_comment = false; + for line in new_json.lines() { + if in_block_comment { + result_lines.push(line); + if line.contains("*/") { + in_block_comment = false; + } + } else { + if line.contains("/*") && !line.contains("*/") { + in_block_comment = true; + } + if !line.trim().is_empty() { + result_lines.push(line); + } + } + } + result_lines.iter().map(|line| line.trim_end()).collect::>().join("\n") } #[cfg(test)] @@ -297,6 +371,161 @@ mod tests { r#" {} } +"# + .trim() + ); + } + + #[test] + fn test_line_comment_between_keys() { + assert_eq!( + format_json( + r#"{"foo":"bar",// a comment +"baz":"qux"}"#, + " " + ), + r#" +{ + "foo": "bar", // a comment + "baz": "qux" +} +"# + .trim() + ); + } + + #[test] + fn test_line_comment_at_end() { + assert_eq!( + format_json( + r#"{"foo":"bar" // trailing +}"#, + " " + ), + r#" +{ + "foo": "bar" // trailing +} +"# + .trim() + ); + } + + #[test] + fn test_block_comment() { + assert_eq!( + format_json(r#"{"foo":"bar",/* comment */"baz":"qux"}"#, " "), + r#" +{ + "foo": "bar", + /* comment */ + "baz": "qux" +} +"# + .trim() + ); + } + + #[test] + fn test_comment_in_array() { + assert_eq!( + format_json( + r#"[1,// item comment +2,3]"#, + " " + ), + r#" +[ + 1, // item comment + 2, + 3 +] +"# + .trim() + ); + } + + #[test] + fn test_comment_only_line() { + assert_eq!( + format_json( + r#"{ + // this is a standalone comment + "foo": "bar" +}"#, + " " + ), + r#" +{ + // this is a standalone comment + "foo": "bar" +} +"# + .trim() + ); + } + + #[test] + fn test_multiline_block_comment() { + assert_eq!( + format_json( + r#"{ + "foo": "bar" + /** + Hello World! + + Hi there + */ +}"#, + " " + ), + r#" +{ + "foo": "bar" + /** + Hello World! + + Hi there + */ +} +"# + .trim() + ); + } + + // NOTE: trailing whitespace on output lines is trimmed by the formatter. + // We can't easily add a test for this because raw string literals get + // trailing whitespace stripped by the editor/linter. + + #[test] + fn test_comment_inside_string_ignored() { + assert_eq!( + format_json(r#"{"foo":"// not a comment","bar":"/* also not */"}"#, " "), + r#" +{ + "foo": "// not a comment", + "bar": "/* also not */" +} +"# + .trim() + ); + } + + #[test] + fn test_comment_on_line_after_comma() { + assert_eq!( + format_json( + r#"{ + "a": "aaa", + // "b": "bbb" +}"#, + " " + ), + r#" +{ + "a": "aaa", + // "b": "bbb" +} "# .trim() ); diff --git a/crates/yaak-templates/src/lib.rs b/crates/yaak-templates/src/lib.rs index 93708b66..db5c4e14 100644 --- a/crates/yaak-templates/src/lib.rs +++ b/crates/yaak-templates/src/lib.rs @@ -1,6 +1,7 @@ pub mod error; pub mod escape; pub mod format_json; +pub mod strip_json_comments; pub mod parser; pub mod renderer; pub mod wasm; diff --git a/crates/yaak-templates/src/strip_json_comments.rs b/crates/yaak-templates/src/strip_json_comments.rs new file mode 100644 index 00000000..ade19c8c --- /dev/null +++ b/crates/yaak-templates/src/strip_json_comments.rs @@ -0,0 +1,318 @@ +/// Strips JSON comments only if the result is valid JSON. If stripping comments +/// produces invalid JSON, the original text is returned unchanged. +pub fn maybe_strip_json_comments(text: &str) -> String { + let stripped = strip_json_comments(text); + if serde_json::from_str::(&stripped).is_ok() { + stripped + } else { + text.to_string() + } +} + +/// Strips comments from JSONC, preserving the original formatting as much as possible. +/// +/// - Trailing comments on a line are removed (along with preceding whitespace) +/// - Whole-line comments are removed, including the line itself +/// - Block comments are removed, including any lines that become empty +/// - Comments inside strings and template tags are left alone +pub fn strip_json_comments(text: &str) -> String { + let mut chars = text.chars().peekable(); + let mut result = String::with_capacity(text.len()); + let mut in_string = false; + let mut in_template_tag = false; + + loop { + let current_char = match chars.next() { + None => break, + Some(c) => c, + }; + + // Handle JSON strings + if in_string { + result.push(current_char); + match current_char { + '"' => in_string = false, + '\\' => { + if let Some(c) = chars.next() { + result.push(c); + } + } + _ => {} + } + continue; + } + + // Handle template tags + if in_template_tag { + result.push(current_char); + if current_char == ']' && chars.peek() == Some(&'}') { + result.push(chars.next().unwrap()); + in_template_tag = false; + } + continue; + } + + // Check for template tag start + if current_char == '$' && chars.peek() == Some(&'{') { + let mut lookahead = chars.clone(); + lookahead.next(); // skip { + if lookahead.peek() == Some(&'[') { + in_template_tag = true; + result.push(current_char); + result.push(chars.next().unwrap()); // { + result.push(chars.next().unwrap()); // [ + continue; + } + } + + // Check for line comment + if current_char == '/' && chars.peek() == Some(&'/') { + chars.next(); // skip second / + // Consume until newline + loop { + match chars.peek() { + Some(&'\n') | None => break, + Some(_) => { + chars.next(); + } + } + } + // Trim trailing whitespace that preceded the comment + let trimmed_len = result.trim_end_matches(|c: char| c == ' ' || c == '\t').len(); + result.truncate(trimmed_len); + continue; + } + + // Check for block comment + if current_char == '/' && chars.peek() == Some(&'*') { + chars.next(); // skip * + // Consume until */ + loop { + match chars.next() { + None => break, + Some('*') if chars.peek() == Some(&'/') => { + chars.next(); // skip / + break; + } + Some(_) => {} + } + } + // Trim trailing whitespace that preceded the comment + let trimmed_len = result.trim_end_matches(|c: char| c == ' ' || c == '\t').len(); + result.truncate(trimmed_len); + // Skip whitespace/newline after the block comment if the next line is content + // (this handles the case where the block comment is on its own line) + continue; + } + + if current_char == '"' { + in_string = true; + } + + result.push(current_char); + } + + // Remove lines that are now empty (were comment-only lines) + let result = result + .lines() + .filter(|line| !line.trim().is_empty()) + .collect::>() + .join("\n"); + + // Remove trailing commas before } or ] + strip_trailing_commas(&result) +} + +/// Removes trailing commas before closing braces/brackets, respecting strings. +fn strip_trailing_commas(text: &str) -> String { + let mut result = String::with_capacity(text.len()); + let chars: Vec = text.chars().collect(); + let mut i = 0; + let mut in_string = false; + + while i < chars.len() { + let ch = chars[i]; + + if in_string { + result.push(ch); + match ch { + '"' => in_string = false, + '\\' => { + i += 1; + if i < chars.len() { + result.push(chars[i]); + } + } + _ => {} + } + i += 1; + continue; + } + + if ch == '"' { + in_string = true; + result.push(ch); + i += 1; + continue; + } + + if ch == ',' { + // Look ahead past whitespace/newlines for } or ] + let mut j = i + 1; + while j < chars.len() && chars[j].is_whitespace() { + j += 1; + } + if j < chars.len() && (chars[j] == '}' || chars[j] == ']') { + // Skip the comma + i += 1; + continue; + } + } + + result.push(ch); + i += 1; + } + + result +} + +#[cfg(test)] +mod tests { + use crate::strip_json_comments::strip_json_comments; + + #[test] + fn test_no_comments() { + let input = r#"{ + "foo": "bar", + "baz": 123 +}"#; + assert_eq!(strip_json_comments(input), input); + } + + #[test] + fn test_trailing_line_comment() { + assert_eq!( + strip_json_comments(r#"{ + "foo": "bar", // this is a comment + "baz": 123 +}"#), + r#"{ + "foo": "bar", + "baz": 123 +}"# + ); + } + + #[test] + fn test_whole_line_comment() { + assert_eq!( + strip_json_comments(r#"{ + // this is a comment + "foo": "bar" +}"#), + r#"{ + "foo": "bar" +}"# + ); + } + + #[test] + fn test_inline_block_comment() { + assert_eq!( + strip_json_comments(r#"{ + "foo": /* a comment */ "bar" +}"#), + r#"{ + "foo": "bar" +}"# + ); + } + + #[test] + fn test_whole_line_block_comment() { + assert_eq!( + strip_json_comments(r#"{ + /* a comment */ + "foo": "bar" +}"#), + r#"{ + "foo": "bar" +}"# + ); + } + + #[test] + fn test_multiline_block_comment() { + assert_eq!( + strip_json_comments(r#"{ + /** + * Hello World! + */ + "foo": "bar" +}"#), + r#"{ + "foo": "bar" +}"# + ); + } + + #[test] + fn test_comment_inside_string_preserved() { + let input = r#"{ + "foo": "// not a comment", + "bar": "/* also not */" +}"#; + assert_eq!(strip_json_comments(input), input); + } + + #[test] + fn test_comment_inside_template_tag_preserved() { + let input = r#"{ + "foo": ${[ fn("// hi", "/* hey */") ]} +}"#; + assert_eq!(strip_json_comments(input), input); + } + + #[test] + fn test_multiple_comments() { + assert_eq!( + strip_json_comments(r#"{ + // first comment + "foo": "bar", // trailing + /* block */ + "baz": 123 +}"#), + r#"{ + "foo": "bar", + "baz": 123 +}"# + ); + } + + #[test] + fn test_trailing_comma_after_comment_removed() { + assert_eq!( + strip_json_comments(r#"{ + "a": "aaa", + // "b": "bbb" +}"#), + r#"{ + "a": "aaa" +}"# + ); + } + + #[test] + fn test_trailing_comma_in_array() { + assert_eq!( + strip_json_comments(r#"[1, 2, /* 3 */]"#), + r#"[1, 2]"# + ); + } + + #[test] + fn test_comma_inside_string_preserved() { + let input = r#"{"a": "hello,}"#; + assert_eq!(strip_json_comments(input), input); + } +} diff --git a/crates/yaak/src/render.rs b/crates/yaak/src/render.rs index 64b5e04e..75015ae7 100644 --- a/crates/yaak/src/render.rs +++ b/crates/yaak/src/render.rs @@ -2,7 +2,7 @@ use log::info; use serde_json::Value; use std::collections::BTreeMap; use yaak_http::path_placeholders::apply_path_placeholders; -use yaak_models::models::{Environment, HttpRequest, HttpRequestHeader, HttpUrlParameter}; +use yaak_models::models::{Environment, GrpcRequest, HttpRequest, HttpRequestHeader, HttpUrlParameter}; use yaak_models::render::make_vars_hashmap; use yaak_templates::{RenderOptions, TemplateCallback, parse_and_render, render_json_value_raw}; @@ -89,6 +89,64 @@ pub async fn render_http_request( Ok(HttpRequest { url, url_parameters, headers, body, authentication, ..request.to_owned() }) } +pub async fn render_grpc_request( + r: &GrpcRequest, + environment_chain: Vec, + cb: &T, + opt: &RenderOptions, +) -> yaak_templates::error::Result { + let vars = &make_vars_hashmap(environment_chain); + + let mut metadata = Vec::new(); + for p in r.metadata.clone() { + if !p.enabled { + continue; + } + metadata.push(HttpRequestHeader { + enabled: p.enabled, + name: parse_and_render(p.name.as_str(), vars, cb, opt).await?, + value: parse_and_render(p.value.as_str(), vars, cb, opt).await?, + id: p.id, + }) + } + + let authentication = { + let mut disabled = false; + let mut auth = BTreeMap::new(); + match r.authentication.get("disabled") { + Some(Value::Bool(true)) => { + disabled = true; + } + Some(Value::String(tmpl)) => { + disabled = parse_and_render(tmpl.as_str(), vars, cb, opt) + .await + .unwrap_or_default() + .is_empty(); + info!( + "Rendering authentication.disabled as a template: {disabled} from \"{tmpl}\"" + ); + } + _ => {} + } + if disabled { + auth.insert("disabled".to_string(), Value::Bool(true)); + } else { + for (k, v) in r.authentication.clone() { + if k == "disabled" { + auth.insert(k, Value::Bool(false)); + } else { + auth.insert(k, render_json_value_raw(v, vars, cb, opt).await?); + } + } + } + auth + }; + + let url = parse_and_render(r.url.as_str(), vars, cb, opt).await?; + + Ok(GrpcRequest { url, metadata, authentication, ..r.to_owned() }) +} + fn strip_disabled_form_entries(v: Value) -> Value { match v { Value::Array(items) => Value::Array( diff --git a/package-lock.json b/package-lock.json index 27715ed7..938c3f65 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2584,6 +2584,16 @@ "ebnf": "^1.9.1" } }, + "node_modules/@shopify/lang-jsonc": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@shopify/lang-jsonc/-/lang-jsonc-1.0.1.tgz", + "integrity": "sha512-KrBrRFhvr1qJiZBODAtqbL1u1e67UR3plBN79Z8nd5TQAAzpx66jS4zs7Ss9M22ygGrpWFhyhSoNVlp5VCYktQ==", + "license": "MIT", + "dependencies": { + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.7" + } + }, "node_modules/@svgr/babel-plugin-add-jsx-attribute": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz", @@ -16323,6 +16333,7 @@ "@replit/codemirror-emacs": "^6.1.0", "@replit/codemirror-vim": "^6.3.0", "@replit/codemirror-vscode-keymap": "^6.0.2", + "@shopify/lang-jsonc": "^1.0.1", "@tanstack/react-query": "^5.90.5", "@tanstack/react-router": "^1.133.13", "@tanstack/react-virtual": "^3.13.12", diff --git a/src-web/components/GrpcEditor.tsx b/src-web/components/GrpcEditor.tsx index 4a98582a..6727e8f6 100644 --- a/src-web/components/GrpcEditor.tsx +++ b/src-web/components/GrpcEditor.tsx @@ -1,4 +1,4 @@ -import { jsonLanguage } from '@codemirror/lang-json'; +import { jsoncLanguage } from '@shopify/lang-jsonc'; import { linter } from '@codemirror/lint'; import type { EditorView } from '@codemirror/view'; import type { GrpcRequest } from '@yaakapp-internal/models'; @@ -115,7 +115,7 @@ export function GrpcEditor({ delay: 200, needsRefresh: handleRefresh, }), - jsonLanguage.data.of({ + jsoncLanguage.data.of({ autocomplete: jsonCompletion(), }), stateExtensions({}), diff --git a/src-web/components/HttpRequestPane.tsx b/src-web/components/HttpRequestPane.tsx index 70300900..75f857b3 100644 --- a/src-web/components/HttpRequestPane.tsx +++ b/src-web/components/HttpRequestPane.tsx @@ -48,6 +48,7 @@ import { FormMultipartEditor } from './FormMultipartEditor'; import { FormUrlencodedEditor } from './FormUrlencodedEditor'; import { HeadersEditor } from './HeadersEditor'; import { HttpAuthenticationEditor } from './HttpAuthenticationEditor'; +import { JsonBodyEditor } from './JsonBodyEditor'; import { MarkdownEditor } from './MarkdownEditor'; import { RequestMethodDropdown } from './RequestMethodDropdown'; import { UrlBar } from './UrlBar'; @@ -257,7 +258,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }: ); const handleBodyTextChange = useCallback( - (text: string) => patchModel(activeRequest, { body: { text } }), + (text: string) => patchModel(activeRequest, { body: { ...activeRequest.body, text } }), [activeRequest], ); @@ -370,16 +371,10 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }: {activeRequest.bodyType === BODY_TYPE_JSON ? ( - ) : activeRequest.bodyType === BODY_TYPE_XML ? ( patchModel(request, { body: { ...request.body, text } }), + [request], + ); + + const autoFix = request.body?.sendJsonComments !== true; + + const lintExtension = useMemo( + () => + linter( + jsonParseLinter( + autoFix + ? { allowComments: true, allowTrailingCommas: true } + : { allowComments: false, allowTrailingCommas: false }, + ), + ), + [autoFix], + ); + + const hasComments = useMemo( + () => textLikelyContainsJsonComments(request.body?.text ?? ''), + [request.body?.text], + ); + + const { value: bannerDismissed, set: setBannerDismissed } = useKeyValue({ + namespace: 'no_sync', + key: ['json-fix-3', request.workspaceId], + fallback: false, + }); + + const handleToggleAutoFix = useCallback(() => { + const newBody = { ...request.body }; + if (autoFix) { + newBody.sendJsonComments = true; + } else { + delete newBody.sendJsonComments; + } + patchModel(request, { body: newBody }); + }, [request, autoFix]); + + const handleDropdownOpen = useCallback(() => { + if (!bannerDismissed) { + setBannerDismissed(true); + } + }, [bannerDismissed, setBannerDismissed]); + + const showBanner = hasComments && autoFix && !bannerDismissed; + + const stripMessage = 'Automatically strip comments and trailing commas before sending'; + const actions = useMemo( + () => [ + showBanner && ( + +

+ Auto-fix enabled + +

+
+ ), +
+ , + leftSlot: ( + + ), + }, + ] satisfies DropdownItem[] + } + > + + +
, + ], + [handleDropdownOpen, handleToggleAutoFix, autoFix, showBanner], + ); + + return ( + + ); +} diff --git a/src-web/components/core/Editor/Editor.tsx b/src-web/components/core/Editor/Editor.tsx index 829211fe..f5d45794 100644 --- a/src-web/components/core/Editor/Editor.tsx +++ b/src-web/components/core/Editor/Editor.tsx @@ -78,6 +78,7 @@ export interface EditorProps { hideGutter?: boolean; id?: string; language?: EditorLanguage | 'pairs' | 'url' | 'timeline' | null; + lintExtension?: Extension; graphQLSchema?: GraphQLSchema | null; onBlur?: () => void; onChange?: (value: string) => void; @@ -124,6 +125,7 @@ function EditorInner({ hideGutter, graphQLSchema, language, + lintExtension, onBlur, onChange, onFocus, @@ -332,6 +334,7 @@ function EditorInner({ const ext = getLanguageExtension({ useTemplating, language, + lintExtension, hideGutter, environmentVariables, autocomplete, @@ -344,6 +347,7 @@ function EditorInner({ view.dispatch({ effects: languageCompartment.reconfigure(ext) }); }, [ language, + lintExtension, autocomplete, environmentVariables, onClickFunction, @@ -371,6 +375,7 @@ function EditorInner({ const langExt = getLanguageExtension({ useTemplating, language, + lintExtension, completionOptions, autocomplete, environmentVariables, diff --git a/src-web/components/core/Editor/extensions.ts b/src-web/components/core/Editor/extensions.ts index 1ec1eb07..8db6750d 100644 --- a/src-web/components/core/Editor/extensions.ts +++ b/src-web/components/core/Editor/extensions.ts @@ -8,7 +8,6 @@ import { history, historyKeymap } from '@codemirror/commands'; import { go } from '@codemirror/lang-go'; import { java } from '@codemirror/lang-java'; import { javascript } from '@codemirror/lang-javascript'; -import { json } from '@codemirror/lang-json'; import { markdown } from '@codemirror/lang-markdown'; import { php } from '@codemirror/lang-php'; import { python } from '@codemirror/lang-python'; @@ -34,7 +33,6 @@ import { ruby } from '@codemirror/legacy-modes/mode/ruby'; import { shell } from '@codemirror/legacy-modes/mode/shell'; import { swift } from '@codemirror/legacy-modes/mode/swift'; import { linter, lintGutter, lintKeymap } from '@codemirror/lint'; - import { search, searchKeymap } from '@codemirror/search'; import type { Extension } from '@codemirror/state'; import { EditorState } from '@codemirror/state'; @@ -50,6 +48,7 @@ import { rectangularSelection, } from '@codemirror/view'; import { tags as t } from '@lezer/highlight'; +import { jsonc, jsoncLanguage } from '@shopify/lang-jsonc'; import { graphql } from 'cm6-graphql'; import type { GraphQLSchema } from 'graphql'; import { activeRequestIdAtom } from '../../../hooks/useActiveRequestId'; @@ -61,13 +60,13 @@ import { showGraphQLDocExplorerAtom } from '../../graphql/graphqlAtoms'; import type { EditorProps } from './Editor'; import { jsonParseLinter } from './json-lint'; import { pairs } from './pairs/extension'; +import { searchMatchCount } from './searchMatchCount'; import { text } from './text/extension'; import { timeline } from './timeline/extension'; import type { TwigCompletionOption } from './twig/completion'; import { twig } from './twig/extension'; import { pathParametersPlugin } from './twig/pathParameters'; import { url } from './url/extension'; -import { searchMatchCount } from './searchMatchCount'; export const syntaxHighlightStyle = HighlightStyle.define([ { @@ -107,7 +106,7 @@ const syntaxExtensions: Record< null | (() => LanguageSupport) > = { graphql: null, - json: json, + json: jsonc, javascript: javascript, // HTML as XML because HTML is oddly slow html: xml, @@ -140,6 +139,7 @@ const closeBracketsFor: (keyof typeof syntaxExtensions)[] = ['json', 'javascript export function getLanguageExtension({ useTemplating, language = 'text', + lintExtension, environmentVariables, autocomplete, hideGutter, @@ -156,7 +156,7 @@ export function getLanguageExtension({ onClickPathParameter: (name: string) => void; completionOptions: TwigCompletionOption[]; graphQLSchema: GraphQLSchema | null; -} & Pick) { +} & Pick) { const extraExtensions: Extension[] = []; if (language === 'url') { @@ -193,7 +193,12 @@ export function getLanguageExtension({ } if (language === 'json') { - extraExtensions.push(linter(jsonParseLinter())); + extraExtensions.push(lintExtension ?? linter(jsonParseLinter())); + extraExtensions.push( + jsoncLanguage.data.of({ + commentTokens: { line: '//', block: { open: '/*', close: '*/' } }, + }), + ); if (!hideGutter) { extraExtensions.push(lintGutter()); } diff --git a/src-web/components/core/Editor/json-lint.ts b/src-web/components/core/Editor/json-lint.ts index f4b080dd..32652f15 100644 --- a/src-web/components/core/Editor/json-lint.ts +++ b/src-web/components/core/Editor/json-lint.ts @@ -4,14 +4,22 @@ import { parse as jsonLintParse } from '@prantlf/jsonlint'; const TEMPLATE_SYNTAX_REGEX = /\$\{\[[\s\S]*?]}/g; -export function jsonParseLinter() { +interface JsonLintOptions { + allowComments?: boolean; + allowTrailingCommas?: boolean; +} + +export function jsonParseLinter(options?: JsonLintOptions) { return (view: EditorView): Diagnostic[] => { try { const doc = view.state.doc.toString(); // We need lint to not break on stuff like {"foo:" ${[ ... ]}} so we'll replace all template // syntax with repeating `1` characters, so it's valid JSON and the position is still correct. const escapedDoc = doc.replace(TEMPLATE_SYNTAX_REGEX, (m) => '1'.repeat(m.length)); - jsonLintParse(escapedDoc); + jsonLintParse(escapedDoc, { + mode: (options?.allowComments ?? true) ? 'cjson' : 'json', + ignoreTrailingCommas: options?.allowTrailingCommas ?? false, + }); // biome-ignore lint/suspicious/noExplicitAny: none } catch (err: any) { if (!('location' in err)) { diff --git a/src-web/components/graphql/GraphQLEditor.tsx b/src-web/components/graphql/GraphQLEditor.tsx index 11b3c879..1db31b0a 100644 --- a/src-web/components/graphql/GraphQLEditor.tsx +++ b/src-web/components/graphql/GraphQLEditor.tsx @@ -156,6 +156,7 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp { type: 'separator', label: 'Setting' }, { label: 'Automatic Introspection', + keepOpenOnSelect: true, onSelect: () => { setAutoIntrospectDisabled({ ...autoIntrospectDisabled, diff --git a/src-web/lib/jsonComments.ts b/src-web/lib/jsonComments.ts new file mode 100644 index 00000000..3c308955 --- /dev/null +++ b/src-web/lib/jsonComments.ts @@ -0,0 +1,30 @@ +/** + * Simple heuristic to detect if a string likely contains JSON/JSONC comments. + * Checks for // and /* patterns that are NOT inside double-quoted strings. + * Used for UI hints only — doesn't need to be perfect. + */ +export function textLikelyContainsJsonComments(text: string): boolean { + let inString = false; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (inString) { + if (ch === '"') { + inString = false; + } else if (ch === '\\') { + i++; // skip escaped char + } + continue; + } + if (ch === '"') { + inString = true; + continue; + } + if (ch === '/' && i + 1 < text.length) { + const next = text[i + 1]; + if (next === '/' || next === '*') { + return true; + } + } + } + return false; +} diff --git a/src-web/package.json b/src-web/package.json index 3f360cbc..45d42a59 100644 --- a/src-web/package.json +++ b/src-web/package.json @@ -27,6 +27,7 @@ "@replit/codemirror-emacs": "^6.1.0", "@replit/codemirror-vim": "^6.3.0", "@replit/codemirror-vscode-keymap": "^6.0.2", + "@shopify/lang-jsonc": "^1.0.1", "@tanstack/react-query": "^5.90.5", "@tanstack/react-router": "^1.133.13", "@tanstack/react-virtual": "^3.13.12",