fix: strip comments from JSON request bodies in CLI (#5769)
Fixes collections with JSON comments failing in the CLI with `SerializationException` while working fine in the app, where comments are stripped before sending requests, but the CLI was sending them as-is, breaking APIs like AWS Cognito that expect valid JSON.
This commit is contained in:
parent
254eb3c958
commit
d3144f99fb
9 changed files with 414 additions and 29 deletions
|
|
@ -48,6 +48,7 @@
|
|||
"commander": "14.0.2",
|
||||
"isolated-vm": "6.0.2",
|
||||
"js-md5": "0.8.3",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"lodash-es": "4.17.22",
|
||||
"papaparse": "5.5.3",
|
||||
"qs": "6.14.0",
|
||||
|
|
|
|||
|
|
@ -180,6 +180,16 @@ describe("hopp test [options] <file_path_or_id>", { timeout: 100000 }, () => {
|
|||
expect(result.error).toBeNull();
|
||||
});
|
||||
|
||||
test("Strips comments from JSONC request bodies", async () => {
|
||||
const args = `test ${getTestJsonFilePath(
|
||||
"jsonc-body-coll.json",
|
||||
"collection"
|
||||
)}`;
|
||||
const result = await runCLIWithNetworkRetry(args);
|
||||
if (result === null) return;
|
||||
expect(result.error).toBeNull();
|
||||
});
|
||||
|
||||
describe("OAuth 2 Authorization type with Authorization Code Grant Type", () => {
|
||||
test("Successfully translates the authorization information to headers/query params and sends it along with the request", async () => {
|
||||
const args = `test ${getTestJsonFilePath(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,74 @@
|
|||
{
|
||||
"v": 11,
|
||||
"name": "JSONC Body Test Collection",
|
||||
"folders": [],
|
||||
"requests": [
|
||||
{
|
||||
"v": "17",
|
||||
"auth": {
|
||||
"authType": "inherit",
|
||||
"authActive": true
|
||||
},
|
||||
"body": {
|
||||
"body": "{\n \"key1\": \"value1\", // inline comment\n \"key2\": \"value2\" // another comment\n}",
|
||||
"contentType": "application/json"
|
||||
},
|
||||
"name": "Echo with inline comments",
|
||||
"method": "POST",
|
||||
"params": [],
|
||||
"headers": [],
|
||||
"endpoint": "https://echo.hoppscotch.io",
|
||||
"testScript": "hopp.test('Should successfully parse JSONC with comments', () => {\n hopp.expect(hopp.response.statusCode).toBe(200);\n const data = JSON.parse(hopp.response.body.asJSON().data);\n hopp.expect(data.key1).toBe('value1');\n hopp.expect(data.key2).toBe('value2');\n});",
|
||||
"preRequestScript": "",
|
||||
"requestVariables": [],
|
||||
"responses": {}
|
||||
},
|
||||
{
|
||||
"v": "17",
|
||||
"auth": {
|
||||
"authType": "inherit",
|
||||
"authActive": true
|
||||
},
|
||||
"body": {
|
||||
"body": "{\n /* Multi-line comment\n should also work */\n \"message\": \"test\",\n \"nested\": {\n \"field\": \"value\" // another comment\n }\n}",
|
||||
"contentType": "application/json"
|
||||
},
|
||||
"name": "Echo with multiline comments",
|
||||
"method": "POST",
|
||||
"params": [],
|
||||
"headers": [],
|
||||
"endpoint": "https://echo.hoppscotch.io",
|
||||
"testScript": "hopp.test('Should successfully parse JSONC with multiline comments', () => {\n hopp.expect(hopp.response.statusCode).toBe(200);\n const data = JSON.parse(hopp.response.body.asJSON().data);\n hopp.expect(data.message).toBe('test');\n hopp.expect(data.nested.field).toBe('value');\n});",
|
||||
"preRequestScript": "",
|
||||
"requestVariables": [],
|
||||
"responses": {}
|
||||
},
|
||||
{
|
||||
"v": "17",
|
||||
"auth": {
|
||||
"authType": "inherit",
|
||||
"authActive": true
|
||||
},
|
||||
"body": {
|
||||
"body": "{\n \"key\": \"value\",\n \"count\": 42,\n}",
|
||||
"contentType": "application/json"
|
||||
},
|
||||
"name": "Echo with trailing commas",
|
||||
"method": "POST",
|
||||
"params": [],
|
||||
"headers": [],
|
||||
"endpoint": "https://echo.hoppscotch.io",
|
||||
"testScript": "hopp.test('Should successfully parse JSONC with trailing commas', () => {\n hopp.expect(hopp.response.statusCode).toBe(200);\n const data = JSON.parse(hopp.response.body.asJSON().data);\n hopp.expect(data.key).toBe('value');\n hopp.expect(data.count).toBe(42);\n});",
|
||||
"preRequestScript": "",
|
||||
"requestVariables": [],
|
||||
"responses": {}
|
||||
}
|
||||
],
|
||||
"auth": {
|
||||
"authType": "inherit",
|
||||
"authActive": true
|
||||
},
|
||||
"headers": [],
|
||||
"variables": [],
|
||||
"description": ""
|
||||
}
|
||||
151
packages/hoppscotch-cli/src/__tests__/unit/jsonc.spec.ts
Normal file
151
packages/hoppscotch-cli/src/__tests__/unit/jsonc.spec.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import { describe, expect, test } from "vitest";
|
||||
import { stripComments } from "../../utils/jsonc";
|
||||
|
||||
describe("stripComments", () => {
|
||||
describe("handles inline comments", () => {
|
||||
test("removes single inline comment", () => {
|
||||
const input = '{"key": "value" // comment\n}';
|
||||
const result = stripComments(input);
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed).toEqual({ key: "value" });
|
||||
});
|
||||
|
||||
test("removes multiple inline comments", () => {
|
||||
const input = '{\n "key1": "value1", // comment1\n "key2": "value2" // comment2\n}';
|
||||
const result = stripComments(input);
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed).toEqual({ key1: "value1", key2: "value2" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("handles multiline comments", () => {
|
||||
test("removes single multiline comment", () => {
|
||||
const input = '{\n /* This is a comment */\n "key": "value"\n}';
|
||||
const result = stripComments(input);
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed).toEqual({ key: "value" });
|
||||
});
|
||||
|
||||
test("removes multiline comment spanning multiple lines", () => {
|
||||
const input = '{\n /* This is\n a multiline\n comment */\n "key": "value"\n}';
|
||||
const result = stripComments(input);
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed).toEqual({ key: "value" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("handles trailing commas", () => {
|
||||
test("removes trailing comma in object", () => {
|
||||
const input = '{"key": "value",}';
|
||||
const result = stripComments(input);
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed).toEqual({ key: "value" });
|
||||
});
|
||||
|
||||
test("removes trailing comma in array", () => {
|
||||
const input = '["item1", "item2",]';
|
||||
const result = stripComments(input);
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed).toEqual(["item1", "item2"]);
|
||||
});
|
||||
|
||||
test("removes multiple trailing commas in nested structures", () => {
|
||||
const input = '{"arr": ["a", "b",], "obj": {"key": "value",},}';
|
||||
const result = stripComments(input);
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed).toEqual({ arr: ["a", "b"], obj: { key: "value" } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("handles combined cases", () => {
|
||||
test("removes both comments and trailing commas", () => {
|
||||
const input = '{\n "key1": "value1", // inline comment\n /* block comment */\n "key2": "value2",\n}';
|
||||
const result = stripComments(input);
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed).toEqual({ key1: "value1", key2: "value2" });
|
||||
});
|
||||
|
||||
test("handles nested objects with comments and trailing commas", () => {
|
||||
const input = '{\n "outer": { // comment\n "inner": "value",\n },\n}';
|
||||
const result = stripComments(input);
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed).toEqual({ outer: { inner: "value" } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("handles edge cases", () => {
|
||||
test("returns empty string unchanged", () => {
|
||||
const input = "";
|
||||
const result = stripComments(input);
|
||||
expect(result).toBe("");
|
||||
});
|
||||
|
||||
test("returns whitespace-only string unchanged", () => {
|
||||
const input = " \n \t ";
|
||||
const result = stripComments(input);
|
||||
expect(result).toBe(input);
|
||||
});
|
||||
|
||||
test("handles valid JSON without comments", () => {
|
||||
const input = '{"key": "value"}';
|
||||
const result = stripComments(input);
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed).toEqual({ key: "value" });
|
||||
});
|
||||
|
||||
test("preserves JSON strings containing comment-like sequences", () => {
|
||||
const input = '{"url": "https://example.com//path"}';
|
||||
const result = stripComments(input);
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.url).toBe("https://example.com//path");
|
||||
});
|
||||
|
||||
test("handles deeply nested structures", () => {
|
||||
const input = '{\n "a": {\n "b": {\n "c": {\n "d": "value", // nested comment\n },\n },\n },\n}';
|
||||
const result = stripComments(input);
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed).toEqual({ a: { b: { c: { d: "value" } } } });
|
||||
});
|
||||
|
||||
test("handles arrays with mixed content", () => {
|
||||
const input = '[\n "string",\n 123, // number\n true, // boolean\n null, // null\n {"nested": "object",}, // object\n]';
|
||||
const result = stripComments(input);
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed).toEqual(["string", 123, true, null, { nested: "object" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handles null return from stripComments_", () => {
|
||||
test("gracefully handles potential null from jsonc-parser", () => {
|
||||
const input = '{"key": "value"}';
|
||||
const result = stripComments(input);
|
||||
expect(result).toBeTruthy();
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed).toEqual({ key: "value" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("handles malformed JSON", () => {
|
||||
test("attempts to parse malformed JSON and returns result", () => {
|
||||
// jsonc-parser is lenient and tries to repair malformed JSON
|
||||
const input = '{"key": "value"'; // missing closing brace
|
||||
const result = stripComments(input);
|
||||
// The parser will attempt to close the brace
|
||||
expect(result).toBe('{"key":"value"}');
|
||||
});
|
||||
|
||||
test("gracefully handles completely invalid JSON", () => {
|
||||
const input = 'this is not json at all {]}{]';
|
||||
const result = stripComments(input);
|
||||
// jsonc-parser extracts what it can and returns an object (even if mostly empty)
|
||||
expect(result).toBe('{}');
|
||||
});
|
||||
|
||||
test("handles JSON with syntax errors", () => {
|
||||
const input = '{"key": undefined}'; // undefined is not valid JSON
|
||||
const result = stripComments(input);
|
||||
// Parser will handle this - exact behavior depends on jsonc-parser
|
||||
expect(typeof result).toBe('string');
|
||||
});
|
||||
});
|
||||
});
|
||||
89
packages/hoppscotch-cli/src/utils/jsonc.ts
Normal file
89
packages/hoppscotch-cli/src/utils/jsonc.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { Node, parseTree, stripComments as stripComments_ } from "jsonc-parser";
|
||||
|
||||
/**
|
||||
* An internal error that is thrown when an invalid JSONC node configuration
|
||||
* is encountered
|
||||
*/
|
||||
class InvalidJSONCNodeError extends Error {
|
||||
constructor() {
|
||||
super();
|
||||
this.message = "Invalid JSONC node";
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: If we choose to export this function, do refactor it to return a result discriminated union instead of throwing
|
||||
/**
|
||||
* @throws {InvalidJSONCNodeError} if the node is in an invalid configuration
|
||||
* @returns The JSON string without comments and trailing commas
|
||||
*/
|
||||
function convertNodeToJSON(node: Node): string {
|
||||
switch (node.type) {
|
||||
case "string":
|
||||
return JSON.stringify(node.value);
|
||||
case "null":
|
||||
return "null";
|
||||
case "array":
|
||||
if (!node.children) {
|
||||
throw new InvalidJSONCNodeError();
|
||||
}
|
||||
|
||||
return `[${node.children
|
||||
.map((child) => convertNodeToJSON(child))
|
||||
.join(",")}]`;
|
||||
case "number":
|
||||
return JSON.stringify(node.value);
|
||||
case "boolean":
|
||||
return JSON.stringify(node.value);
|
||||
case "object":
|
||||
if (!node.children) {
|
||||
throw new InvalidJSONCNodeError();
|
||||
}
|
||||
|
||||
return `{${node.children
|
||||
.map((child) => convertNodeToJSON(child))
|
||||
.join(",")}}`;
|
||||
case "property":
|
||||
if (!node.children || node.children.length !== 2) {
|
||||
throw new InvalidJSONCNodeError();
|
||||
}
|
||||
|
||||
const [keyNode, valueNode] = node.children;
|
||||
|
||||
// Use keyNode.value instead of keyNode to avoid circular references.
|
||||
// Attempting to JSON.stringify(keyNode) directly would throw
|
||||
// "Converting circular structure to JSON" error.
|
||||
// If the valueNode configuration is wrong, this will return an error, which will propagate up
|
||||
return `${JSON.stringify(keyNode.value)}:${convertNodeToJSON(valueNode)}`;
|
||||
}
|
||||
}
|
||||
|
||||
function stripCommentsAndCommas(text: string): string {
|
||||
const tree = parseTree(text, undefined, {
|
||||
allowEmptyContent: true,
|
||||
allowTrailingComma: true,
|
||||
});
|
||||
|
||||
// If we couldn't parse the tree, return the original text
|
||||
if (!tree) {
|
||||
return text;
|
||||
}
|
||||
|
||||
// convertNodeToJSON can throw an error if the tree is invalid
|
||||
try {
|
||||
return convertNodeToJSON(tree);
|
||||
} catch (_) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes comments and trailing commas from a JSONC string.
|
||||
* This is needed because APIs like AWS Cognito expect valid JSON without comments,
|
||||
* but Hoppscotch allows users to add comments to their request bodies.
|
||||
*
|
||||
* @param jsoncString The JSONC string with comments and/or trailing commas.
|
||||
* @returns The clean JSON string without comments or trailing commas.
|
||||
*/
|
||||
export function stripComments(jsoncString: string): string {
|
||||
return stripCommentsAndCommas(stripComments_(jsoncString) ?? jsoncString);
|
||||
}
|
||||
|
|
@ -8,9 +8,9 @@ import {
|
|||
parseTemplateStringE,
|
||||
generateJWTToken,
|
||||
HoppCollectionVariable,
|
||||
calculateHawkHeader
|
||||
calculateHawkHeader,
|
||||
} from "@hoppscotch/data";
|
||||
import { runPreRequestScript } from "@hoppscotch/js-sandbox/node"
|
||||
import { runPreRequestScript } from "@hoppscotch/js-sandbox/node";
|
||||
import { createHoppFetchHook } from "./hopp-fetch";
|
||||
import * as A from "fp-ts/Array";
|
||||
import * as E from "fp-ts/Either";
|
||||
|
|
@ -35,6 +35,7 @@ import {
|
|||
fetchInitialDigestAuthInfo,
|
||||
generateDigestAuthHeader,
|
||||
} from "./auth/digest";
|
||||
import { stripComments } from "./jsonc";
|
||||
|
||||
/**
|
||||
* Runs pre-request-script runner over given request which extracts set ENVs and
|
||||
|
|
@ -81,7 +82,8 @@ export const preRequestScriptRunner = (
|
|||
updatedRequest: updatedRequest ?? {},
|
||||
};
|
||||
}),
|
||||
TE.chainW(({ preRequestUpdatedEnvs, envForEffectiveRequest, updatedRequest }) => {
|
||||
TE.chainW(
|
||||
({ preRequestUpdatedEnvs, envForEffectiveRequest, updatedRequest }) => {
|
||||
const finalRequest = { ...request, ...updatedRequest };
|
||||
|
||||
return TE.tryCatch(
|
||||
|
|
@ -103,7 +105,8 @@ export const preRequestScriptRunner = (
|
|||
},
|
||||
(reason) => error({ code: "PRE_REQUEST_SCRIPT_ERROR", data: reason })
|
||||
);
|
||||
}),
|
||||
}
|
||||
),
|
||||
TE.chainEitherKW((effectiveRequest) => effectiveRequest),
|
||||
TE.mapLeft((reason) =>
|
||||
isHoppCLIError(reason)
|
||||
|
|
@ -578,6 +581,58 @@ function getFinalBodyFromRequest(
|
|||
return E.right(body);
|
||||
}
|
||||
|
||||
// For JSON content types, parse the string body into a JavaScript object
|
||||
// so axios can properly serialize it. This includes standard application/json
|
||||
// and vendor-specific JSON media types (for example those with a +json suffix
|
||||
// or subtypes whose names end with "json" or "-json").
|
||||
if (request.body.contentType) {
|
||||
const mimeType = request.body.contentType.split(";")[0].trim().toLowerCase();
|
||||
|
||||
if (
|
||||
mimeType === "application/json" ||
|
||||
mimeType.endsWith("+json") ||
|
||||
mimeType.endsWith("/json") ||
|
||||
mimeType.endsWith("-json")
|
||||
) {
|
||||
const envResult = parseBodyEnvVariablesE(request.body.body, resolvedVariables);
|
||||
|
||||
if (E.isLeft(envResult)) {
|
||||
return E.left(
|
||||
error({
|
||||
code: "PARSING_ERROR",
|
||||
data: `${request.body.body} (${envResult.left})`,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const bodyString = envResult.right;
|
||||
|
||||
// If the body string is empty or null, return null
|
||||
if (!bodyString || S.isEmpty(bodyString.trim())) {
|
||||
return E.right(null);
|
||||
}
|
||||
|
||||
// Strip comments and trailing commas from JSONC
|
||||
// This ensures collections with comments work the same in CLI as in desktop app
|
||||
const cleanedBody = stripComments(bodyString);
|
||||
|
||||
// Try to parse the JSON body
|
||||
try {
|
||||
const parsedBody = JSON.parse(cleanedBody);
|
||||
return E.right(JSON.stringify(parsedBody));
|
||||
} catch (err) {
|
||||
// If parsing fails after stripping comments, return error to provide
|
||||
// immediate feedback instead of sending invalid JSON to the API.
|
||||
// Use original template string to avoid leaking secrets from env vars.
|
||||
return E.left(
|
||||
error({
|
||||
code: "PARSING_ERROR",
|
||||
data: `${request.body.body} (Invalid JSON in request body: ${err instanceof Error ? err.message : String(err)})`,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return pipe(
|
||||
parseBodyEnvVariablesE(request.body.body, resolvedVariables),
|
||||
E.mapLeft((e) =>
|
||||
|
|
|
|||
|
|
@ -33,8 +33,7 @@ class InvalidJSONCNodeError extends Error {
|
|||
// NOTE: If we choose to export this function, do refactor it to return a result discriminated union instead of throwing
|
||||
/**
|
||||
* @throws {InvalidJSONCNodeError} if the node is in an invalid configuration
|
||||
* @returns The JSON string without comments and trailing commas or null
|
||||
* if the conversion failed
|
||||
* @returns The JSON string without comments and trailing commas
|
||||
*/
|
||||
function convertNodeToJSON(node: Node): string {
|
||||
switch (node.type) {
|
||||
|
|
@ -69,8 +68,11 @@ function convertNodeToJSON(node: Node): string {
|
|||
|
||||
const [keyNode, valueNode] = node.children
|
||||
|
||||
// Use keyNode.value instead of keyNode to avoid circular references.
|
||||
// Attempting to JSON.stringify(keyNode) directly would throw
|
||||
// "Converting circular structure to JSON" error.
|
||||
// If the valueNode configuration is wrong, this will return an error, which will propagate up
|
||||
return `${JSON.stringify(keyNode)}:${convertNodeToJSON(valueNode)}`
|
||||
return `${JSON.stringify(keyNode.value)}:${convertNodeToJSON(valueNode)}`
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -100,7 +102,7 @@ function stripCommentsAndCommas(text: string): string {
|
|||
*/
|
||||
|
||||
export function stripComments(jsonString: string) {
|
||||
return stripCommentsAndCommas(stripComments_(jsonString))
|
||||
return stripCommentsAndCommas(stripComments_(jsonString) ?? jsonString)
|
||||
}
|
||||
|
||||
export default linter
|
||||
|
|
|
|||
|
|
@ -415,6 +415,9 @@ importers:
|
|||
js-md5:
|
||||
specifier: 0.8.3
|
||||
version: 0.8.3
|
||||
jsonc-parser:
|
||||
specifier: 3.3.1
|
||||
version: 3.3.1
|
||||
lodash-es:
|
||||
specifier: 4.17.22
|
||||
version: 4.17.22
|
||||
|
|
|
|||
Loading…
Reference in a new issue