mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-07-15 01:02:54 +02:00
Support comments in JSON body (#419)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Generated
+1
@@ -10399,6 +10399,7 @@ dependencies = [
|
|||||||
"urlencoding",
|
"urlencoding",
|
||||||
"yaak-common",
|
"yaak-common",
|
||||||
"yaak-models",
|
"yaak-models",
|
||||||
|
"yaak-templates",
|
||||||
"yaak-tls",
|
"yaak-tls",
|
||||||
"zstd",
|
"zstd",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use arboard::Clipboard;
|
|||||||
use console::Term;
|
use console::Term;
|
||||||
use inquire::{Confirm, Editor, Password, PasswordDisplayMode, Select, Text};
|
use inquire::{Confirm, Editor, Password, PasswordDisplayMode, Select, Text};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::collections::{BTreeMap, HashMap};
|
use std::collections::HashMap;
|
||||||
use std::io::IsTerminal;
|
use std::io::IsTerminal;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -11,11 +11,11 @@ use tokio::task::JoinHandle;
|
|||||||
use yaak::plugin_events::{
|
use yaak::plugin_events::{
|
||||||
GroupedPluginEvent, HostRequest, SharedPluginEventContext, handle_shared_plugin_event,
|
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::send::{SendHttpRequestWithPluginsParams, send_http_request_with_plugins};
|
||||||
use yaak_crypto::manager::EncryptionManager;
|
use yaak_crypto::manager::EncryptionManager;
|
||||||
use yaak_models::blob_manager::BlobManager;
|
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::queries::any_request::AnyRequest;
|
||||||
use yaak_models::query_manager::QueryManager;
|
use yaak_models::query_manager::QueryManager;
|
||||||
use yaak_models::render::make_vars_hashmap;
|
use yaak_models::render::make_vars_hashmap;
|
||||||
@@ -29,7 +29,7 @@ use yaak_plugins::events::{
|
|||||||
};
|
};
|
||||||
use yaak_plugins::manager::PluginManager;
|
use yaak_plugins::manager::PluginManager;
|
||||||
use yaak_plugins::template_callback::PluginTemplateCallback;
|
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 {
|
pub struct CliPluginEventBridge {
|
||||||
rx_id: String,
|
rx_id: String,
|
||||||
@@ -269,7 +269,7 @@ async fn build_plugin_reply(
|
|||||||
);
|
);
|
||||||
let render_options = RenderOptions::throw();
|
let render_options = RenderOptions::throw();
|
||||||
|
|
||||||
match render_grpc_request_for_cli(
|
match render_grpc_request(
|
||||||
&grpc_request,
|
&grpc_request,
|
||||||
environment_chain,
|
environment_chain,
|
||||||
&template_callback,
|
&template_callback,
|
||||||
@@ -532,60 +532,6 @@ async fn render_json_value_for_cli<T: TemplateCallback>(
|
|||||||
render_json_value_raw(value, vars, cb, opt).await
|
render_json_value_raw(value, vars, cb, opt).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn render_grpc_request_for_cli<T: TemplateCallback>(
|
|
||||||
grpc_request: &GrpcRequest,
|
|
||||||
environment_chain: Vec<Environment>,
|
|
||||||
cb: &T,
|
|
||||||
opt: &RenderOptions,
|
|
||||||
) -> yaak_templates::error::Result<GrpcRequest> {
|
|
||||||
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)> {
|
fn parse_cookie_name_value(raw_cookie: &str) -> Option<(String, String)> {
|
||||||
let first_part = raw_cookie.split(';').next()?.trim();
|
let first_part = raw_cookie.split(';').next()?.trim();
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ use tokio::time;
|
|||||||
use yaak_common::command::new_checked_command;
|
use yaak_common::command::new_checked_command;
|
||||||
use yaak_crypto::manager::EncryptionManager;
|
use yaak_crypto::manager::EncryptionManager;
|
||||||
use yaak_grpc::manager::{GrpcConfig, GrpcHandle};
|
use yaak_grpc::manager::{GrpcConfig, GrpcHandle};
|
||||||
|
use yaak_templates::strip_json_comments::strip_json_comments;
|
||||||
use yaak_grpc::{Code, ServiceDefinition, serialize_message};
|
use yaak_grpc::{Code, ServiceDefinition, serialize_message};
|
||||||
use yaak_mac_window::AppHandleMacWindowExt;
|
use yaak_mac_window::AppHandleMacWindowExt;
|
||||||
use yaak_models::models::{
|
use yaak_models::models::{
|
||||||
@@ -433,6 +434,7 @@ async fn cmd_grpc_go<R: Runtime>(
|
|||||||
result.expect("Failed to render template")
|
result.expect("Failed to render template")
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
let msg = strip_json_comments(&msg);
|
||||||
in_msg_tx.try_send(msg.clone()).unwrap();
|
in_msg_tx.try_send(msg.clone()).unwrap();
|
||||||
}
|
}
|
||||||
Ok(IncomingMsg::Commit) => {
|
Ok(IncomingMsg::Commit) => {
|
||||||
@@ -468,6 +470,7 @@ async fn cmd_grpc_go<R: Runtime>(
|
|||||||
&RenderOptions { error_behavior: RenderErrorBehavior::Throw },
|
&RenderOptions { error_behavior: RenderErrorBehavior::Throw },
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
let msg = strip_json_comments(&msg);
|
||||||
|
|
||||||
app_handle.db().upsert_grpc_event(
|
app_handle.db().upsert_grpc_event(
|
||||||
&GrpcEvent {
|
&GrpcEvent {
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
use log::info;
|
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::collections::BTreeMap;
|
pub use yaak::render::{render_grpc_request, render_http_request};
|
||||||
pub use yaak::render::render_http_request;
|
use yaak_models::models::Environment;
|
||||||
use yaak_models::models::{Environment, GrpcRequest, HttpRequestHeader};
|
|
||||||
use yaak_models::render::make_vars_hashmap;
|
use yaak_models::render::make_vars_hashmap;
|
||||||
use yaak_templates::{RenderOptions, TemplateCallback, parse_and_render, render_json_value_raw};
|
use yaak_templates::{RenderOptions, TemplateCallback, parse_and_render, render_json_value_raw};
|
||||||
|
|
||||||
@@ -25,61 +23,3 @@ pub async fn render_json_value<T: TemplateCallback>(
|
|||||||
let vars = &make_vars_hashmap(environment_chain);
|
let vars = &make_vars_hashmap(environment_chain);
|
||||||
render_json_value_raw(value, vars, cb, opt).await
|
render_json_value_raw(value, vars, cb, opt).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn render_grpc_request<T: TemplateCallback>(
|
|
||||||
r: &GrpcRequest,
|
|
||||||
environment_chain: Vec<Environment>,
|
|
||||||
cb: &T,
|
|
||||||
opt: &RenderOptions,
|
|
||||||
) -> yaak_templates::error::Result<GrpcRequest> {
|
|
||||||
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() })
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ use yaak_models::util::UpdateSource;
|
|||||||
use yaak_plugins::events::{CallHttpAuthenticationRequest, HttpHeader, RenderPurpose};
|
use yaak_plugins::events::{CallHttpAuthenticationRequest, HttpHeader, RenderPurpose};
|
||||||
use yaak_plugins::manager::PluginManager;
|
use yaak_plugins::manager::PluginManager;
|
||||||
use yaak_plugins::template_callback::PluginTemplateCallback;
|
use yaak_plugins::template_callback::PluginTemplateCallback;
|
||||||
|
use yaak_templates::strip_json_comments::maybe_strip_json_comments;
|
||||||
use yaak_templates::{RenderErrorBehavior, RenderOptions};
|
use yaak_templates::{RenderErrorBehavior, RenderOptions};
|
||||||
use yaak_tls::find_client_certificate;
|
use yaak_tls::find_client_certificate;
|
||||||
use yaak_ws::{WebsocketManager, render_websocket_request};
|
use yaak_ws::{WebsocketManager, render_websocket_request};
|
||||||
@@ -72,8 +73,10 @@ pub async fn cmd_ws_send<R: Runtime>(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
let message = maybe_strip_json_comments(&request.message);
|
||||||
|
|
||||||
let mut ws_manager = ws_manager.lock().await;
|
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(
|
app_handle.db().upsert_websocket_event(
|
||||||
&WebsocketEvent {
|
&WebsocketEvent {
|
||||||
@@ -82,7 +85,7 @@ pub async fn cmd_ws_send<R: Runtime>(
|
|||||||
workspace_id: connection.workspace_id.clone(),
|
workspace_id: connection.workspace_id.clone(),
|
||||||
is_server: false,
|
is_server: false,
|
||||||
message_type: WebsocketEventType::Text,
|
message_type: WebsocketEventType::Text,
|
||||||
message: request.message.into(),
|
message: message.into(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
&UpdateSource::from_window_label(window.label()),
|
&UpdateSource::from_window_label(window.label()),
|
||||||
|
|||||||
@@ -21,3 +21,10 @@ pub fn get_str_map<'a>(v: &'a BTreeMap<String, Value>, key: &str) -> &'a str {
|
|||||||
Some(v) => v.as_str().unwrap_or_default(),
|
Some(v) => v.as_str().unwrap_or_default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_bool_map(v: &BTreeMap<String, Value>, key: &str, fallback: bool) -> bool {
|
||||||
|
match v.get(key) {
|
||||||
|
None => fallback,
|
||||||
|
Some(v) => v.as_bool().unwrap_or(fallback),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,4 +29,5 @@ tower-service = "0.3.3"
|
|||||||
urlencoding = "2.1.3"
|
urlencoding = "2.1.3"
|
||||||
yaak-common = { workspace = true }
|
yaak-common = { workspace = true }
|
||||||
yaak-models = { workspace = true }
|
yaak-models = { workspace = true }
|
||||||
|
yaak-templates = { workspace = true }
|
||||||
yaak-tls = { workspace = true }
|
yaak-tls = { workspace = true }
|
||||||
|
|||||||
+120
-12
@@ -9,8 +9,9 @@ use std::collections::BTreeMap;
|
|||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::io::AsyncRead;
|
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_models::models::HttpRequest;
|
||||||
|
use yaak_templates::strip_json_comments::{maybe_strip_json_comments, strip_json_comments};
|
||||||
|
|
||||||
pub(crate) const MULTIPART_BOUNDARY: &str = "------YaakFormBoundary";
|
pub(crate) const MULTIPART_BOUNDARY: &str = "------YaakFormBoundary";
|
||||||
|
|
||||||
@@ -134,16 +135,69 @@ pub fn append_query_params(url: &str, params: Vec<(String, String)>) -> String {
|
|||||||
result
|
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 {
|
fn build_url(r: &HttpRequest) -> String {
|
||||||
let (url_string, params) = apply_path_placeholders(&ensure_proto(&r.url), &r.url_parameters);
|
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,
|
&url_string,
|
||||||
params
|
params
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|p| p.enabled && !p.name.is_empty())
|
.filter(|p| p.enabled && !p.name.is_empty())
|
||||||
.map(|p| (p.name.clone(), p.value.clone()))
|
.map(|p| (p.name.clone(), p.value.clone()))
|
||||||
.collect(),
|
.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, serde_json::Value>) -> 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)> {
|
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()))
|
(build_form_body(&body), Some("application/x-www-form-urlencoded".to_string()))
|
||||||
}
|
}
|
||||||
"multipart/form-data" => build_multipart_body(&body, &headers).await?,
|
"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 => {
|
t => {
|
||||||
warn!("Unsupported body type: {}", t);
|
warn!("Unsupported body type: {}", t);
|
||||||
(None, None)
|
(None, None)
|
||||||
@@ -252,13 +306,20 @@ async fn build_binary_body(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_text_body(body: &BTreeMap<String, serde_json::Value>) -> Option<SendableBodyWithMeta> {
|
fn build_text_body(body: &BTreeMap<String, serde_json::Value>, body_type: &str) -> Option<SendableBodyWithMeta> {
|
||||||
let text = get_str_map(body, "text");
|
let text = get_str_map(body, "text");
|
||||||
if text.is_empty() {
|
if text.is_empty() {
|
||||||
None
|
return None;
|
||||||
} else {
|
|
||||||
Some(SendableBodyWithMeta::Bytes(Bytes::from(text.to_string())))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
fn build_graphql_body(
|
||||||
@@ -266,7 +327,7 @@ fn build_graphql_body(
|
|||||||
body: &BTreeMap<String, serde_json::Value>,
|
body: &BTreeMap<String, serde_json::Value>,
|
||||||
) -> Option<SendableBodyWithMeta> {
|
) -> Option<SendableBodyWithMeta> {
|
||||||
let query = get_str_map(body, "query");
|
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" {
|
if method.to_lowercase() == "get" {
|
||||||
// GraphQL GET requests use query parameters, not a body
|
// GraphQL GET requests use query parameters, not a body
|
||||||
@@ -684,7 +745,7 @@ mod tests {
|
|||||||
let mut body = BTreeMap::new();
|
let mut body = BTreeMap::new();
|
||||||
body.insert("text".to_string(), json!("Hello, World!"));
|
body.insert("text".to_string(), json!("Hello, World!"));
|
||||||
|
|
||||||
let result = build_text_body(&body);
|
let result = build_text_body(&body, "application/json");
|
||||||
match result {
|
match result {
|
||||||
Some(SendableBodyWithMeta::Bytes(bytes)) => {
|
Some(SendableBodyWithMeta::Bytes(bytes)) => {
|
||||||
assert_eq!(bytes, Bytes::from("Hello, World!"))
|
assert_eq!(bytes, Bytes::from("Hello, World!"))
|
||||||
@@ -698,7 +759,7 @@ mod tests {
|
|||||||
let mut body = BTreeMap::new();
|
let mut body = BTreeMap::new();
|
||||||
body.insert("text".to_string(), json!(""));
|
body.insert("text".to_string(), json!(""));
|
||||||
|
|
||||||
let result = build_text_body(&body);
|
let result = build_text_body(&body, "application/json");
|
||||||
assert!(result.is_none());
|
assert!(result.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -706,10 +767,57 @@ mod tests {
|
|||||||
async fn test_text_body_missing() {
|
async fn test_text_body_missing() {
|
||||||
let body = BTreeMap::new();
|
let body = BTreeMap::new();
|
||||||
|
|
||||||
let result = build_text_body(&body);
|
let result = build_text_body(&body, "application/json");
|
||||||
assert!(result.is_none());
|
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]
|
#[tokio::test]
|
||||||
async fn test_form_urlencoded_body() -> Result<()> {
|
async fn test_form_urlencoded_body() -> Result<()> {
|
||||||
let mut body = BTreeMap::new();
|
let mut body = BTreeMap::new();
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ pub fn format_json(text: &str, tab: &str) -> String {
|
|||||||
let mut new_json = "".to_string();
|
let mut new_json = "".to_string();
|
||||||
let mut depth = 0;
|
let mut depth = 0;
|
||||||
let mut state = FormatState::None;
|
let mut state = FormatState::None;
|
||||||
|
let mut saw_newline_in_whitespace = false;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let rest_of_chars = chars.clone();
|
let rest_of_chars = chars.clone();
|
||||||
@@ -61,6 +62,62 @@ pub fn format_json(text: &str, tab: &str) -> String {
|
|||||||
continue;
|
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 {
|
match current_char {
|
||||||
',' => {
|
',' => {
|
||||||
new_json.push(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 == '\t'
|
||||||
|| current_char == '\r'
|
|| current_char == '\r'
|
||||||
{
|
{
|
||||||
|
if current_char == '\n' {
|
||||||
|
saw_newline_in_whitespace = true;
|
||||||
|
}
|
||||||
// Don't add these
|
// Don't add these
|
||||||
} else {
|
} else {
|
||||||
|
saw_newline_in_whitespace = false;
|
||||||
new_json.push(current_char);
|
new_json.push(current_char);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replace only lines containing whitespace with nothing
|
// Filter out whitespace-only lines, but preserve empty lines inside block comments
|
||||||
new_json
|
let mut result_lines: Vec<&str> = Vec::new();
|
||||||
.lines()
|
let mut in_block_comment = false;
|
||||||
.filter(|line| !line.trim().is_empty()) // Filter out whitespace-only lines
|
for line in new_json.lines() {
|
||||||
.collect::<Vec<&str>>() // Collect the non-empty lines into a vector
|
if in_block_comment {
|
||||||
.join("\n") // Join the lines back into a single string
|
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::<Vec<&str>>().join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -297,6 +371,161 @@ mod tests {
|
|||||||
r#"
|
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()
|
.trim()
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod escape;
|
pub mod escape;
|
||||||
pub mod format_json;
|
pub mod format_json;
|
||||||
|
pub mod strip_json_comments;
|
||||||
pub mod parser;
|
pub mod parser;
|
||||||
pub mod renderer;
|
pub mod renderer;
|
||||||
pub mod wasm;
|
pub mod wasm;
|
||||||
|
|||||||
@@ -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::<serde_json::Value>(&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::<Vec<&str>>()
|
||||||
|
.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<char> = 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ use log::info;
|
|||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use yaak_http::path_placeholders::apply_path_placeholders;
|
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_models::render::make_vars_hashmap;
|
||||||
use yaak_templates::{RenderOptions, TemplateCallback, parse_and_render, render_json_value_raw};
|
use yaak_templates::{RenderOptions, TemplateCallback, parse_and_render, render_json_value_raw};
|
||||||
|
|
||||||
@@ -89,6 +89,64 @@ pub async fn render_http_request<T: TemplateCallback>(
|
|||||||
Ok(HttpRequest { url, url_parameters, headers, body, authentication, ..request.to_owned() })
|
Ok(HttpRequest { url, url_parameters, headers, body, authentication, ..request.to_owned() })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn render_grpc_request<T: TemplateCallback>(
|
||||||
|
r: &GrpcRequest,
|
||||||
|
environment_chain: Vec<Environment>,
|
||||||
|
cb: &T,
|
||||||
|
opt: &RenderOptions,
|
||||||
|
) -> yaak_templates::error::Result<GrpcRequest> {
|
||||||
|
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 {
|
fn strip_disabled_form_entries(v: Value) -> Value {
|
||||||
match v {
|
match v {
|
||||||
Value::Array(items) => Value::Array(
|
Value::Array(items) => Value::Array(
|
||||||
|
|||||||
Generated
+11
@@ -2584,6 +2584,16 @@
|
|||||||
"ebnf": "^1.9.1"
|
"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": {
|
"node_modules/@svgr/babel-plugin-add-jsx-attribute": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz",
|
"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-emacs": "^6.1.0",
|
||||||
"@replit/codemirror-vim": "^6.3.0",
|
"@replit/codemirror-vim": "^6.3.0",
|
||||||
"@replit/codemirror-vscode-keymap": "^6.0.2",
|
"@replit/codemirror-vscode-keymap": "^6.0.2",
|
||||||
|
"@shopify/lang-jsonc": "^1.0.1",
|
||||||
"@tanstack/react-query": "^5.90.5",
|
"@tanstack/react-query": "^5.90.5",
|
||||||
"@tanstack/react-router": "^1.133.13",
|
"@tanstack/react-router": "^1.133.13",
|
||||||
"@tanstack/react-virtual": "^3.13.12",
|
"@tanstack/react-virtual": "^3.13.12",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { jsonLanguage } from '@codemirror/lang-json';
|
import { jsoncLanguage } from '@shopify/lang-jsonc';
|
||||||
import { linter } from '@codemirror/lint';
|
import { linter } from '@codemirror/lint';
|
||||||
import type { EditorView } from '@codemirror/view';
|
import type { EditorView } from '@codemirror/view';
|
||||||
import type { GrpcRequest } from '@yaakapp-internal/models';
|
import type { GrpcRequest } from '@yaakapp-internal/models';
|
||||||
@@ -115,7 +115,7 @@ export function GrpcEditor({
|
|||||||
delay: 200,
|
delay: 200,
|
||||||
needsRefresh: handleRefresh,
|
needsRefresh: handleRefresh,
|
||||||
}),
|
}),
|
||||||
jsonLanguage.data.of({
|
jsoncLanguage.data.of({
|
||||||
autocomplete: jsonCompletion(),
|
autocomplete: jsonCompletion(),
|
||||||
}),
|
}),
|
||||||
stateExtensions({}),
|
stateExtensions({}),
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import { FormMultipartEditor } from './FormMultipartEditor';
|
|||||||
import { FormUrlencodedEditor } from './FormUrlencodedEditor';
|
import { FormUrlencodedEditor } from './FormUrlencodedEditor';
|
||||||
import { HeadersEditor } from './HeadersEditor';
|
import { HeadersEditor } from './HeadersEditor';
|
||||||
import { HttpAuthenticationEditor } from './HttpAuthenticationEditor';
|
import { HttpAuthenticationEditor } from './HttpAuthenticationEditor';
|
||||||
|
import { JsonBodyEditor } from './JsonBodyEditor';
|
||||||
import { MarkdownEditor } from './MarkdownEditor';
|
import { MarkdownEditor } from './MarkdownEditor';
|
||||||
import { RequestMethodDropdown } from './RequestMethodDropdown';
|
import { RequestMethodDropdown } from './RequestMethodDropdown';
|
||||||
import { UrlBar } from './UrlBar';
|
import { UrlBar } from './UrlBar';
|
||||||
@@ -257,7 +258,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleBodyTextChange = useCallback(
|
const handleBodyTextChange = useCallback(
|
||||||
(text: string) => patchModel(activeRequest, { body: { text } }),
|
(text: string) => patchModel(activeRequest, { body: { ...activeRequest.body, text } }),
|
||||||
[activeRequest],
|
[activeRequest],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -370,16 +371,10 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
|||||||
<TabContent value={TAB_BODY}>
|
<TabContent value={TAB_BODY}>
|
||||||
<ConfirmLargeRequestBody request={activeRequest}>
|
<ConfirmLargeRequestBody request={activeRequest}>
|
||||||
{activeRequest.bodyType === BODY_TYPE_JSON ? (
|
{activeRequest.bodyType === BODY_TYPE_JSON ? (
|
||||||
<Editor
|
<JsonBodyEditor
|
||||||
forceUpdateKey={forceUpdateKey}
|
forceUpdateKey={forceUpdateKey}
|
||||||
autocompleteFunctions
|
|
||||||
autocompleteVariables
|
|
||||||
placeholder="..."
|
|
||||||
heightMode={fullHeight ? 'full' : 'auto'}
|
heightMode={fullHeight ? 'full' : 'auto'}
|
||||||
defaultValue={`${activeRequest.body?.text ?? ''}`}
|
request={activeRequest}
|
||||||
language="json"
|
|
||||||
onChange={handleBodyTextChange}
|
|
||||||
stateKey={`json.${activeRequest.id}`}
|
|
||||||
/>
|
/>
|
||||||
) : activeRequest.bodyType === BODY_TYPE_XML ? (
|
) : activeRequest.bodyType === BODY_TYPE_XML ? (
|
||||||
<Editor
|
<Editor
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { linter } from '@codemirror/lint';
|
||||||
|
import type { HttpRequest } from '@yaakapp-internal/models';
|
||||||
|
import { patchModel } from '@yaakapp-internal/models';
|
||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
import { useKeyValue } from '../hooks/useKeyValue';
|
||||||
|
import { textLikelyContainsJsonComments } from '../lib/jsonComments';
|
||||||
|
import { Banner } from './core/Banner';
|
||||||
|
import type { DropdownItem } from './core/Dropdown';
|
||||||
|
import { Dropdown } from './core/Dropdown';
|
||||||
|
import type { EditorProps } from './core/Editor/Editor';
|
||||||
|
import { jsonParseLinter } from './core/Editor/json-lint';
|
||||||
|
import { Editor } from './core/Editor/LazyEditor';
|
||||||
|
import { Icon } from './core/Icon';
|
||||||
|
import { IconButton } from './core/IconButton';
|
||||||
|
import { IconTooltip } from './core/IconTooltip';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
forceUpdateKey: string;
|
||||||
|
heightMode: EditorProps['heightMode'];
|
||||||
|
request: HttpRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function JsonBodyEditor({ forceUpdateKey, heightMode, request }: Props) {
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(text: string) => 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<boolean>({
|
||||||
|
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<EditorProps['actions']>(
|
||||||
|
() => [
|
||||||
|
showBanner && (
|
||||||
|
<Banner color="notice" className="!opacity-100 h-sm !py-0 !px-2 flex items-center text-xs">
|
||||||
|
<p className="inline-flex items-center gap-1 min-w-0">
|
||||||
|
<span className="truncate">Auto-fix enabled</span>
|
||||||
|
<Icon icon="arrow_right" size="sm" className="opacity-disabled" />
|
||||||
|
</p>
|
||||||
|
</Banner>
|
||||||
|
),
|
||||||
|
<div key="settings" className="!opacity-100 !shadow">
|
||||||
|
<Dropdown
|
||||||
|
onOpen={handleDropdownOpen}
|
||||||
|
items={
|
||||||
|
[
|
||||||
|
{
|
||||||
|
label: 'Automatically Fix JSON',
|
||||||
|
keepOpenOnSelect: true,
|
||||||
|
onSelect: handleToggleAutoFix,
|
||||||
|
rightSlot: <IconTooltip content={stripMessage} />,
|
||||||
|
leftSlot: (
|
||||||
|
<Icon icon={autoFix ? 'check_square_checked' : 'check_square_unchecked'} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
] satisfies DropdownItem[]
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<IconButton size="sm" variant="border" icon="settings" title="JSON Settings" />
|
||||||
|
</Dropdown>
|
||||||
|
</div>,
|
||||||
|
],
|
||||||
|
[handleDropdownOpen, handleToggleAutoFix, autoFix, showBanner],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Editor
|
||||||
|
forceUpdateKey={forceUpdateKey}
|
||||||
|
autocompleteFunctions
|
||||||
|
autocompleteVariables
|
||||||
|
placeholder="..."
|
||||||
|
heightMode={heightMode}
|
||||||
|
defaultValue={`${request.body?.text ?? ''}`}
|
||||||
|
language="json"
|
||||||
|
onChange={handleChange}
|
||||||
|
stateKey={`json.${request.id}`}
|
||||||
|
actions={actions}
|
||||||
|
lintExtension={lintExtension}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -78,6 +78,7 @@ export interface EditorProps {
|
|||||||
hideGutter?: boolean;
|
hideGutter?: boolean;
|
||||||
id?: string;
|
id?: string;
|
||||||
language?: EditorLanguage | 'pairs' | 'url' | 'timeline' | null;
|
language?: EditorLanguage | 'pairs' | 'url' | 'timeline' | null;
|
||||||
|
lintExtension?: Extension;
|
||||||
graphQLSchema?: GraphQLSchema | null;
|
graphQLSchema?: GraphQLSchema | null;
|
||||||
onBlur?: () => void;
|
onBlur?: () => void;
|
||||||
onChange?: (value: string) => void;
|
onChange?: (value: string) => void;
|
||||||
@@ -124,6 +125,7 @@ function EditorInner({
|
|||||||
hideGutter,
|
hideGutter,
|
||||||
graphQLSchema,
|
graphQLSchema,
|
||||||
language,
|
language,
|
||||||
|
lintExtension,
|
||||||
onBlur,
|
onBlur,
|
||||||
onChange,
|
onChange,
|
||||||
onFocus,
|
onFocus,
|
||||||
@@ -332,6 +334,7 @@ function EditorInner({
|
|||||||
const ext = getLanguageExtension({
|
const ext = getLanguageExtension({
|
||||||
useTemplating,
|
useTemplating,
|
||||||
language,
|
language,
|
||||||
|
lintExtension,
|
||||||
hideGutter,
|
hideGutter,
|
||||||
environmentVariables,
|
environmentVariables,
|
||||||
autocomplete,
|
autocomplete,
|
||||||
@@ -344,6 +347,7 @@ function EditorInner({
|
|||||||
view.dispatch({ effects: languageCompartment.reconfigure(ext) });
|
view.dispatch({ effects: languageCompartment.reconfigure(ext) });
|
||||||
}, [
|
}, [
|
||||||
language,
|
language,
|
||||||
|
lintExtension,
|
||||||
autocomplete,
|
autocomplete,
|
||||||
environmentVariables,
|
environmentVariables,
|
||||||
onClickFunction,
|
onClickFunction,
|
||||||
@@ -371,6 +375,7 @@ function EditorInner({
|
|||||||
const langExt = getLanguageExtension({
|
const langExt = getLanguageExtension({
|
||||||
useTemplating,
|
useTemplating,
|
||||||
language,
|
language,
|
||||||
|
lintExtension,
|
||||||
completionOptions,
|
completionOptions,
|
||||||
autocomplete,
|
autocomplete,
|
||||||
environmentVariables,
|
environmentVariables,
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { history, historyKeymap } from '@codemirror/commands';
|
|||||||
import { go } from '@codemirror/lang-go';
|
import { go } from '@codemirror/lang-go';
|
||||||
import { java } from '@codemirror/lang-java';
|
import { java } from '@codemirror/lang-java';
|
||||||
import { javascript } from '@codemirror/lang-javascript';
|
import { javascript } from '@codemirror/lang-javascript';
|
||||||
import { json } from '@codemirror/lang-json';
|
|
||||||
import { markdown } from '@codemirror/lang-markdown';
|
import { markdown } from '@codemirror/lang-markdown';
|
||||||
import { php } from '@codemirror/lang-php';
|
import { php } from '@codemirror/lang-php';
|
||||||
import { python } from '@codemirror/lang-python';
|
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 { shell } from '@codemirror/legacy-modes/mode/shell';
|
||||||
import { swift } from '@codemirror/legacy-modes/mode/swift';
|
import { swift } from '@codemirror/legacy-modes/mode/swift';
|
||||||
import { linter, lintGutter, lintKeymap } from '@codemirror/lint';
|
import { linter, lintGutter, lintKeymap } from '@codemirror/lint';
|
||||||
|
|
||||||
import { search, searchKeymap } from '@codemirror/search';
|
import { search, searchKeymap } from '@codemirror/search';
|
||||||
import type { Extension } from '@codemirror/state';
|
import type { Extension } from '@codemirror/state';
|
||||||
import { EditorState } from '@codemirror/state';
|
import { EditorState } from '@codemirror/state';
|
||||||
@@ -50,6 +48,7 @@ import {
|
|||||||
rectangularSelection,
|
rectangularSelection,
|
||||||
} from '@codemirror/view';
|
} from '@codemirror/view';
|
||||||
import { tags as t } from '@lezer/highlight';
|
import { tags as t } from '@lezer/highlight';
|
||||||
|
import { jsonc, jsoncLanguage } from '@shopify/lang-jsonc';
|
||||||
import { graphql } from 'cm6-graphql';
|
import { graphql } from 'cm6-graphql';
|
||||||
import type { GraphQLSchema } from 'graphql';
|
import type { GraphQLSchema } from 'graphql';
|
||||||
import { activeRequestIdAtom } from '../../../hooks/useActiveRequestId';
|
import { activeRequestIdAtom } from '../../../hooks/useActiveRequestId';
|
||||||
@@ -61,13 +60,13 @@ import { showGraphQLDocExplorerAtom } from '../../graphql/graphqlAtoms';
|
|||||||
import type { EditorProps } from './Editor';
|
import type { EditorProps } from './Editor';
|
||||||
import { jsonParseLinter } from './json-lint';
|
import { jsonParseLinter } from './json-lint';
|
||||||
import { pairs } from './pairs/extension';
|
import { pairs } from './pairs/extension';
|
||||||
|
import { searchMatchCount } from './searchMatchCount';
|
||||||
import { text } from './text/extension';
|
import { text } from './text/extension';
|
||||||
import { timeline } from './timeline/extension';
|
import { timeline } from './timeline/extension';
|
||||||
import type { TwigCompletionOption } from './twig/completion';
|
import type { TwigCompletionOption } from './twig/completion';
|
||||||
import { twig } from './twig/extension';
|
import { twig } from './twig/extension';
|
||||||
import { pathParametersPlugin } from './twig/pathParameters';
|
import { pathParametersPlugin } from './twig/pathParameters';
|
||||||
import { url } from './url/extension';
|
import { url } from './url/extension';
|
||||||
import { searchMatchCount } from './searchMatchCount';
|
|
||||||
|
|
||||||
export const syntaxHighlightStyle = HighlightStyle.define([
|
export const syntaxHighlightStyle = HighlightStyle.define([
|
||||||
{
|
{
|
||||||
@@ -107,7 +106,7 @@ const syntaxExtensions: Record<
|
|||||||
null | (() => LanguageSupport)
|
null | (() => LanguageSupport)
|
||||||
> = {
|
> = {
|
||||||
graphql: null,
|
graphql: null,
|
||||||
json: json,
|
json: jsonc,
|
||||||
javascript: javascript,
|
javascript: javascript,
|
||||||
// HTML as XML because HTML is oddly slow
|
// HTML as XML because HTML is oddly slow
|
||||||
html: xml,
|
html: xml,
|
||||||
@@ -140,6 +139,7 @@ const closeBracketsFor: (keyof typeof syntaxExtensions)[] = ['json', 'javascript
|
|||||||
export function getLanguageExtension({
|
export function getLanguageExtension({
|
||||||
useTemplating,
|
useTemplating,
|
||||||
language = 'text',
|
language = 'text',
|
||||||
|
lintExtension,
|
||||||
environmentVariables,
|
environmentVariables,
|
||||||
autocomplete,
|
autocomplete,
|
||||||
hideGutter,
|
hideGutter,
|
||||||
@@ -156,7 +156,7 @@ export function getLanguageExtension({
|
|||||||
onClickPathParameter: (name: string) => void;
|
onClickPathParameter: (name: string) => void;
|
||||||
completionOptions: TwigCompletionOption[];
|
completionOptions: TwigCompletionOption[];
|
||||||
graphQLSchema: GraphQLSchema | null;
|
graphQLSchema: GraphQLSchema | null;
|
||||||
} & Pick<EditorProps, 'language' | 'autocomplete' | 'hideGutter'>) {
|
} & Pick<EditorProps, 'language' | 'autocomplete' | 'hideGutter' | 'lintExtension'>) {
|
||||||
const extraExtensions: Extension[] = [];
|
const extraExtensions: Extension[] = [];
|
||||||
|
|
||||||
if (language === 'url') {
|
if (language === 'url') {
|
||||||
@@ -193,7 +193,12 @@ export function getLanguageExtension({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (language === 'json') {
|
if (language === 'json') {
|
||||||
extraExtensions.push(linter(jsonParseLinter()));
|
extraExtensions.push(lintExtension ?? linter(jsonParseLinter()));
|
||||||
|
extraExtensions.push(
|
||||||
|
jsoncLanguage.data.of({
|
||||||
|
commentTokens: { line: '//', block: { open: '/*', close: '*/' } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
if (!hideGutter) {
|
if (!hideGutter) {
|
||||||
extraExtensions.push(lintGutter());
|
extraExtensions.push(lintGutter());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,22 @@ import { parse as jsonLintParse } from '@prantlf/jsonlint';
|
|||||||
|
|
||||||
const TEMPLATE_SYNTAX_REGEX = /\$\{\[[\s\S]*?]}/g;
|
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[] => {
|
return (view: EditorView): Diagnostic[] => {
|
||||||
try {
|
try {
|
||||||
const doc = view.state.doc.toString();
|
const doc = view.state.doc.toString();
|
||||||
// We need lint to not break on stuff like {"foo:" ${[ ... ]}} so we'll replace all template
|
// 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.
|
// 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));
|
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
|
// biome-ignore lint/suspicious/noExplicitAny: none
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (!('location' in err)) {
|
if (!('location' in err)) {
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
|
|||||||
{ type: 'separator', label: 'Setting' },
|
{ type: 'separator', label: 'Setting' },
|
||||||
{
|
{
|
||||||
label: 'Automatic Introspection',
|
label: 'Automatic Introspection',
|
||||||
|
keepOpenOnSelect: true,
|
||||||
onSelect: () => {
|
onSelect: () => {
|
||||||
setAutoIntrospectDisabled({
|
setAutoIntrospectDisabled({
|
||||||
...autoIntrospectDisabled,
|
...autoIntrospectDisabled,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
"@replit/codemirror-emacs": "^6.1.0",
|
"@replit/codemirror-emacs": "^6.1.0",
|
||||||
"@replit/codemirror-vim": "^6.3.0",
|
"@replit/codemirror-vim": "^6.3.0",
|
||||||
"@replit/codemirror-vscode-keymap": "^6.0.2",
|
"@replit/codemirror-vscode-keymap": "^6.0.2",
|
||||||
|
"@shopify/lang-jsonc": "^1.0.1",
|
||||||
"@tanstack/react-query": "^5.90.5",
|
"@tanstack/react-query": "^5.90.5",
|
||||||
"@tanstack/react-router": "^1.133.13",
|
"@tanstack/react-router": "^1.133.13",
|
||||||
"@tanstack/react-virtual": "^3.13.12",
|
"@tanstack/react-virtual": "^3.13.12",
|
||||||
|
|||||||
Reference in New Issue
Block a user