Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 24 additions & 16 deletions docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,9 @@ DESCRIPTION
Use --list-endpoints to see all available API endpoints.

USAGE
$ apify api [methodOrEndpoint] [endpoint] [-d <value>] [-H <value>]
[-l] [-X GET|POST|PUT|PATCH|DELETE] [-p <value>]
$ apify api [methodOrEndpoint] [endpoint] [-d <value>]
[--describe <value> | -l | -s <value>] [-H <value>]
[-X GET|POST|PUT|PATCH|DELETE] [-p <value>]

ARGUMENTS
methodOrEndpoint The API endpoint path (e.g. "acts",
Expand All @@ -66,20 +67,27 @@ ARGUMENTS
argument is an HTTP method.

FLAGS
-d, --body=<value> The request body (JSON string). Use
"-" to read from stdin.
-H, --header=<value> Additional HTTP header(s). Pass a
single "key:value" string, or a JSON object like '{"X-Foo":
"bar", "X-Baz": "qux"}' to send multiple headers. The flag
can only be used once; use the JSON form for multiple
headers.
-l, --list-endpoints List all available Apify API
endpoints.
-X, --method=<option> The HTTP method to use. Defaults to
GET.
<options: GET|POST|PUT|PATCH|DELETE>
-p, --params=<value> Query parameters as a JSON object,
e.g. '{"limit": 1, "desc": true}'.
-d, --body=<value> The request body (JSON string).
Use "-" to read from stdin.
--describe=<value> Describe an endpoint: print every HTTP
method on a path, its summary, and path parameters.
Accepts a path like "actor-runs/{runId}" or
"/v2/actor-runs/{runId}".
-H, --header=<value> Additional HTTP header(s). Pass a
single "key:value" string, or a JSON object like
'{"X-Foo": "bar", "X-Baz": "qux"}' to send multiple
headers. The flag can only be used once; use the JSON form
for multiple headers.
-l, --list-endpoints List all available Apify API
endpoints.
-X, --method=<option> The HTTP method to use. Defaults
to GET.
<options: GET|POST|PUT|PATCH|DELETE>
-p, --params=<value> Query parameters as a JSON object,
e.g. '{"limit": 1, "desc": true}'.
-s, --search=<value> Filter --list-endpoints by a
space-separated query. Each token must appear
(case-insensitive) in method, path, or summary.
```

##### `apify telemetry`
Expand Down
253 changes: 225 additions & 28 deletions src/commands/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,38 @@ function parseHeaders(raw: string | undefined): Record<string, string> {
};
}

const METHOD_COLORS: Record<string, (text: string) => string> = {
GET: chalk.green,
POST: chalk.yellow,
PUT: chalk.blue,
PATCH: chalk.cyan,
DELETE: chalk.red,
};

function formatEndpointLine(ep: Endpoint): string {
const colorize = METHOD_COLORS[ep.method] || chalk.white;
const methodStr = colorize(ep.method.padEnd(7));
const summaryStr = ep.summary ? chalk.gray(` ${ep.summary}`) : '';
return `${methodStr} ${ep.path}${summaryStr}`;
}

const LIST_ENDPOINTS_HINT = `Run ${chalk.cyan('apify api --list-endpoints')} to see all available Apify API endpoints.`;

function printSuggestions(suggestions: Endpoint[]) {
if (suggestions.length > 0) {
simpleLog({ message: `\nDid you mean:`, stdout: false });

for (const ep of suggestions) {
simpleLog({ message: ` ${formatEndpointLine(ep)}`, stdout: false });
}
}

simpleLog({ message: `\n${LIST_ENDPOINTS_HINT}`, stdout: false });
}

export class ApiCommand extends ApifyCommand<typeof ApiCommand> {
private cachedEndpoints: Endpoint[] | null = null;

static override name = 'api' as const;

static override description =
Expand Down Expand Up @@ -140,6 +171,14 @@ export class ApiCommand extends ApifyCommand<typeof ApiCommand> {
description: 'List all available Apify API endpoints.',
command: 'apify api --list-endpoints',
},
{
description: 'Search for endpoints matching a query.',
command: 'apify api --list-endpoints --search "actor run"',
},
{
description: 'Describe an endpoint (methods, summary, path params).',
command: 'apify api --describe actor-runs/{runId}',
},
];

static override docsUrl = 'https://docs.apify.com/api/v2';
Expand Down Expand Up @@ -186,12 +225,37 @@ export class ApiCommand extends ApifyCommand<typeof ApiCommand> {
char: 'l',
description: 'List all available Apify API endpoints.',
default: false,
exclusive: ['describe'],
}),
search: Flags.string({
char: 's',
description:
'Filter --list-endpoints by a space-separated query. ' +
'Each token must appear (case-insensitive) in method, path, or summary.',
required: false,
exclusive: ['describe'],
}),
describe: Flags.string({
description:
'Describe an endpoint: print every HTTP method on a path, its summary, ' +
'and path parameters. Accepts a path like "actor-runs/{runId}" or "/v2/actor-runs/{runId}".',
required: false,
exclusive: ['list-endpoints', 'search'],
}),
};

async run() {
if (this.flags.search && !this.flags.listEndpoints) {
throw new Error('The --search flag can only be used together with --list-endpoints.');
}

if (this.flags.describe) {
await this.describeEndpoint(this.flags.describe);
return;
}

if (this.flags.listEndpoints) {
await this.printEndpoints();
await this.printEndpoints(this.flags.search);
return;
}

Expand Down Expand Up @@ -248,15 +312,8 @@ export class ApiCommand extends ApifyCommand<typeof ApiCommand> {
const apifyClient = await getLoggedClientOrThrow();
const token = apifyClient.token!;

// Normalize endpoint — strip leading slash and any "v2/" prefix,
// because apifyClient.baseUrl already ends in "/v2".
let endpoint = endpointArg;

if (endpoint.startsWith('/')) {
endpoint = endpoint.slice(1);
}

endpoint = endpoint.replace(/^v2\//i, '');
// apifyClient.baseUrl already ends in "/v2"
const endpoint = normalizePath(endpointArg);

let url = `${apifyClient.baseUrl}/${endpoint}`;

Expand Down Expand Up @@ -311,10 +368,7 @@ export class ApiCommand extends ApifyCommand<typeof ApiCommand> {
}

if (response.status === 404) {
simpleLog({
message: `\nRun ${chalk.cyan('apify api --list-endpoints')} to see all available Apify API endpoints.`,
stdout: false,
});
await this.print404Suggestions(endpoint);
}

return;
Expand All @@ -330,23 +384,72 @@ export class ApiCommand extends ApifyCommand<typeof ApiCommand> {
}
}

private async printEndpoints() {
const endpoints = await fetchEndpoints();
private async getEndpoints(): Promise<Endpoint[]> {
this.cachedEndpoints ??= await fetchEndpoints();
return this.cachedEndpoints;
}

private async printEndpoints(search?: string) {
let endpoints = await this.getEndpoints();

const methodColors: Record<string, (text: string) => string> = {
GET: chalk.green,
POST: chalk.yellow,
PUT: chalk.blue,
PATCH: chalk.cyan,
DELETE: chalk.red,
};
if (search) {
endpoints = filterEndpoints(endpoints, search);

if (endpoints.length === 0) {
simpleLog({ message: `No endpoints matched the query "${search}".`, stdout: false });
return;
}
}

for (const ep of endpoints) {
console.log(formatEndpointLine(ep));
}
}

private async describeEndpoint(input: string) {
const normalized = normalizePath(input);
const endpoints = await this.getEndpoints();

const matches = endpoints.filter((ep) => normalizePath(ep.path) === normalized);

if (matches.length > 0) {
const pathParams = extractPathParams(matches[0].path);

console.log(chalk.bold(matches[0].path));
console.log('');

for (const ep of matches) {
const colorize = METHOD_COLORS[ep.method] || chalk.white;
console.log(` ${colorize(ep.method.padEnd(7))} ${ep.summary || chalk.gray('(no summary)')}`);
}

if (pathParams.length > 0) {
console.log('');
console.log(chalk.bold('Path parameters:'));
for (const param of pathParams) {
console.log(` ${chalk.yellow(`{${param}}`)}`);
}
}

console.log('');
console.log(chalk.gray(`Docs: https://docs.apify.com/api/v2`));
return;
}

