diff --git a/packages/hoppscotch-cli/package.json b/packages/hoppscotch-cli/package.json index 86e8511e..73737a57 100644 --- a/packages/hoppscotch-cli/package.json +++ b/packages/hoppscotch-cli/package.json @@ -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", diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/commands/test.spec.ts b/packages/hoppscotch-cli/src/__tests__/e2e/commands/test.spec.ts index 8a1b94f5..dac7af4b 100644 --- a/packages/hoppscotch-cli/src/__tests__/e2e/commands/test.spec.ts +++ b/packages/hoppscotch-cli/src/__tests__/e2e/commands/test.spec.ts @@ -180,6 +180,16 @@ describe("hopp test [options] ", { 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( diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/jsonc-body-coll.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/jsonc-body-coll.json new file mode 100644 index 00000000..247e3672 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/jsonc-body-coll.json @@ -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": "" +} diff --git a/packages/hoppscotch-cli/src/__tests__/unit/jsonc.spec.ts b/packages/hoppscotch-cli/src/__tests__/unit/jsonc.spec.ts new file mode 100644 index 00000000..9b4fcd78 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/unit/jsonc.spec.ts @@ -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'); + }); + }); +}); diff --git a/packages/hoppscotch-cli/src/utils/jsonc.ts b/packages/hoppscotch-cli/src/utils/jsonc.ts new file mode 100644 index 00000000..00522036 --- /dev/null +++ b/packages/hoppscotch-cli/src/utils/jsonc.ts @@ -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); +} diff --git a/packages/hoppscotch-cli/src/utils/pre-request.ts b/packages/hoppscotch-cli/src/utils/pre-request.ts index fe9cecea..1b9d0151 100644 --- a/packages/hoppscotch-cli/src/utils/pre-request.ts +++ b/packages/hoppscotch-cli/src/utils/pre-request.ts @@ -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,29 +82,31 @@ export const preRequestScriptRunner = ( updatedRequest: updatedRequest ?? {}, }; }), - TE.chainW(({ preRequestUpdatedEnvs, envForEffectiveRequest, updatedRequest }) => { - const finalRequest = { ...request, ...updatedRequest }; + TE.chainW( + ({ preRequestUpdatedEnvs, envForEffectiveRequest, updatedRequest }) => { + const finalRequest = { ...request, ...updatedRequest }; - return TE.tryCatch( - async () => { - const result = await getEffectiveRESTRequest( - finalRequest, - envForEffectiveRequest, - collectionVariables - ); - // Replace the updatedEnvs from getEffectiveRESTRequest with the one from pre-request script - // This preserves the global/selected separation - if (E.isRight(result)) { - return E.right({ - ...result.right, - updatedEnvs: preRequestUpdatedEnvs, - }); - } - return result; - }, - (reason) => error({ code: "PRE_REQUEST_SCRIPT_ERROR", data: reason }) - ); - }), + return TE.tryCatch( + async () => { + const result = await getEffectiveRESTRequest( + finalRequest, + envForEffectiveRequest, + collectionVariables + ); + // Replace the updatedEnvs from getEffectiveRESTRequest with the one from pre-request script + // This preserves the global/selected separation + if (E.isRight(result)) { + return E.right({ + ...result.right, + updatedEnvs: preRequestUpdatedEnvs, + }); + } + return result; + }, + (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) => diff --git a/packages/hoppscotch-common/src/helpers/auth/types/__tests__/digest.spec.ts b/packages/hoppscotch-common/src/helpers/auth/types/__tests__/digest.spec.ts index 9d404f6e..649f4490 100644 --- a/packages/hoppscotch-common/src/helpers/auth/types/__tests__/digest.spec.ts +++ b/packages/hoppscotch-common/src/helpers/auth/types/__tests__/digest.spec.ts @@ -188,7 +188,7 @@ describe("Digest Auth", () => { algorithm: "MD5", qop: "auth-int", opaque: "", - reqBody: '{"name": "test", "value": 123}', + reqBody: '{"name":"test","value":123}', }) expect(headers[0].value).toBe(mockDigestHeader) diff --git a/packages/hoppscotch-common/src/helpers/editor/linting/jsonc.ts b/packages/hoppscotch-common/src/helpers/editor/linting/jsonc.ts index 712b3700..c0f1a703 100644 --- a/packages/hoppscotch-common/src/helpers/editor/linting/jsonc.ts +++ b/packages/hoppscotch-common/src/helpers/editor/linting/jsonc.ts @@ -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 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 77e6ed0e..672f49fa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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