Skip to content

Commit 4af2f87

Browse files
committed
fix(api): guard GET from JSON body extraction, throw on key conflicts, simplify tryParseJsonField
Addresses BugBot review comments: - GET + bare JSON raw-field: buildFromFields now skips extractJsonBody for GET requests, so a JSON-shaped raw-field falls through to query-param routing (which throws a clear ValidationError) rather than silently creating a GET body. - Shallow merge key conflict: when auto-detected JSON body and field flags share a top-level key, throw ValidationError before merging. A shallow spread would silently drop nested data (e.g. statusDetails.ignoreCount lost when statusDetails[minCount]=5 is also passed). Suggest --data/-d. Simplify tryParseJsonField by removing the redundant startsWith check — JSON.parse handles non-JSON strings via the catch branch; the check was just an optimization that added noise. Note: the startsWith guard in normalizeFields is intentionally kept (broader than JSON.parse — it protects malformed-but-JSON-shaped strings like {broken:json} from colon mangling).
1 parent 20fa499 commit 4af2f87

File tree

2 files changed

+75
-10
lines changed

2 files changed

+75
-10
lines changed

src/commands/api.ts

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -636,9 +636,6 @@ function tryParseJsonField(field: string): unknown | undefined {
636636
if (field.includes("=")) {
637637
return;
638638
}
639-
if (!(field.startsWith("{") || field.startsWith("["))) {
640-
return;
641-
}
642639

643640
try {
644641
return JSON.parse(field);
@@ -652,8 +649,8 @@ function tryParseJsonField(field: string): unknown | undefined {
652649
* as the intended request body. This handles the common mistake of passing
653650
* `-f '{"status":"ignored"}'` instead of `-d '{"status":"ignored"}'`.
654651
*
655-
* Detection is conservative: the field must have no `=`, start with `{` or
656-
* `[`, *and* parse as valid JSON. Only one JSON body is allowed — multiple
652+
* Detection is conservative: the field must have no `=` and parse as valid
653+
* JSON. Only one JSON body is allowed — multiple
657654
* JSON fields are ambiguous and produce a {@link ValidationError}.
658655
*
659656
* @returns An object with the extracted `body` (if any) and the `remaining`
@@ -889,10 +886,14 @@ export function buildFromFields(
889886
let rawField = normalizeFields(flags["raw-field"], stderr);
890887

891888
// Auto-detect bare JSON passed as a field value (common mistake).
892-
// Extract it as the body and hint about --data/-d for next time.
893-
const extracted = extractJsonBody(rawField, stderr);
894-
let body: Record<string, unknown> | unknown[] | undefined = extracted.body;
895-
rawField = extracted.remaining;
889+
// GET requests don't have a body — skip detection so JSON-shaped values
890+
// fall through to query-param routing (which will throw a clear error).
891+
let body: Record<string, unknown> | unknown[] | undefined;
892+
if (method !== "GET") {
893+
const extracted = extractJsonBody(rawField, stderr);
894+
body = extracted.body;
895+
rawField = extracted.remaining;
896+
}
896897

897898
// Route remaining fields to body (merge) or params based on HTTP method
898899
const options = prepareRequestOptions(method, field, rawField);
@@ -905,10 +906,25 @@ export function buildFromFields(
905906
"field"
906907
);
907908
}
909+
if (body) {
910+
// Detect top-level key conflicts before merging — a shallow spread would
911+
// silently drop nested fields from the JSON body (e.g. statusDetails.ignoreCount
912+
// overwritten by statusDetails[minCount]=5).
913+
const conflicts = Object.keys(options.body).filter(
914+
(k) => k in (body as Record<string, unknown>)
915+
);
916+
if (conflicts.length > 0) {
917+
throw new ValidationError(
918+
`Field flag(s) conflict with detected JSON body at key(s): ${conflicts.join(", ")}. ` +
919+
"Use --data/-d to pass the full JSON body, or use only field flags (-F/-f).",
920+
"field"
921+
);
922+
}
923+
}
908924
// Merge field-built key=value entries into the auto-detected JSON object body
909925
body =
910926
body && typeof body === "object"
911-
? { ...body, ...options.body }
927+
? { ...(body as Record<string, unknown>), ...options.body }
912928
: options.body;
913929
}
914930

test/commands/api.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1416,4 +1416,53 @@ describe("buildFromFields", () => {
14161416
)
14171417
).toThrow(/Cannot combine a JSON array/);
14181418
});
1419+
1420+
test("does NOT extract JSON body for GET — falls through to query-param routing (which throws)", () => {
1421+
const stderr = createMockWriter();
1422+
// GET with a bare JSON field: no body extracted, falls to buildRawQueryParams
1423+
// which throws "Invalid field format" since there is no '='
1424+
expect(() =>
1425+
buildFromFields("GET", { "raw-field": ['{"status":"ignored"}'] }, stderr)
1426+
).toThrow(ValidationError);
1427+
// No hint should have been emitted (JSON extraction was skipped for GET)
1428+
expect(stderr.output).toBe("");
1429+
});
1430+
1431+
test("throws ValidationError when field flags conflict with JSON body at same top-level key", () => {
1432+
const stderr = createMockWriter();
1433+
expect(() =>
1434+
buildFromFields(
1435+
"PUT",
1436+
{
1437+
"raw-field": [
1438+
'{"status":"ignored","statusDetails":{"ignoreCount":1}}',
1439+
],
1440+
field: ["statusDetails[minCount]=5"],
1441+
},
1442+
stderr
1443+
)
1444+
).toThrow(ValidationError);
1445+
expect(() =>
1446+
buildFromFields(
1447+
"PUT",
1448+
{
1449+
"raw-field": [
1450+
'{"status":"ignored","statusDetails":{"ignoreCount":1}}',
1451+
],
1452+
field: ["statusDetails[minCount]=5"],
1453+
},
1454+
stderr
1455+
)
1456+
).toThrow(/conflict/i);
1457+
});
1458+
1459+
test("non-conflicting keys from JSON body and field flags merge cleanly", () => {
1460+
const stderr = createMockWriter();
1461+
const result = buildFromFields(
1462+
"PUT",
1463+
{ "raw-field": ['{"status":"ignored"}'], field: ["assignee=me"] },
1464+
stderr
1465+
);
1466+
expect(result.body).toEqual({ status: "ignored", assignee: "me" });
1467+
});
14191468
});

0 commit comments

Comments
 (0)