1+ import process from 'node:process' ;
2+
13import chalk from 'chalk' ;
24
35import { ApifyCommand , StdinMode } from '../lib/command-framework/apify-command.js' ;
@@ -10,6 +12,89 @@ import apiEndpoints from './api-endpoints.json' with { type: 'json' };
1012
1113const HTTP_METHODS : string [ ] = [ 'GET' , 'POST' , 'PUT' , 'PATCH' , 'DELETE' ] ;
1214
15+ function parseParams ( raw : string | undefined ) : string {
16+ if ( ! raw ) {
17+ return '' ;
18+ }
19+
20+ let parsed : unknown ;
21+
22+ try {
23+ parsed = JSON . parse ( raw ) ;
24+ } catch {
25+ throw new Error ( 'Invalid JSON in --params flag. Please provide a valid JSON object, e.g. \'{"limit": 1}\'.' ) ;
26+ }
27+
28+ if ( parsed === null || typeof parsed !== 'object' || Array . isArray ( parsed ) ) {
29+ throw new Error ( '--params must be a JSON object (e.g. \'{"limit": 1}\').' ) ;
30+ }
31+
32+ const searchParams = new URLSearchParams ( ) ;
33+
34+ for ( const [ key , value ] of Object . entries ( parsed as Record < string , unknown > ) ) {
35+ if ( value === undefined || value === null ) {
36+ continue ;
37+ }
38+
39+ if ( typeof value === 'object' ) {
40+ throw new Error (
41+ `--params value for "${ key } " must be a scalar (string, number, or boolean), got ${ Array . isArray ( value ) ? 'array' : 'object' } . ` +
42+ 'Query parameters cannot contain nested objects or arrays.' ,
43+ ) ;
44+ }
45+
46+ searchParams . append ( key , String ( value ) ) ;
47+ }
48+
49+ return searchParams . toString ( ) ;
50+ }
51+
52+ function parseHeaders ( raw : string | undefined ) : Record < string , string > {
53+ if ( ! raw ) {
54+ return { } ;
55+ }
56+
57+ const trimmed = raw . trim ( ) ;
58+
59+ // JSON object form: --header '{"X-Foo": "bar", "X-Baz": "qux"}'
60+ if ( trimmed . startsWith ( '{' ) ) {
61+ let parsed : unknown ;
62+
63+ try {
64+ parsed = JSON . parse ( trimmed ) ;
65+ } catch {
66+ throw new Error ( 'Invalid JSON in --header flag. Provide a JSON object like \'{"X-Foo": "bar"}\'.' ) ;
67+ }
68+
69+ if ( parsed === null || typeof parsed !== 'object' || Array . isArray ( parsed ) ) {
70+ throw new Error ( '--header JSON must be an object mapping header names to string values.' ) ;
71+ }
72+
73+ const result : Record < string , string > = { } ;
74+
75+ for ( const [ key , value ] of Object . entries ( parsed as Record < string , unknown > ) ) {
76+ if ( typeof value !== 'string' ) {
77+ throw new Error ( `--header value for "${ key } " must be a string, got ${ typeof value } .` ) ;
78+ }
79+
80+ result [ key . trim ( ) ] = value . trim ( ) ;
81+ }
82+
83+ return result ;
84+ }
85+
86+ // "key:value" form
87+ const colonIndex = trimmed . indexOf ( ':' ) ;
88+
89+ if ( colonIndex === - 1 ) {
90+ throw new Error ( 'Header must be in "key:value" format, or a JSON object for multiple headers.' ) ;
91+ }
92+
93+ return {
94+ [ trimmed . slice ( 0 , colonIndex ) . trim ( ) ] : trimmed . slice ( colonIndex + 1 ) . trim ( ) ,
95+ } ;
96+ }
97+
1398export class ApiCommand extends ApifyCommand < typeof ApiCommand > {
1499 static override name = 'api' as const ;
15100
@@ -41,9 +126,8 @@ export class ApiCommand extends ApifyCommand<typeof ApiCommand> {
41126 static override flags = {
42127 method : Flags . string ( {
43128 char : 'X' ,
44- description : 'The HTTP method to use.' ,
129+ description : 'The HTTP method to use. Defaults to GET. ' ,
45130 choices : HTTP_METHODS ,
46- default : 'GET' ,
47131 } ) ,
48132 body : Flags . string ( {
49133 char : 'd' ,
@@ -53,7 +137,9 @@ export class ApiCommand extends ApifyCommand<typeof ApiCommand> {
53137 } ) ,
54138 header : Flags . string ( {
55139 char : 'H' ,
56- description : 'Additional HTTP header in "key:value" format (only one header supported).' ,
140+ description :
141+ 'Additional HTTP header(s). Pass a single "key:value" string, or a JSON object ' +
142+ 'like \'{"X-Foo": "bar", "X-Baz": "qux"}\' to send multiple headers.' ,
57143 required : false ,
58144 } ) ,
59145 params : Flags . string ( {
@@ -76,56 +162,66 @@ export class ApiCommand extends ApifyCommand<typeof ApiCommand> {
76162
77163 // Support "apify api GET /v2/users/me" syntax — if the first arg is an HTTP method,
78164 // use it as the method and the second arg as the endpoint
79- let { method } = this . flags ;
165+ const explicitMethodFlag = this . flags . method ?. toUpperCase ( ) ;
166+ let method : string | undefined ;
80167 let endpointArg = this . args . methodOrEndpoint ;
81168
82169 if ( endpointArg && HTTP_METHODS . includes ( endpointArg . toUpperCase ( ) ) ) {
83- method = endpointArg . toUpperCase ( ) ;
170+ const positionalMethod = endpointArg . toUpperCase ( ) ;
171+
172+ if ( explicitMethodFlag && explicitMethodFlag !== positionalMethod ) {
173+ throw new Error (
174+ `Conflicting HTTP methods: positional "${ positionalMethod } " vs --method "${ explicitMethodFlag } ". ` +
175+ 'Please specify the method only once.' ,
176+ ) ;
177+ }
178+
179+ method = positionalMethod ;
84180 endpointArg = this . args . endpoint ;
181+ } else {
182+ method = explicitMethodFlag ;
85183 }
86184
185+ method ??= 'GET' ;
186+
87187 if ( ! endpointArg ) {
88188 this . printHelp ( ) ;
89189 return ;
90190 }
91191
192+ // Parse and validate --params before any I/O so bad input fails fast
193+ const queryString = parseParams ( this . flags . params ) ;
194+
195+ // Parse and validate --header(s) before any I/O
196+ const customHeaders = parseHeaders ( this . flags . header ) ;
197+
198+ // Validate body is valid JSON before sending
199+ if ( this . flags . body ) {
200+ try {
201+ JSON . parse ( this . flags . body ) ;
202+ } catch {
203+ throw new Error ( 'Invalid JSON in --body flag. Please provide a valid JSON string.' ) ;
204+ }
205+ }
206+
92207 const apifyClient = await getLoggedClientOrThrow ( ) ;
93208 const token = apifyClient . token ! ;
94209
95- // Normalize endpoint — strip leading slash and ensure v2 prefix
210+ // Normalize endpoint — strip leading slash and any "v2/" prefix,
211+ // because apifyClient.baseUrl already ends in "/v2".
96212 let endpoint = endpointArg ;
97213
98214 if ( endpoint . startsWith ( '/' ) ) {
99215 endpoint = endpoint . slice ( 1 ) ;
100216 }
101217
102- // Auto-prepend "v2/" if the endpoint doesn't already include it,
103- // since all Apify API endpoints are under /v2/
104- if ( ! endpoint . startsWith ( 'v2/' ) ) {
105- endpoint = `v2/${ endpoint } ` ;
106- }
107-
108- const baseUrl = process . env . APIFY_CLIENT_BASE_URL || 'https://api.apify.com' ;
109- let url = `${ baseUrl } /${ endpoint } ` ;
218+ endpoint = endpoint . replace ( / ^ v 2 \/ ? / , '' ) ;
110219
111- // Append query params from --params flag
112- if ( this . flags . params ) {
113- let paramsObj : Record < string , unknown > ;
114-
115- try {
116- paramsObj = JSON . parse ( this . flags . params ) ;
117- } catch {
118- throw new Error ( 'Invalid JSON in --params flag. Please provide a valid JSON object, e.g. \'{"limit": 1}\'.' ) ;
119- }
120-
121- const searchParams = new URLSearchParams ( ) ;
122-
123- for ( const [ key , value ] of Object . entries ( paramsObj ) ) {
124- searchParams . append ( key , String ( value ) ) ;
125- }
220+ let url = `${ apifyClient . baseUrl } /${ endpoint } ` ;
126221
222+ if ( queryString ) {
127223 const separator = url . includes ( '?' ) ? '&' : '?' ;
128- url = `${ url } ${ separator } ${ searchParams . toString ( ) } ` ;
224+ url = `${ url } ${ separator } ${ queryString } ` ;
129225 }
130226
131227 // Build headers
@@ -138,24 +234,7 @@ export class ApiCommand extends ApifyCommand<typeof ApiCommand> {
138234 headers [ 'Content-Type' ] = 'application/json' ;
139235 }
140236
141- if ( this . flags . header ) {
142- const colonIndex = this . flags . header . indexOf ( ':' ) ;
143-
144- if ( colonIndex === - 1 ) {
145- throw new Error ( 'Header must be in "key:value" format.' ) ;
146- }
147-
148- headers [ this . flags . header . slice ( 0 , colonIndex ) . trim ( ) ] = this . flags . header . slice ( colonIndex + 1 ) . trim ( ) ;
149- }
150-
151- // Validate body is valid JSON before sending
152- if ( this . flags . body ) {
153- try {
154- JSON . parse ( this . flags . body ) ;
155- } catch {
156- throw new Error ( 'Invalid JSON in --body flag. Please provide a valid JSON string.' ) ;
157- }
158- }
237+ Object . assign ( headers , customHeaders ) ;
159238
160239 // Make the request
161240 const response = await fetch ( url , {
@@ -177,6 +256,13 @@ export class ApiCommand extends ApifyCommand<typeof ApiCommand> {
177256 error ( { message : `${ response . status } ${ response . statusText } : ${ responseText } ` } ) ;
178257 }
179258
259+ if ( response . status === 404 ) {
260+ simpleLog ( {
261+ message : `\nRun ${ chalk . cyan ( 'apify api --list-endpoints' ) } to see all available Apify API endpoints.` ,
262+ stdout : false ,
263+ } ) ;
264+ }
265+
180266 return ;
181267 }
182268
0 commit comments