UNPKG

@swell/cli

Version:

Swell's command line interface/utility

211 lines (210 loc) 8.13 kB
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, }); } }