@swell/cli
Version:
Swell's command line interface/utility
211 lines (210 loc) • 8.13 kB
JavaScript
import { Flags } from '@oclif/core';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { FetchError } from 'node-fetch';
import { HttpMethod } from './lib/api.js';
import { resolveAppId } from './lib/apps/resolve.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\/([^/?]+)(\?.*)?$/;
// Shared --header / -H flag for function-call paths. Pass once per header.
// Only forwarded as $call.headers on /functions/* paths; ignored elsewhere.
export const headerFlag = Flags.string({
char: 'H',
description: "HTTP header to forward to a function (format: 'Name: value'). Repeat for multiple. Only applies to /functions/* paths.",
multiple: true,
});
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, api, body, header } = flags;
const isFrontendAPI = api === 'frontend';
if (!live) {
await this.api.setEnv('test');
}
if (isFrontendAPI) {
await this.api.setPublicKey();
}
if (!requestPath.startsWith('/')) {
throw new Error('Path must start with a forward slash (/)');
}
const processedBody = await this.processBody(body);
const callHeaders = this.parseHeaders(header);
// 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);
return this.buildFunctionCallRequest(parsedInput, {
functionId,
body: processedBody,
query: this.parseQueryString(queryString),
headers: callHeaders,
});
}
// Check if this is a direct function call: /functions/{functionId}
const directMatch = requestPath.match(FUNCTION_DIRECT_REGEX);
if (directMatch) {
const [, functionId, queryString] = directMatch;
return this.buildFunctionCallRequest(parsedInput, {
functionId,
body: processedBody,
query: this.parseQueryString(queryString),
headers: callHeaders,
});
}
const paths = isFrontendAPI
? { frontendPath: requestPath }
: { adminPath: `/data${requestPath}` };
return {
...parsedInput,
paths,
options: {
body: processedBody,
},
};
}
/**
* Resolve function ID from app ID and function name.
*/
async resolveFunctionId(appIdOrSlug, functionName) {
const appId = await resolveAppId(this.api, 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, call) {
// Merge query params with body data (body takes precedence)
// Only merge if body is a plain object; otherwise use body or query alone
const mergedData = this.isPlainObject(call.body)
? { ...call.query, ...call.body }
: call.body ?? call.query;
const $call = {
data: mergedData,
method: this.method,
};
if (Object.keys(call.headers).length > 0) {
$call.headers = call.headers;
}
return {
...parsedInput,
paths: { adminPath: `/data/:functions/${call.functionId}` },
options: {
body: { $call },
},
methodOverride: HttpMethod.PUT,
};
}
// Parse repeated `-H "Name: value"` flag entries into a map. Splits on first colon
// so values containing colons (e.g. `Authorization: Bearer x:y`) survive intact.
parseHeaders(headerArgs) {
if (!headerArgs?.length) {
return {};
}
const result = {};
for (const entry of headerArgs) {
const colonIndex = entry.indexOf(':');
if (colonIndex < 1) {
throw new Error(`Invalid header '${entry}'. Expected format: 'Name: value'.`);
}
const name = entry.slice(0, colonIndex).trim();
const value = entry.slice(colonIndex + 1).trim();
if (!name) {
throw new Error(`Invalid header '${entry}'. Header name cannot be empty.`);
}
result[name] = value;
}
return result;
}
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.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,
});
}
}