Skip to content

Commit 3ab62dd

Browse files
fix: harden apify api command and add local test coverage
Tighten input validation in `apify api`: extract parseParams/parseHeaders helpers, support multi-header JSON form, reject nested/non-scalar query params, and detect conflicts between the positional HTTP method and the --method flag. Derive the base URL from apifyClient.baseUrl so the v2 prefix and APIFY_CLIENT_BASE_URL are handled in one place, and hint at --list-endpoints on 404 responses. Register the command in the docs generator (and regenerate docs). Move fetch-api-endpoints out of `build` into `prepack` so local builds do not require network access. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 99957ac commit 3ab62dd

5 files changed

Lines changed: 319 additions & 50 deletions

File tree

docs/reference.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,50 @@ FLAGS
4444
not provided, the latest version will be used.
4545
```
4646

47+
##### `apify api`
48+
49+
```sh
50+
DESCRIPTION
51+
Makes an authenticated HTTP request to the Apify API and prints the response.
52+
The endpoint can be a relative path (e.g. "acts", "v2/acts", or "/v2/acts").
53+
The "v2/" prefix is added automatically if omitted.
54+
55+
You can also pass the HTTP method before the endpoint:
56+
apify api GET /v2/actor-runs
57+
apify api POST /v2/acts -d '{"name": "my-actor"}'
58+
59+
Use --params/-p to pass query parameters as JSON:
60+
apify api actor-runs -p '{"limit": 1, "desc": true}'
61+
62+
Use --list-endpoints to see all available API endpoints.
63+
For full documentation, see https://docs.apify.com/api/v2
64+
65+
USAGE
66+
$ apify api [methodOrEndpoint] [endpoint] [-d <value>] [-H <value>]
67+
[-l] [-X GET|POST|PUT|PATCH|DELETE] [-p <value>]
68+
69+
ARGUMENTS
70+
methodOrEndpoint The API endpoint path (e.g. "acts",
71+
"v2/acts", "/v2/users/me"), or an HTTP method followed by the
72+
endpoint (e.g. "GET /v2/users/me").
73+
endpoint The API endpoint path when the first
74+
argument is an HTTP method.
75+
76+
FLAGS
77+
-d, --body=<value> The request body (JSON string). Use
78+
"-" to read from stdin.
79+
-H, --header=<value> Additional HTTP header(s). Pass a
80+
single "key:value" string, or a JSON object like '{"X-Foo":
81+
"bar", "X-Baz": "qux"}' to send multiple headers.
82+
-l, --list-endpoints List all available Apify API
83+
endpoints.
84+
-X, --method=<option> The HTTP method to use. Defaults to
85+
GET.
86+
<options: GET|POST|PUT|PATCH|DELETE>
87+
-p, --params=<value> Query parameters as a JSON object,
88+
e.g. '{"limit": 1, "desc": true}'.
89+
```
90+
4791
##### `apify telemetry`
4892
4993
```sh

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,9 @@
1818
"format:fix": "biome format --write . && prettier --write \"**/*.{md,yml,yaml}\"",
1919
"clean": "rimraf dist",
2020
"fetch-api-endpoints": "tsx scripts/fetch-api-endpoints.ts",
21-
"build": "(yarn fetch-api-endpoints || echo 'Warning: Failed to fetch API endpoints, using existing file') && yarn clean && tsc && tsup",
21+
"build": "yarn clean && tsc && tsup",
2222
"build-bundles": "bun run scripts/build-cli-bundles.ts",
23-
"prepack": "yarn insert-cli-metadata && yarn build && yarn update-docs",
23+
"prepack": "yarn insert-cli-metadata && (yarn fetch-api-endpoints || echo 'Warning: Failed to fetch API endpoints, using existing file') && yarn build && yarn update-docs",
2424
"insert-cli-metadata": "tsx scripts/insert-cli-metadata.ts",
2525
"update-docs": "tsx scripts/generate-cli-docs.ts",
2626
"postinstall": "node -e \"console.log('We have an active developer community on Discord. You can find it on https://discord.gg/crawlee-apify-801163717915574323.');\"",

scripts/generate-cli-docs.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ const categories: Record<string, CommandsInCategory[]> = {
7575
//
7676
{ command: Commands.help },
7777
{ command: Commands.upgrade },
78+
{ command: Commands.api },
7879
{ command: Commands.telemetry },
7980
{ command: Commands.telemetryEnable },
8081
{ command: Commands.telemetryDisable },

src/commands/api.ts

Lines changed: 134 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import process from 'node:process';
2+
13
import chalk from 'chalk';
24

35
import { ApifyCommand, StdinMode } from '../lib/command-framework/apify-command.js';
@@ -10,6 +12,89 @@ import apiEndpoints from './api-endpoints.json' with { type: 'json' };
1012

1113
const 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+
1398
export 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(/^v2\/?/, '');
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

Comments
 (0)