@swell/cli
Version:
Swell's command line interface/utility
182 lines (181 loc) • 7.11 kB
JavaScript
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,
});
}
}