Auto-fix JSON comments and trailing commas for HTTP request bodies

Add a per-request "Automatically Fix JSON" toggle (default: on) that strips
comments and trailing commas before sending HTTP requests. When disabled,
comments are sent as-is and the editor linter flags them as errors.

- New JsonBodyEditor component with settings dropdown and dismissible banner
- Configurable JSON linter (allowComments/allowTrailingCommas) via lintExtension prop
- strip_trailing_commas pass in Rust strip_json_comments pipeline
- Fix JSON formatter pulling standalone comments onto previous comma line
- Add get_bool_map helper, maybe_strip_json_comments, and related tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Gregory Schier
2026-03-05 13:23:41 -08:00
parent ed6f9f8bd0
commit c340a7d5ae
11 changed files with 355 additions and 30 deletions
+7
View File
@@ -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(),
}
}
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),
}
}
+64 -10
View File
@@ -9,9 +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::strip_json_comments;
use yaak_templates::strip_json_comments::{maybe_strip_json_comments, strip_json_comments};
pub(crate) const MULTIPART_BOUNDARY: &str = "------YaakFormBoundary";
@@ -195,7 +195,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)
@@ -270,13 +270,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");
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(
@@ -702,7 +709,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!"))
@@ -716,7 +723,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());
}
@@ -724,10 +731,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();
+28 -2
View File
@@ -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();
@@ -74,8 +75,8 @@ pub fn format_json(text: &str, tab: &str) -> String {
}
// 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") {
// After comma: undo the newline+indent, make comment trailing
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() {
@@ -85,6 +86,7 @@ pub fn format_json(text: &str, tab: &str) -> String {
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;
}
@@ -180,8 +182,12 @@ 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);
}
}
@@ -500,6 +506,26 @@ mod tests {
"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()
);
@@ -113,11 +113,67 @@ pub fn strip_json_comments(text: &str) -> String {
}
// Remove lines that are now empty (were comment-only lines)
result
let result = result
.lines()
.filter(|line| !line.trim().is_empty())
.collect::<Vec<&str>>()
.join("\n")
.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)]
@@ -232,4 +288,31 @@ mod tests {
}"#
);
}
#[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);
}
}
+4 -9
View File
@@ -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 }:
<TabContent value={TAB_BODY}>
<ConfirmLargeRequestBody request={activeRequest}>
{activeRequest.bodyType === BODY_TYPE_JSON ? (
<Editor
<JsonBodyEditor
forceUpdateKey={forceUpdateKey}
autocompleteFunctions
autocompleteVariables
placeholder="..."
heightMode={fullHeight ? 'full' : 'auto'}
defaultValue={`${activeRequest.body?.text ?? ''}`}
language="json"
onChange={handleBodyTextChange}
stateKey={`json.${activeRequest.id}`}
request={activeRequest}
/>
) : activeRequest.bodyType === BODY_TYPE_XML ? (
<Editor
+116
View File
@@ -0,0 +1,116 @@
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 } : {})),
[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;
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,
+5 -5
View File
@@ -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 { jsonc, jsoncLanguage } from '@shopify/lang-jsonc';
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([
{
@@ -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<EditorProps, 'language' | 'autocomplete' | 'hideGutter'>) {
} & Pick<EditorProps, 'language' | 'autocomplete' | 'hideGutter' | 'lintExtension'>) {
const extraExtensions: Extension[] = [];
if (language === 'url') {
@@ -193,7 +193,7 @@ 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: '*/' } },
+10 -2
View File
@@ -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, { mode: 'cjson' });
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)) {
@@ -156,6 +156,7 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
{ type: 'separator', label: 'Setting' },
{
label: 'Automatic Introspection',
keepOpenOnSelect: true,
onSelect: () => {
setAutoIntrospectDisabled({
...autoIntrospectDisabled,
+30
View File
@@ -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;
}