UNPKG

@swell/cli

Version:

Swell's command line interface/utility

182 lines (181 loc) 7.11 kB
import * as fs from 'node:fs'; import path from 'node:path'; import { FetchError } from 'node-fetch'; import { HttpMethod } from './lib/api.js'; import { SwellCommand } from './swell-command.js'; // Pattern to match /functions/{appId}/{functionName} with optional query string const FUNCTION_PATH_REGEX = /^\/functions\/([^/]+)\/([^/?]+)(\?.*)?$/; // Pattern to match /functions/{functionId} with optional query string const FUNCTION_DIRECT_REGEX = /^\/functions\/([^/?]+)(\?.*)?$/; export class SwellApiCommand extends SwellCommand { async request(command, requestOptions = {}) { const { paths, options, methodOverride } = await this.parseCommand(command); const method = methodOverride || this.method; const response = await this.api[method](paths, { rawResponse: true, ...options, ...requestOptions, }); await this.handleResponse(response); } async catch(error) { if (error instanceof FetchError) { const message = `Could not connect to Swell API. Please try again later: ${error.message}`; return this.onError(message, { exit: 2, code: error.code }); } return this.onError(error.message, { exit: 1 }); } async parseCommand(options, argv) { const parsedInput = await super.parse(options, argv); const { args, flags } = parsedInput; const { path: requestPath } = args; const { live, body } = flags; if (!live) { await this.api.setEnv('test'); } if (!requestPath.startsWith('/')) { throw new Error('Path must start with a forward slash (/)'); } const processedBody = await this.processBody(body); // Check if this is a function call by name: /functions/{appId}/{functionName} // Must check this first (more specific pattern) const functionMatch = requestPath.match(FUNCTION_PATH_REGEX); if (functionMatch) { const [, appId, functionName, queryString] = functionMatch; const functionId = await this.resolveFunctionId(appId, functionName); const queryParams = this.parseQueryString(queryString); return this.buildFunctionCallRequest(parsedInput, functionId, processedBody, queryParams); } // Check if this is a direct function call: /functions/{functionId} const directMatch = requestPath.match(FUNCTION_DIRECT_REGEX); if (directMatch) { const [, functionId, queryString] = directMatch; const queryParams = this.parseQueryString(queryString); return this.buildFunctionCallRequest(parsedInput, functionId, processedBody, queryParams); } const paths = { adminPath: `/data${requestPath}` }; return { ...parsedInput, paths, options: { body: processedBody, }, }; } /** * Resolve app ObjectId from a friendly slug or return as-is if already an ObjectId. */ async resolveAppId(appIdOrSlug) { // If it looks like an ObjectId (24 hex chars), return as-is if (/^[\da-f]{24}$/i.test(appIdOrSlug)) { return appIdOrSlug; } // Fetch all installed apps and filter by public_id or private_id client-side const installedApps = await this.api.get({ adminPath: `/client/apps` }); const app = installedApps?.results?.find((a) => a.app_public_id === appIdOrSlug || a.app_private_id === `_${appIdOrSlug}`); if (!app) { throw new Error(`App '${appIdOrSlug}' not found`); } return app.app_id; } /** * Resolve function ID from app ID and function name. */ async resolveFunctionId(appIdOrSlug, functionName) { const appId = await this.resolveAppId(appIdOrSlug); const functionRecord = await this.api.get({ adminPath: `/data/:functions` }, { query: { app_id: appId, name: functionName, limit: 1, }, }); if (!functionRecord?.results?.length) { throw new Error(`Function '${functionName}' not found for app '${appIdOrSlug}'`); } return functionRecord.results[0].id; } isPlainObject(value) { return typeof value === 'object' && value !== null && !Array.isArray(value); } /** * Build a function invocation request via the admin /:functions endpoint. */ buildFunctionCallRequest(parsedInput, functionId, bodyData, queryParams) { // Merge query params with body data (body takes precedence) // Only merge if bodyData is a plain object; otherwise use bodyData or query params alone const mergedData = this.isPlainObject(bodyData) ? { ...queryParams, ...bodyData } : bodyData ?? queryParams; const callBody = { $call: { data: mergedData, method: this.method, }, }; return { ...parsedInput, paths: { adminPath: `/data/:functions/${functionId}` }, options: { body: callBody, }, methodOverride: HttpMethod.PUT, }; } parseQueryString(queryString) { if (!queryString) { return {}; } const params = new URLSearchParams(queryString.slice(1)); // Remove leading '?' const result = {}; for (const [key, value] of params.entries()) { result[key] = value; } return result; } async processBody(body) { if (!body) { return; } const bodyData = this.isFilePath(body) ? await fs.promises.readFile(path.resolve(body), 'utf8') : body; return JSON.parse(bodyData); } isFilePath(filePath) { return (path.isAbsolute(filePath) || filePath.startsWith('./') || filePath.startsWith('../')); } async handleResponse(response) { const responseText = await response.text(); // Handle truly empty responses (no body) - success for 2xx status codes // This handles DELETE 204 No Content and similar cases if (!responseText) { if (response.ok) { this.onSuccess({ success: true }); return; } throw new Error('Not found'); } const result = JSON.parse(responseText); // API returned empty/null content in body - this is "not found" semantics if (result === null || result === undefined || result === '') { throw new Error('Not found'); } if (result.error || result.errors || !response.ok) { throw new Error(responseText); } this.onSuccess(result); } onSuccess(result) { this.log(JSON.stringify(result, null, 2)); } onError(message, { code, exit }) { this.error(message, { code, exit, }); } }