simpleLog({ message: `No endpoint found for "${input}".`, stdout: false });

for (const { method, path, summary } of endpoints) {
const colorize = methodColors[method] || chalk.white;
const methodStr = colorize(method.padEnd(7));
const summaryStr = summary ? chalk.gray(` ${summary}`) : '';
const suggestions = findClosestEndpoints(endpoints, normalized);
printSuggestions(suggestions);
}

console.log(`${methodStr} ${path}${summaryStr}`);
private async print404Suggestions(endpoint: string) {
try {
const endpoints = await this.getEndpoints();
const suggestions = findClosestEndpoints(endpoints, endpoint);
printSuggestions(suggestions);
} catch {
// Silently ignore if we can't fetch the spec for suggestions
simpleLog({ message: `\n${LIST_ENDPOINTS_HINT}`, stdout: false });
}
}
}
Expand Down Expand Up @@ -387,3 +490,97 @@ async function fetchEndpoints(): Promise<Endpoint[]> {

return endpoints;
}

function normalizePath(input: string): string {
let path = input;

if (path.startsWith('/')) {
path = path.slice(1);
}

path = path.replace(/^v2\//i, '');

return path;
}

function filterEndpoints(endpoints: Endpoint[], query: string): Endpoint[] {
const tokens = query.toLowerCase().split(/\s+/).filter(Boolean);

if (tokens.length === 0) {
return endpoints;
}

return endpoints.filter((ep) => {
const haystack = `${ep.method} ${ep.path} ${ep.summary}`.toLowerCase();
return tokens.every((token) => haystack.includes(token));
});
}

function extractPathParams(path: string): string[] {
const matches = path.match(/\{([^}]+)\}/g);

if (!matches) {
return [];
}

return matches.map((m) => m.slice(1, -1));
}

