@salesforce/plugin-api
Version:
A plugin to call API endpoints via CLI commands
176 lines • 7.49 kB
JavaScript
/*
* Copyright (c) 2023, salesforce.com, inc.
* All rights reserved.
* Licensed under the BSD 3-Clause license.
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
import { readFileSync, createReadStream } from 'node:fs';
import { ProxyAgent } from 'proxy-agent';
import { Flags, SfCommand } from '@salesforce/sf-plugins-core';
import { Messages, Org, SFDX_HTTP_HEADERS, SfError } from '@salesforce/core';
import { Args } from '@oclif/core';
import FormData from 'form-data';
import { includeFlag, sendAndPrintRequest, streamToFileFlag } from '../../../shared/shared.js';
Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
const messages = Messages.loadMessages('@salesforce/plugin-api', 'rest');
const methodOptions = ['GET', 'POST', 'PUT', 'PATCH', 'HEAD', 'DELETE', 'OPTIONS', 'TRACE'];
export class Rest extends SfCommand {
static summary = messages.getMessage('summary');
static description = messages.getMessage('description');
static examples = messages.getMessages('examples');
static state = 'beta';
static enableJsonFlag = false;
static flags = {
'target-org': Flags.requiredOrg(),
include: includeFlag,
method: Flags.option({
options: methodOptions,
summary: messages.getMessage('flags.method.summary'),
char: 'X',
})(),
header: Flags.string({
summary: messages.getMessage('flags.header.summary'),
helpValue: 'key:value',
char: 'H',
multiple: true,
}),
file: Flags.file({
summary: messages.getMessage('flags.file.summary'),
description: messages.getMessage('flags.file.description'),
helpValue: 'file',
char: 'f',
exclusive: ['body'],
}),
'stream-to-file': streamToFileFlag,
body: Flags.string({
summary: messages.getMessage('flags.body.summary'),
allowStdin: true,
helpValue: 'file',
char: 'b',
}),
};
static args = {
url: Args.string({
description: 'Salesforce API endpoint',
required: false,
}),
};
async run() {
const { flags, args } = await this.parse(Rest);
const org = flags['target-org'];
const streamFile = flags['stream-to-file'];
const fileOptions = flags.file
? JSON.parse(readFileSync(flags.file, 'utf8'))
: undefined;
// validate that we have a URL to hit
if (!args.url && !fileOptions?.url) {
throw new SfError("The url is required either in --file file's content or as an argument");
}
// the conditional above ensures we either have an arg or it's in the file - now we just have to find where the URL value is
const specified = args.url ?? (fileOptions?.url).raw ?? fileOptions?.url;
const url = new URL(`${org.getField(Org.Fields.INSTANCE_URL)}/${specified.replace(/\//y, '')}`);
// default the method to GET here to allow flags to override, but not hinder reading from files, rather than setting the default in the flag definition
const method = flags.method ?? fileOptions?.method ?? 'GET';
// @ts-expect-error users _could_ put one of these in their file without knowing it's wrong - TS is smarter than users here :)
if (!methodOptions.includes(method)) {
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
throw new SfError(`"${method}" must be one of ${methodOptions.join(', ')}`);
}
// body can be undefined;
// if we have a --body @myfile.json, read the file
// if we have a --body '{"key":"value"}' use that
// else read from --file's body
let body;
if (method !== 'GET') {
if (flags.body && flags.body.startsWith('@')) {
// remove the '@' and read it
body = readFileSync(flags.body.substring(1));
}
else if (flags.body) {
body = flags.body;
}
else if (!flags.body) {
body = getBodyContents(fileOptions?.body);
}
}
let headers = getHeaders(flags.header ?? fileOptions?.header);
if (body instanceof FormData) {
// if it's a multi-part formdata request, those have extra headers
headers = { ...headers, ...body.getHeaders() };
}
// refresh access token to ensure `got` gets a valid access token.
// TODO: we could skip this step if we used jsforce's HTTP module instead (handles expired tokens).
await org.refreshAuth();
const options = {
agent: { https: new ProxyAgent() },
method,
headers: {
...SFDX_HTTP_HEADERS,
Authorization: `Bearer ${
// we don't care about apiVersion here, just need to get the access token.
// eslint-disable-next-line sf-plugin/get-connection-with-version
org.getConnection().getConnectionOptions().accessToken}`,
...headers,
},
body,
throwHttpErrors: false,
followRedirect: false,
};
await sendAndPrintRequest({ streamFile, url, options, include: flags.include, this: this });
}
}
export const getBodyContents = (body) => {
if (!body?.mode) {
throw new SfError("No 'mode' found in 'body' entry", undefined, ['add "mode":"raw" | "formdata" to your body']);
}
if (body?.mode === 'raw') {
return JSON.stringify(body.raw);
}
else {
// parse formdata
const form = new FormData();
body?.formdata.map((data) => {
if (data.type === 'text') {
form.append(data.key, data.value);
}
else if (data.type === 'file' && typeof data.src === 'string') {
form.append(data.key, createReadStream(data.src));
}
else if (Array.isArray(data.src)) {
form.append(data.key, data.src);
}
});
return form;
}
};
export function getHeaders(keyValPair) {
if (!keyValPair)
return {};
const headers = {};
if (typeof keyValPair === 'string') {
const [key, ...rest] = keyValPair.split(':');
headers[key.toLowerCase()] = rest.join(':').trim();
}
else {
keyValPair.map((header) => {
if (typeof header === 'string') {
const [key, ...rest] = header.split(':');
const value = rest.join(':').trim();
if (!key || !value) {
throw new SfError(`Failed to parse HTTP header: "${header}".`, 'Failed To Parse HTTP Header', [
'Make sure the header is in a "key:value" format, e.g. "Accept: application/json"',
]);
}
headers[key.toLowerCase()] = value;
}
else if (!header.disabled) {
if (!header.key || !header.value) {
throw new SfError(`Failed to validate header: missing key: ${header.key} or value: ${header.value}`);
}
headers[header.key.toLowerCase()] = header.value;
}
});
}
return headers;
}
//# sourceMappingURL=rest.js.map