diff --git a/package.json b/package.json index 5c259df7a..cee4d3bac 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ "format": "biome format . && prettier --check \"**/*.{md,yml,yaml}\"", "format:fix": "biome format --write . && prettier --write \"**/*.{md,yml,yaml}\"", "clean": "rimraf dist", - "build": "yarn clean && tsc && tsup", + "fetch-api-endpoints": "tsx scripts/fetch-api-endpoints.ts", + "build": "(yarn fetch-api-endpoints || echo 'Warning: Failed to fetch API endpoints, using existing file') && yarn clean && tsc && tsup", "build-bundles": "bun run scripts/build-cli-bundles.ts", "prepack": "yarn insert-cli-metadata && yarn build && yarn update-docs", "insert-cli-metadata": "tsx scripts/insert-cli-metadata.ts", diff --git a/scripts/fetch-api-endpoints.ts b/scripts/fetch-api-endpoints.ts new file mode 100644 index 000000000..bcb2910ac --- /dev/null +++ b/scripts/fetch-api-endpoints.ts @@ -0,0 +1,52 @@ +/** + * Fetches the Apify OpenAPI spec and extracts a minimal endpoint catalog + * (method, path, summary) for use by the `apify api --list-endpoints` flag. + */ + +import { writeFile } from 'node:fs/promises'; + +const OPENAPI_URL = 'https://docs.apify.com/api/openapi.json'; +const OUTPUT_PATH = new URL('../src/commands/api-endpoints.json', import.meta.url); + +interface OpenAPISpec { + paths: Record>; +} + +interface Endpoint { + method: string; + path: string; + summary: string; +} + +const HTTP_METHODS = new Set(['get', 'post', 'put', 'patch', 'delete']); + +console.log(`Fetching OpenAPI spec from ${OPENAPI_URL}...`); + +const response = await fetch(OPENAPI_URL); + +if (!response.ok) { + throw new Error(`Failed to fetch OpenAPI spec: ${response.status} ${response.statusText}`); +} + +const spec = (await response.json()) as OpenAPISpec; + +const endpoints: Endpoint[] = []; + +for (const [path, methods] of Object.entries(spec.paths)) { + for (const [method, details] of Object.entries(methods)) { + if (HTTP_METHODS.has(method)) { + endpoints.push({ + method: method.toUpperCase(), + path, + summary: details.summary || '', + }); + } + } +} + +// Sort by path, then method +endpoints.sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method)); + +await writeFile(OUTPUT_PATH, `${JSON.stringify(endpoints, null, '\t')}\n`); + +console.log(`Extracted ${endpoints.length} endpoints to ${OUTPUT_PATH.pathname}`); diff --git a/src/commands/_register.ts b/src/commands/_register.ts index 16657acd8..a6d5197b8 100644 --- a/src/commands/_register.ts +++ b/src/commands/_register.ts @@ -9,6 +9,7 @@ import { ActorGetValueCommand } from './actor/get-value.js'; import { ActorPushDataCommand } from './actor/push-data.js'; import { ActorSetValueCommand } from './actor/set-value.js'; import { ActorsIndexCommand } from './actors/_index.js'; +import { ApiCommand } from './api.js'; import { AuthIndexCommand } from './auth/_index.js'; import { BuildsIndexCommand } from './builds/_index.js'; import { TopLevelCallCommand } from './call.js'; @@ -49,6 +50,7 @@ export const apifyCommands = [ TelemetryIndexCommand, // top-level + ApiCommand, TopLevelCallCommand, UpgradeCommand, InstallCommand, diff --git a/src/commands/api-endpoints.json b/src/commands/api-endpoints.json new file mode 100644 index 000000000..21cd12d3e --- /dev/null +++ b/src/commands/api-endpoints.json @@ -0,0 +1,647 @@ +[ + { + "method": "GET", + "path": "/v2/actor-builds", + "summary": "Get user builds list" + }, + { + "method": "DELETE", + "path": "/v2/actor-builds/{buildId}", + "summary": "Delete build" + }, + { + "method": "GET", + "path": "/v2/actor-builds/{buildId}", + "summary": "Get build" + }, + { + "method": "POST", + "path": "/v2/actor-builds/{buildId}/abort", + "summary": "Abort build" + }, + { + "method": "GET", + "path": "/v2/actor-builds/{buildId}/log", + "summary": "Get log" + }, + { + "method": "GET", + "path": "/v2/actor-builds/{buildId}/openapi.json", + "summary": "Get OpenAPI definition" + }, + { + "method": "GET", + "path": "/v2/actor-runs", + "summary": "Get user runs list" + }, + { + "method": "DELETE", + "path": "/v2/actor-runs/{runId}", + "summary": "Delete run" + }, + { + "method": "GET", + "path": "/v2/actor-runs/{runId}", + "summary": "Get run" + }, + { + "method": "PUT", + "path": "/v2/actor-runs/{runId}", + "summary": "Update run" + }, + { + "method": "POST", + "path": "/v2/actor-runs/{runId}/abort", + "summary": "Abort run" + }, + { + "method": "POST", + "path": "/v2/actor-runs/{runId}/charge", + "summary": "Charge events in run" + }, + { + "method": "POST", + "path": "/v2/actor-runs/{runId}/metamorph", + "summary": "Metamorph run" + }, + { + "method": "POST", + "path": "/v2/actor-runs/{runId}/reboot", + "summary": "Reboot run" + }, + { + "method": "POST", + "path": "/v2/actor-runs/{runId}/resurrect", + "summary": "Resurrect run" + }, + { + "method": "GET", + "path": "/v2/actor-tasks", + "summary": "Get list of tasks" + }, + { + "method": "POST", + "path": "/v2/actor-tasks", + "summary": "Create task" + }, + { + "method": "DELETE", + "path": "/v2/actor-tasks/{actorTaskId}", + "summary": "Delete task" + }, + { + "method": "GET", + "path": "/v2/actor-tasks/{actorTaskId}", + "summary": "Get task" + }, + { + "method": "PUT", + "path": "/v2/actor-tasks/{actorTaskId}", + "summary": "Update task" + }, + { + "method": "GET", + "path": "/v2/actor-tasks/{actorTaskId}/input", + "summary": "Get task input" + }, + { + "method": "PUT", + "path": "/v2/actor-tasks/{actorTaskId}/input", + "summary": "Update task input" + }, + { + "method": "GET", + "path": "/v2/actor-tasks/{actorTaskId}/run-sync", + "summary": "Run task synchronously" + }, + { + "method": "POST", + "path": "/v2/actor-tasks/{actorTaskId}/run-sync", + "summary": "Run task synchronously" + }, + { + "method": "GET", + "path": "/v2/actor-tasks/{actorTaskId}/run-sync-get-dataset-items", + "summary": "Run task synchronously and get dataset items" + }, + { + "method": "POST", + "path": "/v2/actor-tasks/{actorTaskId}/run-sync-get-dataset-items", + "summary": "Run task synchronously and get dataset items" + }, + { + "method": "GET", + "path": "/v2/actor-tasks/{actorTaskId}/runs", + "summary": "Get list of task runs" + }, + { + "method": "POST", + "path": "/v2/actor-tasks/{actorTaskId}/runs", + "summary": "Run task" + }, + { + "method": "GET", + "path": "/v2/actor-tasks/{actorTaskId}/runs/last", + "summary": "Get last run" + }, + { + "method": "GET", + "path": "/v2/actor-tasks/{actorTaskId}/webhooks", + "summary": "Get list of webhooks" + }, + { + "method": "GET", + "path": "/v2/acts", + "summary": "Get list of Actors" + }, + { + "method": "POST", + "path": "/v2/acts", + "summary": "Create Actor" + }, + { + "method": "DELETE", + "path": "/v2/acts/{actorId}", + "summary": "Delete Actor" + }, + { + "method": "GET", + "path": "/v2/acts/{actorId}", + "summary": "Get Actor" + }, + { + "method": "PUT", + "path": "/v2/acts/{actorId}", + "summary": "Update Actor" + }, + { + "method": "GET", + "path": "/v2/acts/{actorId}/builds", + "summary": "Get list of builds" + }, + { + "method": "POST", + "path": "/v2/acts/{actorId}/builds", + "summary": "Build Actor" + }, + { + "method": "GET", + "path": "/v2/acts/{actorId}/builds/{buildId}", + "summary": "Get build" + }, + { + "method": "POST", + "path": "/v2/acts/{actorId}/builds/{buildId}/abort", + "summary": "Abort build" + }, + { + "method": "GET", + "path": "/v2/acts/{actorId}/builds/{buildId}/openapi.json", + "summary": "Get OpenAPI definition" + }, + { + "method": "GET", + "path": "/v2/acts/{actorId}/builds/default", + "summary": "Get default build" + }, + { + "method": "GET", + "path": "/v2/acts/{actorId}/run-sync", + "summary": "Without input" + }, + { + "method": "POST", + "path": "/v2/acts/{actorId}/run-sync", + "summary": "Run Actor synchronously with input and return output" + }, + { + "method": "GET", + "path": "/v2/acts/{actorId}/run-sync-get-dataset-items", + "summary": "Run Actor synchronously without input and get dataset items" + }, + { + "method": "POST", + "path": "/v2/acts/{actorId}/run-sync-get-dataset-items", + "summary": "Run Actor synchronously with input and get dataset items" + }, + { + "method": "GET", + "path": "/v2/acts/{actorId}/runs", + "summary": "Get list of runs" + }, + { + "method": "POST", + "path": "/v2/acts/{actorId}/runs", + "summary": "Run Actor" + }, + { + "method": "GET", + "path": "/v2/acts/{actorId}/runs/{runId}", + "summary": "Get run" + }, + { + "method": "POST", + "path": "/v2/acts/{actorId}/runs/{runId}/abort", + "summary": "Abort run" + }, + { + "method": "POST", + "path": "/v2/acts/{actorId}/runs/{runId}/metamorph", + "summary": "Metamorph run" + }, + { + "method": "POST", + "path": "/v2/acts/{actorId}/runs/{runId}/resurrect", + "summary": "Resurrect run" + }, + { + "method": "GET", + "path": "/v2/acts/{actorId}/runs/last", + "summary": "Get last run" + }, + { + "method": "GET", + "path": "/v2/acts/{actorId}/versions", + "summary": "Get list of versions" + }, + { + "method": "POST", + "path": "/v2/acts/{actorId}/versions", + "summary": "Create version" + }, + { + "method": "DELETE", + "path": "/v2/acts/{actorId}/versions/{versionNumber}", + "summary": "Delete version" + }, + { + "method": "GET", + "path": "/v2/acts/{actorId}/versions/{versionNumber}", + "summary": "Get version" + }, + { + "method": "POST", + "path": "/v2/acts/{actorId}/versions/{versionNumber}", + "summary": "Update version (POST)" + }, + { + "method": "PUT", + "path": "/v2/acts/{actorId}/versions/{versionNumber}", + "summary": "Update version" + }, + { + "method": "GET", + "path": "/v2/acts/{actorId}/versions/{versionNumber}/env-vars", + "summary": "Get list of environment variables" + }, + { + "method": "POST", + "path": "/v2/acts/{actorId}/versions/{versionNumber}/env-vars", + "summary": "Create environment variable" + }, + { + "method": "DELETE", + "path": "/v2/acts/{actorId}/versions/{versionNumber}/env-vars/{envVarName}", + "summary": "Delete environment variable" + }, + { + "method": "GET", + "path": "/v2/acts/{actorId}/versions/{versionNumber}/env-vars/{envVarName}", + "summary": "Get environment variable" + }, + { + "method": "POST", + "path": "/v2/acts/{actorId}/versions/{versionNumber}/env-vars/{envVarName}", + "summary": "Update environment variable (POST)" + }, + { + "method": "PUT", + "path": "/v2/acts/{actorId}/versions/{versionNumber}/env-vars/{envVarName}", + "summary": "Update environment variable" + }, + { + "method": "GET", + "path": "/v2/acts/{actorId}/webhooks", + "summary": "Get list of webhooks" + }, + { + "method": "DELETE", + "path": "/v2/browser-info", + "summary": "Get browser info" + }, + { + "method": "GET", + "path": "/v2/browser-info", + "summary": "Get browser info" + }, + { + "method": "POST", + "path": "/v2/browser-info", + "summary": "Get browser info" + }, + { + "method": "PUT", + "path": "/v2/browser-info", + "summary": "Get browser info" + }, + { + "method": "GET", + "path": "/v2/datasets", + "summary": "Get list of datasets" + }, + { + "method": "POST", + "path": "/v2/datasets", + "summary": "Create dataset" + }, + { + "method": "DELETE", + "path": "/v2/datasets/{datasetId}", + "summary": "Delete dataset" + }, + { + "method": "GET", + "path": "/v2/datasets/{datasetId}", + "summary": "Get dataset" + }, + { + "method": "PUT", + "path": "/v2/datasets/{datasetId}", + "summary": "Update dataset" + }, + { + "method": "GET", + "path": "/v2/datasets/{datasetId}/items", + "summary": "Get dataset items" + }, + { + "method": "POST", + "path": "/v2/datasets/{datasetId}/items", + "summary": "Store items" + }, + { + "method": "GET", + "path": "/v2/datasets/{datasetId}/statistics", + "summary": "Get dataset statistics" + }, + { + "method": "GET", + "path": "/v2/key-value-stores", + "summary": "Get list of key-value stores" + }, + { + "method": "POST", + "path": "/v2/key-value-stores", + "summary": "Create key-value store" + }, + { + "method": "DELETE", + "path": "/v2/key-value-stores/{storeId}", + "summary": "Delete store" + }, + { + "method": "GET", + "path": "/v2/key-value-stores/{storeId}", + "summary": "Get store" + }, + { + "method": "PUT", + "path": "/v2/key-value-stores/{storeId}", + "summary": "Update store" + }, + { + "method": "GET", + "path": "/v2/key-value-stores/{storeId}/keys", + "summary": "Get list of keys" + }, + { + "method": "GET", + "path": "/v2/key-value-stores/{storeId}/records", + "summary": "Download records" + }, + { + "method": "DELETE", + "path": "/v2/key-value-stores/{storeId}/records/{recordKey}", + "summary": "Delete record" + }, + { + "method": "GET", + "path": "/v2/key-value-stores/{storeId}/records/{recordKey}", + "summary": "Get record" + }, + { + "method": "POST", + "path": "/v2/key-value-stores/{storeId}/records/{recordKey}", + "summary": "Store record (POST)" + }, + { + "method": "PUT", + "path": "/v2/key-value-stores/{storeId}/records/{recordKey}", + "summary": "Store record" + }, + { + "method": "GET", + "path": "/v2/logs/{buildOrRunId}", + "summary": "Get log" + }, + { + "method": "GET", + "path": "/v2/request-queues", + "summary": "Get list of request queues" + }, + { + "method": "POST", + "path": "/v2/request-queues", + "summary": "Create request queue" + }, + { + "method": "DELETE", + "path": "/v2/request-queues/{queueId}", + "summary": "Delete request queue" + }, + { + "method": "GET", + "path": "/v2/request-queues/{queueId}", + "summary": "Get request queue" + }, + { + "method": "PUT", + "path": "/v2/request-queues/{queueId}", + "summary": "Update request queue" + }, + { + "method": "GET", + "path": "/v2/request-queues/{queueId}/head", + "summary": "Get head" + }, + { + "method": "POST", + "path": "/v2/request-queues/{queueId}/head/lock", + "summary": "Get head and lock" + }, + { + "method": "GET", + "path": "/v2/request-queues/{queueId}/requests", + "summary": "List requests" + }, + { + "method": "POST", + "path": "/v2/request-queues/{queueId}/requests", + "summary": "Add request" + }, + { + "method": "DELETE", + "path": "/v2/request-queues/{queueId}/requests/{requestId}", + "summary": "Delete request" + }, + { + "method": "GET", + "path": "/v2/request-queues/{queueId}/requests/{requestId}", + "summary": "Get request" + }, + { + "method": "PUT", + "path": "/v2/request-queues/{queueId}/requests/{requestId}", + "summary": "Update request" + }, + { + "method": "DELETE", + "path": "/v2/request-queues/{queueId}/requests/{requestId}/lock", + "summary": "Delete request lock" + }, + { + "method": "PUT", + "path": "/v2/request-queues/{queueId}/requests/{requestId}/lock", + "summary": "Prolong request lock" + }, + { + "method": "DELETE", + "path": "/v2/request-queues/{queueId}/requests/batch", + "summary": "Delete requests" + }, + { + "method": "POST", + "path": "/v2/request-queues/{queueId}/requests/batch", + "summary": "Add requests" + }, + { + "method": "POST", + "path": "/v2/request-queues/{queueId}/requests/unlock", + "summary": "Unlock requests" + }, + { + "method": "GET", + "path": "/v2/schedules", + "summary": "Get list of schedules" + }, + { + "method": "POST", + "path": "/v2/schedules", + "summary": "Create schedule" + }, + { + "method": "DELETE", + "path": "/v2/schedules/{scheduleId}", + "summary": "Delete schedule" + }, + { + "method": "GET", + "path": "/v2/schedules/{scheduleId}", + "summary": "Get schedule" + }, + { + "method": "PUT", + "path": "/v2/schedules/{scheduleId}", + "summary": "Update schedule" + }, + { + "method": "GET", + "path": "/v2/schedules/{scheduleId}/log", + "summary": "Get schedule log" + }, + { + "method": "GET", + "path": "/v2/store", + "summary": "Get list of Actors in store" + }, + { + "method": "POST", + "path": "/v2/tools/decode-and-verify", + "summary": "Decode and verify object" + }, + { + "method": "POST", + "path": "/v2/tools/encode-and-sign", + "summary": "Encode and sign object" + }, + { + "method": "GET", + "path": "/v2/users/{userId}", + "summary": "Get public user data" + }, + { + "method": "GET", + "path": "/v2/users/me", + "summary": "Get private user data" + }, + { + "method": "GET", + "path": "/v2/users/me/limits", + "summary": "Get limits" + }, + { + "method": "PUT", + "path": "/v2/users/me/limits", + "summary": "Update limits" + }, + { + "method": "GET", + "path": "/v2/users/me/usage/monthly", + "summary": "Get monthly usage" + }, + { + "method": "GET", + "path": "/v2/webhook-dispatches", + "summary": "Get list of webhook dispatches" + }, + { + "method": "GET", + "path": "/v2/webhook-dispatches/{dispatchId}", + "summary": "Get webhook dispatch" + }, + { + "method": "GET", + "path": "/v2/webhooks", + "summary": "Get list of webhooks" + }, + { + "method": "POST", + "path": "/v2/webhooks", + "summary": "Create webhook" + }, + { + "method": "DELETE", + "path": "/v2/webhooks/{webhookId}", + "summary": "Delete webhook" + }, + { + "method": "GET", + "path": "/v2/webhooks/{webhookId}", + "summary": "Get webhook" + }, + { + "method": "PUT", + "path": "/v2/webhooks/{webhookId}", + "summary": "Update webhook" + }, + { + "method": "GET", + "path": "/v2/webhooks/{webhookId}/dispatches", + "summary": "Get collection" + }, + { + "method": "POST", + "path": "/v2/webhooks/{webhookId}/test", + "summary": "Test webhook" + } +] diff --git a/src/commands/api.ts b/src/commands/api.ts new file mode 100644 index 000000000..83c8580e7 --- /dev/null +++ b/src/commands/api.ts @@ -0,0 +1,210 @@ +import chalk from 'chalk'; + +import { ApifyCommand, StdinMode } from '../lib/command-framework/apify-command.js'; +import { Args } from '../lib/command-framework/args.js'; +import { Flags } from '../lib/command-framework/flags.js'; +import { APIFY_CLIENT_DEFAULT_HEADERS, CommandExitCodes } from '../lib/consts.js'; +import { error, simpleLog } from '../lib/outputs.js'; +import { getLoggedClientOrThrow } from '../lib/utils.js'; +import apiEndpoints from './api-endpoints.json' with { type: 'json' }; + +const HTTP_METHODS: string[] = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']; + +export class ApiCommand extends ApifyCommand { + static override name = 'api' as const; + + static override description = + 'Makes an authenticated HTTP request to the Apify API and prints the response.\n' + + 'The endpoint can be a relative path (e.g. "acts", "v2/acts", or "/v2/acts").\n' + + 'The "v2/" prefix is added automatically if omitted.\n\n' + + 'You can also pass the HTTP method before the endpoint:\n' + + ' apify api GET /v2/actor-runs\n' + + ' apify api POST /v2/acts -d \'{"name": "my-actor"}\'\n\n' + + 'Use --params/-p to pass query parameters as JSON:\n' + + ' apify api actor-runs -p \'{"limit": 1, "desc": true}\'\n\n' + + 'Use --list-endpoints to see all available API endpoints.\n' + + 'For full documentation, see https://docs.apify.com/api/v2'; + + static override args = { + methodOrEndpoint: Args.string({ + required: false, + description: + 'The API endpoint path (e.g. "acts", "v2/acts", "/v2/users/me"), ' + + 'or an HTTP method followed by the endpoint (e.g. "GET /v2/users/me").', + }), + endpoint: Args.string({ + required: false, + description: 'The API endpoint path when the first argument is an HTTP method.', + }), + }; + + static override flags = { + method: Flags.string({ + char: 'X', + description: 'The HTTP method to use.', + choices: HTTP_METHODS, + default: 'GET', + }), + body: Flags.string({ + char: 'd', + description: 'The request body (JSON string). Use "-" to read from stdin.', + required: false, + stdin: StdinMode.Stringified, + }), + header: Flags.string({ + char: 'H', + description: 'Additional HTTP header in "key:value" format (only one header supported).', + required: false, + }), + params: Flags.string({ + char: 'p', + description: 'Query parameters as a JSON object, e.g. \'{"limit": 1, "desc": true}\'.', + required: false, + }), + 'list-endpoints': Flags.boolean({ + char: 'l', + description: 'List all available Apify API endpoints.', + default: false, + }), + }; + + async run() { + if (this.flags.listEndpoints) { + this.printEndpoints(); + return; + } + + // Support "apify api GET /v2/users/me" syntax — if the first arg is an HTTP method, + // use it as the method and the second arg as the endpoint + let { method } = this.flags; + let endpointArg = this.args.methodOrEndpoint; + + if (endpointArg && HTTP_METHODS.includes(endpointArg.toUpperCase())) { + method = endpointArg.toUpperCase(); + endpointArg = this.args.endpoint; + } + + if (!endpointArg) { + this.printHelp(); + return; + } + + const apifyClient = await getLoggedClientOrThrow(); + const token = apifyClient.token!; + + // Normalize endpoint — strip leading slash and ensure v2 prefix + let endpoint = endpointArg; + + if (endpoint.startsWith('/')) { + endpoint = endpoint.slice(1); + } + + // Auto-prepend "v2/" if the endpoint doesn't already include it, + // since all Apify API endpoints are under /v2/ + if (!endpoint.startsWith('v2/')) { + endpoint = `v2/${endpoint}`; + } + + const baseUrl = process.env.APIFY_CLIENT_BASE_URL || 'https://api.apify.com'; + let url = `${baseUrl}/${endpoint}`; + + // Append query params from --params flag + if (this.flags.params) { + let paramsObj: Record; + + try { + paramsObj = JSON.parse(this.flags.params); + } catch { + throw new Error('Invalid JSON in --params flag. Please provide a valid JSON object, e.g. \'{"limit": 1}\'.'); + } + + const searchParams = new URLSearchParams(); + + for (const [key, value] of Object.entries(paramsObj)) { + searchParams.append(key, String(value)); + } + + const separator = url.includes('?') ? '&' : '?'; + url = `${url}${separator}${searchParams.toString()}`; + } + + // Build headers + const headers: Record = { + ...APIFY_CLIENT_DEFAULT_HEADERS, + Authorization: `Bearer ${token}`, + }; + + if (this.flags.body) { + headers['Content-Type'] = 'application/json'; + } + + if (this.flags.header) { + const colonIndex = this.flags.header.indexOf(':'); + + if (colonIndex === -1) { + throw new Error('Header must be in "key:value" format.'); + } + + headers[this.flags.header.slice(0, colonIndex).trim()] = this.flags.header.slice(colonIndex + 1).trim(); + } + + // Validate body is valid JSON before sending + if (this.flags.body) { + try { + JSON.parse(this.flags.body); + } catch { + throw new Error('Invalid JSON in --body flag. Please provide a valid JSON string.'); + } + } + + // Make the request + const response = await fetch(url, { + method, + headers, + body: this.flags.body || undefined, + }); + + const responseText = await response.text(); + + if (!response.ok) { + process.exitCode = CommandExitCodes.RunFailed; + + try { + const parsed = JSON.parse(responseText); + error({ message: `${response.status} ${response.statusText}` }); + simpleLog({ message: JSON.stringify(parsed, null, 2), stdout: false }); + } catch { + error({ message: `${response.status} ${response.statusText}: ${responseText}` }); + } + + return; + } + + if (responseText) { + try { + const parsed = JSON.parse(responseText); + simpleLog({ message: JSON.stringify(parsed, null, 2), stdout: true }); + } catch { + simpleLog({ message: responseText, stdout: true }); + } + } + } + + private printEndpoints() { + const methodColors: Record string> = { + GET: chalk.green, + POST: chalk.yellow, + PUT: chalk.blue, + PATCH: chalk.cyan, + DELETE: chalk.red, + }; + + for (const { method, path, summary } of apiEndpoints) { + const colorize = methodColors[method] || chalk.white; + const methodStr = colorize(method.padEnd(7)); + const summaryStr = summary ? chalk.gray(` ${summary}`) : ''; + + console.log(`${methodStr} ${path}${summaryStr}`); + } + } +} diff --git a/test/api/commands/api.test.ts b/test/api/commands/api.test.ts new file mode 100644 index 000000000..e9dfc54b7 --- /dev/null +++ b/test/api/commands/api.test.ts @@ -0,0 +1,174 @@ +import { ApiCommand } from '../../../src/commands/api.js'; +import { testRunCommand } from '../../../src/lib/command-framework/apify-command.js'; +import { safeLogin, useAuthSetup } from '../../__setup__/hooks/useAuthSetup.js'; +import { useConsoleSpy } from '../../__setup__/hooks/useConsoleSpy.js'; + +useAuthSetup(); + +const { lastErrorMessage, logSpy, errorSpy } = useConsoleSpy(); + +describe('[api] apify api', () => { + it('should fail when not logged in', async () => { + await testRunCommand(ApiCommand, { + args_methodOrEndpoint: 'v2/users/me', + }); + + expect(lastErrorMessage()).toMatch(/you are not logged in/i); + }); + + it('should GET v2/users/me', async () => { + await safeLogin(); + + await testRunCommand(ApiCommand, { + args_methodOrEndpoint: 'v2/users/me', + }); + + const spy = logSpy(); + expect(spy).toHaveBeenCalled(); + + const output = spy.mock.calls[0][0]; + const parsed = JSON.parse(output); + + expect(parsed.data).toBeDefined(); + expect(parsed.data.id).toBeDefined(); + expect(parsed.data.username).toBeDefined(); + }); + + it('should normalize endpoint with leading slash', async () => { + await safeLogin(); + + await testRunCommand(ApiCommand, { + args_methodOrEndpoint: '/v2/users/me', + }); + + const spy = logSpy(); + expect(spy).toHaveBeenCalled(); + + const output = spy.mock.calls[0][0]; + const parsed = JSON.parse(output); + + expect(parsed.data).toBeDefined(); + expect(parsed.data.id).toBeDefined(); + }); + + it('should auto-prepend v2/ prefix when omitted', async () => { + await safeLogin(); + + await testRunCommand(ApiCommand, { + args_methodOrEndpoint: 'users/me', + }); + + const spy = logSpy(); + expect(spy).toHaveBeenCalled(); + + const output = spy.mock.calls[0][0]; + const parsed = JSON.parse(output); + + expect(parsed.data).toBeDefined(); + expect(parsed.data.id).toBeDefined(); + expect(parsed.data.username).toBeDefined(); + }); + + it('should set exit code for non-existent endpoint', async () => { + await safeLogin(); + + await testRunCommand(ApiCommand, { + args_methodOrEndpoint: 'v2/acts/this-actor-does-not-exist-at-all-12345', + }); + + expect(process.exitCode).toBe(1); + + const spy = errorSpy(); + expect(spy).toHaveBeenCalled(); + }); + + it('should work with custom header', async () => { + await safeLogin(); + + await testRunCommand(ApiCommand, { + args_methodOrEndpoint: 'v2/users/me', + flags_header: 'X-Custom-Test:hello', + }); + + const spy = logSpy(); + expect(spy).toHaveBeenCalled(); + + const output = spy.mock.calls[0][0]; + const parsed = JSON.parse(output); + + expect(parsed.data.id).toBeDefined(); + }); + + it('should support positional HTTP method before endpoint', async () => { + await safeLogin(); + + await testRunCommand(ApiCommand, { + args_methodOrEndpoint: 'GET', + args_endpoint: 'v2/users/me', + }); + + const spy = logSpy(); + expect(spy).toHaveBeenCalled(); + + const output = spy.mock.calls[0][0]; + const parsed = JSON.parse(output); + + expect(parsed.data).toBeDefined(); + expect(parsed.data.id).toBeDefined(); + expect(parsed.data.username).toBeDefined(); + }); + + it('should support --params flag for query parameters', async () => { + await safeLogin(); + + await testRunCommand(ApiCommand, { + args_methodOrEndpoint: 'v2/actor-runs', + flags_params: JSON.stringify({ limit: 1, desc: true }), + }); + + const spy = logSpy(); + expect(spy).toHaveBeenCalled(); + + const output = spy.mock.calls[0][0]; + const parsed = JSON.parse(output); + + expect(parsed.data).toBeDefined(); + expect(parsed.data.items).toBeDefined(); + expect(parsed.data.items.length).toBeLessThanOrEqual(1); + }); + + it('should support POST with --body', async () => { + await safeLogin(); + + const actorName = `test-api-cmd-${Date.now()}`; + let actorId: string | undefined; + + try { + // Create an actor via POST + await testRunCommand(ApiCommand, { + args_methodOrEndpoint: 'v2/acts', + flags_method: 'POST', + flags_body: JSON.stringify({ name: actorName, title: 'Test API Command' }), + }); + + const spy = logSpy(); + expect(spy).toHaveBeenCalled(); + + const output = spy.mock.calls[0][0]; + const parsed = JSON.parse(output); + + expect(parsed.data).toBeDefined(); + expect(parsed.data.name).toBe(actorName); + + actorId = parsed.data.id; + } finally { + // Cleanup — delete the created actor even if assertions fail + if (actorId) { + await testRunCommand(ApiCommand, { + args_methodOrEndpoint: `v2/acts/${actorId}`, + flags_method: 'DELETE', + }); + } + } + }); +});