UNPKG

@ably/cli

Version:

Ably CLI for Pub/Sub, Chat and Spaces

261 lines (260 loc) 10.6 kB
import fetch from "node-fetch"; import { getCliVersion } from "../utils/version.js"; import isTestMode from "../utils/test-mode.js"; export class ControlApi { accessToken; controlHost; logErrors; constructor(options) { this.accessToken = options.accessToken; this.controlHost = options.controlHost || "control.ably.net"; // Respect SUPPRESS_CONTROL_API_ERRORS env var for default behavior // Explicit options.logErrors will override the env var. // eslint-disable-next-line unicorn/no-negated-condition if (options.logErrors !== undefined) { this.logErrors = options.logErrors; } else { // Determine logErrors based on environment variables const suppressErrors = process.env.SUPPRESS_CONTROL_API_ERRORS === "true" || process.env.CI === "true" || isTestMode(); this.logErrors = !suppressErrors; } } // Ask a question to the Ably AI agent async askHelp(question, conversation) { const payload = { question, ...(conversation && { context: conversation.messages }), }; return this.request("/help", "POST", payload); } // Create a new app async createApp(appData) { // First get the account ID from /me endpoint const meResponse = await this.getMe(); const accountId = meResponse.account.id; // Use correct path with account ID prefix return this.request(`/accounts/${accountId}/apps`, "POST", appData); } // Create a new key for an app async createKey(appId, keyData) { return this.request(`/apps/${appId}/keys`, "POST", keyData); } async createNamespace(appId, namespaceData) { return this.request(`/apps/${appId}/namespaces`, "POST", namespaceData); } async createQueue(appId, queueData) { return this.request(`/apps/${appId}/queues`, "POST", queueData); } // Create a new rule with typed RuleData interface async createRule(appId, ruleData) { return this.request(`/apps/${appId}/rules`, "POST", ruleData); } // Delete an app async deleteApp(appId) { // Delete app uses /apps/{appId} path return this.request(`/apps/${appId}`, "DELETE"); } async deleteNamespace(appId, namespaceId) { return this.request(`/apps/${appId}/namespaces/${namespaceId}`, "DELETE"); } async deleteQueue(appId, queueName) { return this.request(`/apps/${appId}/queues/${queueName}`, "DELETE"); } async deleteRule(appId, ruleId) { return this.request(`/apps/${appId}/rules/${ruleId}`, "DELETE"); } // Get account stats async getAccountStats(options = {}) { const queryParams = new URLSearchParams(); if (options.start) queryParams.append("start", options.start.toString()); if (options.end) queryParams.append("end", options.end.toString()); if (options.by) queryParams.append("by", options.by); if (options.limit) queryParams.append("limit", options.limit.toString()); if (options.unit) queryParams.append("unit", options.unit); const queryString = queryParams.toString() ? `?${queryParams.toString()}` : ""; // First get the account ID from /me endpoint const meResponse = await this.getMe(); const accountId = meResponse.account.id; // Account stats require the account ID in the path return this.request(`/accounts/${accountId}/stats${queryString}`); } // Get an app by ID async getApp(appId) { // There's no single app GET endpoint, need to get all apps and filter const apps = await this.listApps(); const app = apps.find((a) => a.id === appId); if (!app) { throw new Error(`App with ID "${appId}" not found`); } return app; } // Get app stats async getAppStats(appId, options = {}) { const queryParams = new URLSearchParams(); if (options.start) queryParams.append("start", options.start.toString()); if (options.end) queryParams.append("end", options.end.toString()); if (options.by) queryParams.append("by", options.by); if (options.limit) queryParams.append("limit", options.limit.toString()); if (options.unit) queryParams.append("unit", options.unit); const queryString = queryParams.toString() ? `?${queryParams.toString()}` : ""; // App ID-specific operations don't need account ID in the path return this.request(`/apps/${appId}/stats${queryString}`); } // Get a specific key by ID or key value async getKey(appId, keyIdOrValue) { // Check if it's a full key (containing colon) or just an ID const isFullKey = keyIdOrValue.includes(":"); if (isFullKey) { // If it's a full key, we need to list all keys and find the matching one const keys = await this.listKeys(appId); const matchingKey = keys.find((k) => k.key === keyIdOrValue); if (!matchingKey) { throw new Error(`Key "${keyIdOrValue}" not found`); } return matchingKey; } // If it's just an ID, we can fetch it directly return this.request(`/apps/${appId}/keys/${keyIdOrValue}`); } // Get user and account info async getMe() { return this.request("/me"); } async getNamespace(appId, namespaceId) { return this.request(`/apps/${appId}/namespaces/${namespaceId}`); } async getRule(appId, ruleId) { return this.request(`/apps/${appId}/rules/${ruleId}`); } // Get all apps async listApps() { // First get the account ID from /me endpoint const meResponse = await this.getMe(); const accountId = meResponse.account.id; // Use correct path with account ID prefix return this.request(`/accounts/${accountId}/apps`); } // List all keys for an app async listKeys(appId) { return this.request(`/apps/${appId}/keys`); } // Namespace (Channel Rules) methods async listNamespaces(appId) { return this.request(`/apps/${appId}/namespaces`); } // Queues methods async listQueues(appId) { return this.request(`/apps/${appId}/queues`); } // Rules (Integrations) methods async listRules(appId) { return this.request(`/apps/${appId}/rules`); } // Revoke a key async revokeKey(appId, keyId) { return this.request(`/apps/${appId}/keys/${keyId}`, "DELETE"); } // Update an app async updateApp(appId, appData) { // Update app uses /apps/{appId} path return this.request(`/apps/${appId}`, "PATCH", appData); } // Update an existing key async updateKey(appId, keyId, keyData) { return this.request(`/apps/${appId}/keys/${keyId}`, "PATCH", keyData); } async updateNamespace(appId, namespaceId, namespaceData) { return this.request(`/apps/${appId}/namespaces/${namespaceId}`, "PATCH", namespaceData); } // Update a rule with typed RuleData interface async updateRule(appId, ruleId, ruleData) { return this.request(`/apps/${appId}/rules/${ruleId}`, "PATCH", ruleData); } // Upload Apple Push Notification Service P12 certificate for an app async uploadApnsP12(appId, certificateData, options = {}) { const data = { p12Certificate: certificateData, password: options.password, useForSandbox: options.useForSandbox, }; // App ID-specific operations don't need account ID in the path return this.request(`/apps/${appId}/push/certificate`, "POST", data); } async request(path, method = "GET", body) { const url = this.controlHost.includes("local") ? `http://${this.controlHost}/api/v1${path}` : `https://${this.controlHost}/v1${path}`; const options = { headers: { Accept: "application/json", Authorization: `Bearer ${this.accessToken}`, "Content-Type": "application/json", "Ably-Agent": `ably-cli/${getCliVersion()}`, }, method, }; if (body && (method === "POST" || method === "PUT" || method === "PATCH")) { options.body = JSON.stringify(body); } const response = await fetch(url, options); if (!response.ok) { const responseBody = await response.text(); // Attempt to parse JSON, otherwise use raw text let responseData = responseBody; try { responseData = JSON.parse(responseBody); } catch { /* Ignore parsing errors, keep as string */ } const errorDetails = { message: `API request failed with status ${response.status}: ${response.statusText}`, response: responseData, // Assign unknown type statusCode: response.status, }; // Log the error for debugging purposes, but not during tests if (this.logErrors) { console.error("Control API Request Error:", { message: errorDetails.message, response: errorDetails.response || "No response body", statusCode: errorDetails.statusCode, }); } // Throw a more user-friendly error, including the message from the response if available let errorMessage = `API request failed (${response.status} ${response.statusText})`; if (typeof responseData === "object" && responseData !== null && "message" in responseData && typeof responseData.message === "string") { errorMessage += `: ${responseData.message}`; } else if (typeof responseData === "string" && responseData.length < 100) { // Include short string responses directly errorMessage += `: ${responseData}`; } throw new Error(errorMessage); } if (response.status === 204) { return {}; } return (await response.json()); } }