@@ -15,6 +15,7 @@ type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
1515
1616type 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
771995export 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