Skip to content

Commit 786e890

Browse files
feat(api): add --data/-d flag and auto-detect JSON body in fields (#320)
## Summary Fixes [CLI-AF](https://sentry.sentry.io/issues/CLI-AF) — users passing raw JSON as `--raw-field` now get auto-correction instead of a cryptic error. ## Problem A user ran: ```bash sentry api /api/0/issues/7303827789/ -X PUT -f '{"status":"ignored","statusDetails":{"ignoreCount":1}}' ``` and got: `Error: Invalid field format: {...}. Expected key=value` Worse, on current main, `normalizeFields` would silently *mangle* the JSON by converting the first `:` to `=`, producing `{"status"="ignored",...}` — silent data corruption sent to the API. ## Changes ### 1. `--data`/`-d` flag (like curl) New explicit inline JSON body flag, following curl convention: ```bash sentry api issues/123/ -X PUT -d '{"status":"resolved"}' ``` - Tries `JSON.parse()`, falls back to raw string (like `--input`) - Mutually exclusive with `--input` (clear `ValidationError` if both used) ### 2. JSON auto-detection in field values When someone passes `-f '{"status":"ignored"}'`, we detect it's JSON (no `=`, starts with `{`/`[`, valid `JSON.parse`), use it as the request body, and hint: ``` hint: '{"status":"ignored"}' was used as the request body. Use --data/-d to pass inline JSON next time. ``` If there are also normal `key=value` fields, they're merged into the JSON body. Multiple bare JSON fields throw a `ValidationError`. ### 3. `normalizeFields` JSON guard Skip colon→equals auto-correction for strings starting with `{` or `[`. This fixes the **silent data corruption bug** where JSON was mangled into garbage. ## Tests 30 new tests across 4 describe blocks: - `normalizeFields` — JSON passthrough (objects, arrays, mixed with other fields) - `parseDataBody` — valid JSON, arrays, nested, invalid, partial - `extractJsonBody` — extraction + hint, remaining fields, invalid JSON, multiple JSON error, key=value with JSON value, preview truncation - `buildFromFields` — CLI-AF scenario, merge JSON + fields, GET params routing, no-JSON passthrough All 2312 unit tests pass. Typecheck and lint clean. --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent 63d397e commit 786e890

File tree

6 files changed

+1043
-95
lines changed

6 files changed

+1043
-95
lines changed

AGENTS.md

Lines changed: 38 additions & 76 deletions
Large diffs are not rendered by default.

plugins/sentry-cli/skills/sentry-cli/SKILL.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,7 @@ Make an authenticated API request
354354

355355
**Flags:**
356356
- `-X, --method <value> - The HTTP method for the request - (default: "GET")`
357+
- `-d, --data <value> - Inline JSON body for the request (like curl -d)`
357358
- `-F, --field <value>... - Add a typed parameter (key=value, key[sub]=value, key[]=value)`
358359
- `-f, --raw-field <value>... - Add a string parameter without JSON parsing`
359360
- `-H, --header <value>... - Add a HTTP request header in key:value format`

src/commands/api.ts

Lines changed: 238 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
1515

1616
type ApiFlags = {
1717
readonly method: HttpMethod;
18+
readonly data?: string;
1819
readonly field?: string[];
1920
readonly "raw-field"?: string[];
2021
readonly header?: string[];
@@ -333,6 +334,13 @@ export function normalizeFields(
333334
return field;
334335
}
335336

337+
// JSON-shaped strings (starting with { or [) must not be "corrected" —
338+
// the colon inside is JSON syntax, not a key:value separator. Let the
339+
// downstream pipeline handle it (extractJsonBody or processField error).
340+
if (field.startsWith("{") || field.startsWith("[")) {
341+
return field;
342+
}
343+
336344
const colonIndex = field.indexOf(":");
337345
// ':' must exist and not be the very first character (that would make an
338346
// empty key, which the parser rejects regardless)
@@ -600,6 +608,109 @@ export function parseHeaders(headers: string[]): Record<string, string> {
600608

601609
// Request Body Building
602610

611+
/**
612+
* Parse an inline string as a request body. Tries JSON first; falls back to
613+
* the raw string so non-JSON payloads still work.
614+
*
615+
* @param data - Raw string from --data flag
616+
* @returns Parsed JSON object/array, or the original string
617+
* @internal Exported for testing
618+
*/
619+
export function parseDataBody(
620+
data: string
621+
): Record<string, unknown> | unknown[] | string {
622+
try {
623+
return JSON.parse(data) as Record<string, unknown> | unknown[];
624+
} catch {
625+
return data;
626+
}
627+
}
628+
629+
/**
630+
* Try to parse a single field as a bare JSON **object or array** body.
631+
*
632+
* The `startsWith` guard is intentional — not just an optimisation. It
633+
* restricts detection to objects (`{`) and arrays (`[`), excluding JSON
634+
* primitives like `42`, `true`, `"string"`. Without this guard those
635+
* primitives would be extracted as the body, and downstream code (e.g. the
636+
* `k in body` key-conflict check) would throw a `TypeError` because the `in`
637+
* operator requires an object on the right-hand side.
638+
*
639+
* @internal
640+
*/
641+
function tryParseJsonField(
642+
field: string
643+
): Record<string, unknown> | unknown[] | undefined {
644+
if (field.includes("=")) {
645+
return;
646+
}
647+
if (!(field.startsWith("{") || field.startsWith("["))) {
648+
return;
649+
}
650+
651+
try {
652+
return JSON.parse(field) as Record<string, unknown> | unknown[];
653+
} catch {
654+
return;
655+
}
656+
}
657+
658+
/**
659+
* Scan a field list for bare JSON **object or array** values (no `=`) and
660+
* extract the first one as the intended request body. This handles the
661+
* common mistake of passing `-f '{"status":"ignored"}'` instead of
662+
* `-d '{"status":"ignored"}'`.
663+
*
664+
* Detection is conservative: the field must have no `=`, start with `{` or
665+
* `[`, and parse as valid JSON. Only one JSON body is allowed — multiple
666+
* JSON fields are ambiguous and produce a {@link ValidationError}.
667+
*
668+
* @returns An object with the extracted `body` (if any) and the `remaining`
669+
* fields that are normal key=value entries, or `undefined` if the input
670+
* was empty/undefined.
671+
* @internal Exported for testing
672+
*/
673+
export function extractJsonBody(
674+
fields: string[] | undefined,
675+
stderr: Writer
676+
): { body?: Record<string, unknown> | unknown[]; remaining?: string[] } {
677+
if (!fields || fields.length === 0) {
678+
return {};
679+
}
680+
681+
let jsonBody: Record<string, unknown> | unknown[] | undefined;
682+
const remaining: string[] = [];
683+
684+
for (const field of fields) {
685+
const parsed = tryParseJsonField(field);
686+
687+
if (parsed === undefined) {
688+
remaining.push(field);
689+
continue;
690+
}
691+
692+
if (jsonBody !== undefined) {
693+
throw new ValidationError(
694+
"Multiple JSON bodies detected in field arguments. " +
695+
"Use --data/-d to pass an inline JSON body explicitly.",
696+
"field"
697+
);
698+
}
699+
700+
jsonBody = parsed;
701+
const preview = field.length > 60 ? `${field.substring(0, 57)}...` : field;
702+
stderr.write(
703+
`hint: '${preview}' was used as the request body. ` +
704+
"Use --data/-d to pass inline JSON next time.\n"
705+
);
706+
}
707+
708+
return {
709+
body: jsonBody,
710+
remaining: remaining.length > 0 ? remaining : undefined,
711+
};
712+
}
713+
603714
/**
604715
* Build request body from --input flag (file or stdin).
605716
* Tries to parse the content as JSON, otherwise returns as string.
@@ -766,6 +877,119 @@ export function handleResponse(
766877
}
767878
}
768879

880+
/**
881+
* Build body and params from field flags, auto-detecting bare JSON bodies.
882+
*
883+
* Runs colon-to-equals normalization, extracts any JSON body passed as a
884+
* field value (with a stderr hint about `--data`), and routes the remaining
885+
* fields to body or query params based on the HTTP method.
886+
*
887+
* @internal Exported for testing
888+
*/
889+
export function buildFromFields(
890+
method: HttpMethod,
891+
flags: Pick<ApiFlags, "field" | "raw-field">,
892+
stderr: Writer
893+
): {
894+
body?: Record<string, unknown> | unknown[];
895+
params?: Record<string, string | string[]>;
896+
} {
897+
const field = normalizeFields(flags.field, stderr);
898+
let rawField = normalizeFields(flags["raw-field"], stderr);
899+
900+
// Auto-detect bare JSON passed as a field value (common mistake).
901+
// GET requests don't have a body — skip detection so JSON-shaped values
902+
// fall through to query-param routing (which will throw a clear error).
903+
let body: Record<string, unknown> | unknown[] | undefined;
904+
if (method !== "GET") {
905+
const extracted = extractJsonBody(rawField, stderr);
906+
body = extracted.body;
907+
rawField = extracted.remaining;
908+
}
909+
910+
// Route remaining fields to body (merge) or params based on HTTP method
911+
const options = prepareRequestOptions(method, field, rawField);
912+
if (options.body) {
913+
if (Array.isArray(body)) {
914+
// Can't meaningfully merge key=value fields into a JSON array body.
915+
throw new ValidationError(
916+
"Cannot combine a JSON array body with field flags (-F/-f). " +
917+
"Use --data/-d to pass the array as the full body without extra fields.",
918+
"field"
919+
);
920+
}
921+
if (body) {
922+
// Detect top-level key conflicts before merging — a shallow spread would
923+
// silently drop nested fields from the JSON body (e.g. statusDetails.ignoreCount
924+
// overwritten by statusDetails[minCount]=5).
925+
const conflicts = Object.keys(options.body).filter(
926+
(k) => k in (body as Record<string, unknown>)
927+
);
928+
if (conflicts.length > 0) {
929+
throw new ValidationError(
930+
`Field flag(s) conflict with detected JSON body at key(s): ${conflicts.join(", ")}. ` +
931+
"Use --data/-d to pass the full JSON body, or use only field flags (-F/-f).",
932+
"field"
933+
);
934+
}
935+
}
936+
// Merge field-built key=value entries into the auto-detected JSON object body
937+
body =
938+
body && typeof body === "object"
939+
? { ...(body as Record<string, unknown>), ...options.body }
940+
: options.body;
941+
}
942+
943+
return { body, params: options.params };
944+
}
945+
946+
/**
947+
* Resolve the request body and query params from the user-provided flags.
948+
*
949+
* Priority order: `--data` > `--input` > field flags (`-F`/`-f`).
950+
* Mutually-exclusive combinations throw {@link ValidationError}.
951+
*
952+
* @returns body and params ready for the API request
953+
* @internal Exported for testing
954+
*/
955+
export async function resolveBody(
956+
flags: Pick<ApiFlags, "method" | "data" | "input" | "field" | "raw-field">,
957+
stdin: NodeJS.ReadStream & { fd: 0 },
958+
stderr: Writer
959+
): Promise<{
960+
body?: Record<string, unknown> | unknown[] | string;
961+
params?: Record<string, string | string[]>;
962+
}> {
963+
if (flags.data !== undefined && flags.input !== undefined) {
964+
throw new ValidationError(
965+
"Cannot use --data and --input together. " +
966+
"Use --data/-d for inline JSON, or --input for file/stdin.",
967+
"data"
968+
);
969+
}
970+
971+
if (
972+
flags.data !== undefined &&
973+
(flags.field?.length || flags["raw-field"]?.length)
974+
) {
975+
throw new ValidationError(
976+
"Cannot use --data with --field or --raw-field. " +
977+
"Use --data/-d for a full JSON body, or -F/-f for individual fields.",
978+
"data"
979+
);
980+
}
981+
982+
if (flags.data !== undefined) {
983+
return { body: parseDataBody(flags.data) };
984+
}
985+
986+
if (flags.input !== undefined) {
987+
return { body: await buildBodyFromInput(flags.input, stdin) };
988+
}
989+
990+
return buildFromFields(flags.method, flags, stderr);
991+
}
992+
769993
// Command Definition
770994

771995
export const apiCommand = buildCommand({
@@ -775,6 +999,9 @@ export const apiCommand = buildCommand({
775999
"Make a raw API request to the Sentry API. Similar to 'gh api' for GitHub. " +
7761000
"The endpoint is relative to /api/0/ (do not include the prefix). " +
7771001
"Authentication is handled automatically using your stored credentials.\n\n" +
1002+
"Body options:\n" +
1003+
' --data/-d \'{"key":"value"}\' Inline JSON body (like curl -d)\n' +
1004+
' --input/-i file.json Read body from file (or "-" for stdin)\n\n' +
7781005
"Field syntax (--field/-F):\n" +
7791006
" key=value Simple field (values parsed as JSON if valid)\n" +
7801007
" key[sub]=value Nested object: {key: {sub: value}}\n" +
@@ -784,6 +1011,7 @@ export const apiCommand = buildCommand({
7841011
"Examples:\n" +
7851012
" sentry api organizations/\n" +
7861013
" sentry api issues/123/ -X PUT -F status=resolved\n" +
1014+
' sentry api issues/123/ -X PUT -d \'{"status":"resolved"}\'\n' +
7871015
" sentry api projects/my-org/my-project/ -F options[sampleRate]=0.5\n" +
7881016
" sentry api teams/my-org/my-team/members/ -F user[email]=user@example.com",
7891017
},
@@ -806,6 +1034,13 @@ export const apiCommand = buildCommand({
8061034
default: "GET" as const,
8071035
placeholder: "method",
8081036
},
1037+
data: {
1038+
kind: "parsed",
1039+
parse: String,
1040+
brief: "Inline JSON body for the request (like curl -d)",
1041+
optional: true,
1042+
placeholder: "json",
1043+
},
8091044
field: {
8101045
kind: "parsed",
8111046
parse: String,
@@ -853,6 +1088,7 @@ export const apiCommand = buildCommand({
8531088
},
8541089
aliases: {
8551090
X: "method",
1091+
d: "data",
8561092
F: "field",
8571093
f: "raw-field",
8581094
H: "header",
@@ -869,25 +1105,8 @@ export const apiCommand = buildCommand({
8691105
// Normalize endpoint to ensure trailing slash (Sentry API requirement)
8701106
const normalizedEndpoint = normalizeEndpoint(endpoint);
8711107

872-
// Build request body/params from --input, --field, or --raw-field
873-
// --input takes precedence; otherwise route fields based on HTTP method
874-
let body: Record<string, unknown> | string | undefined;
875-
let params: Record<string, string | string[]> | undefined;
876-
877-
if (flags.input !== undefined) {
878-
// --input takes precedence for body content
879-
body = await buildBodyFromInput(flags.input, stdin);
880-
} else {
881-
// Auto-correct ':'-separated fields (e.g. -F status:resolved → -F status=resolved)
882-
// before routing to body or params so the correction applies everywhere.
883-
const field = normalizeFields(flags.field, stderr);
884-
const rawField = normalizeFields(flags["raw-field"], stderr);
885-
886-
// Route fields to body or params based on HTTP method
887-
const options = prepareRequestOptions(flags.method, field, rawField);
888-
body = options.body;
889-
params = options.params;
890-
}
1108+
// Resolve body and query params from flags (--data, --input, or fields)
1109+
const { body, params } = await resolveBody(flags, stdin, stderr);
8911110

8921111
const headers =
8931112
flags.header && flags.header.length > 0

0 commit comments

Comments
 (0)