UNPKG

@salesforce/plugin-api

Version:

A plugin to call API endpoints via CLI commands

176 lines 7.49 kB
/* * 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