function findClosestEndpoints(endpoints: Endpoint[], input: string, maxPaths = 5): Endpoint[] {
const normalized = input.toLowerCase();
const inputSegments = normalized.split('/').filter(Boolean);

const pathScores = new Map<string, number>();

for (const ep of endpoints) {
if (pathScores.has(ep.path)) {
continue;
}

const normalizedEpPath = normalizePath(ep.path).toLowerCase();

let score = 0;

if (normalizedEpPath.includes(normalized) || normalized.includes(normalizedEpPath)) {
score += 10;
}

const epSegments = normalizedEpPath.split('/').filter(Boolean);

const len = Math.min(inputSegments.length, epSegments.length);

for (let i = 0; i < len; i++) {
if (epSegments[i] === inputSegments[i]) {
score += 2;
} else if (epSegments[i].startsWith('{')) {
score += 1;
}
}

if (inputSegments.length === epSegments.length) {
score += 1;
}

if (score > 0) {
pathScores.set(ep.path, score);
}
}

const sortedPaths = [...pathScores.entries()]
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.slice(0, maxPaths)
.map(([path]) => path);

const pathSet = new Set(sortedPaths);
const matchedByPath = new Map<string, Endpoint[]>();

for (const ep of endpoints) {
if (pathSet.has(ep.path)) {
const list = matchedByPath.get(ep.path) || [];
list.push(ep);
matchedByPath.set(ep.path, list);
}
}

return sortedPaths.flatMap((path) => matchedByPath.get(path) || []);
}
Loading
Loading