Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions command-snapshot.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,18 @@
"alias": [],
"command": "api:request:rest",
"flagAliases": [],
"flagChars": ["H", "S", "X", "i", "o"],
"flags": ["api-version", "body", "flags-dir", "header", "include", "method", "stream-to-file", "target-org"],
"flagChars": ["H", "S", "X", "b", "f", "i", "o"],
"flags": [
"api-version",
"body",
"file",
"flags-dir",
"header",
"include",
"method",
"stream-to-file",
"target-org"
],
"plugin": "@salesforce/plugin-api"
}
]
39 changes: 38 additions & 1 deletion messages/rest.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ For a full list of supported REST endpoints and resources, see https://developer

- Create an account record using the POST method; specify the request details directly in the "--body" flag:

<%= config.bin %> <%= command.id %> 'sobjects/account' --body "{\"Name\" : \"Account from REST API\",\"ShippingCity\" : \"Boise\"}" --method POST
<%= config.bin %> <%= command.id %> sobjects/account --body "{\"Name\" : \"Account from REST API\",\"ShippingCity\" : \"Boise\"}" --method POST

- Create an account record using the information in a file called "info.json":

Expand All @@ -34,10 +34,47 @@ For a full list of supported REST endpoints and resources, see https://developer

<%= config.bin %> <%= command.id %> 'sobjects/account/<Account ID>' --body "{\"BillingCity\": \"San Francisco\"}" --method PATCH

- Store the values for the request header, body, and so on, in a file, which you then specify with the --file flag; see the description of --file for more information:

<%= config.bin %> <%= command.id %> --file myFile.json

# flags.method.summary

HTTP method for the request.

# flags.file.summary

JSON file that contains values for the request header, body, method, and URL.

# flags.file.description

Use this flag instead of specifying the request details with individual flags, such as --body or --method. This schema defines how to create the JSON file:

{
url: { raw: string } | string;
method: 'GET', 'POST', 'PUT', 'PATCH', 'HEAD', 'DELETE', 'OPTIONS', 'TRACE';
description?: string;
header: string | Array<Record<string, string>>;
body: { mode: 'raw' | 'formdata'; raw: string; formdata: FormData };
}

Salesforce CLI defined this schema to be mimic Postman schemas; both share similar properties. The CLI's schema also supports Postman Collections to reuse and share requests. As a result, you can build an API call using Postman, export and save it to a file, and then use the file as a value to this flag. For information about Postman, see https://learning.postman.com/.

Here's a simple example of a JSON file that contains values for the request URL, method, and body:

{
"url": "sobjects/Account/<Account ID>",
"method": "PATCH",
"body" : {
"mode": "raw",
"raw": {
"BillingCity": "Boise"
}
}
}

See more examples in the plugin-api test directory, including JSON files that use "formdata" to define collections: https://github.com/salesforcecli/plugin-api/tree/main/test/test-files/data-project.

# flags.header.summary

HTTP header in "key:value" format.
Expand Down
3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"@salesforce/sf-plugins-core": "^11.3.2",
"@salesforce/ts-types": "^2.0.12",
"ansis": "^3.3.2",
"form-data": "^4.0.0",
"got": "^13.0.0",
"proxy-agent": "^6.4.0"
},
Expand All @@ -31,8 +32,6 @@
"files": [
"/lib",
"/messages",
"/npm-shrinkwrap.json",
"/oclif.lock",
"/oclif.manifest.json",
"/schemas"
],
Expand Down
151 changes: 129 additions & 22 deletions src/commands/api/request/rest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,48 @@
* 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, existsSync } from 'node:fs';
import { join } from 'node:path';
import { readFileSync, createReadStream } from 'node:fs';
import { ProxyAgent } from 'proxy-agent';
import type { Headers } from 'got';
import { Flags, SfCommand } from '@salesforce/sf-plugins-core';
import { Messages, Org, SFDX_HTTP_HEADERS } from '@salesforce/core';
import { Messages, Org, SFDX_HTTP_HEADERS, SfError } from '@salesforce/core';
import { Args } from '@oclif/core';
import { getHeaders, includeFlag, sendAndPrintRequest, streamToFileFlag } from '../../../shared/shared.js';
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'] as const;

type FileFormData = {
type: 'file';
src: string | string[];
key: string;
};

type StringFormData = {
type: 'text';
value: string;
key: string;
};

type FormDataPostmanSchema = {
mode: 'formdata';
formdata: Array<FileFormData | StringFormData>;
};

type RawPostmanSchema = {
mode: 'raw';
raw: string | Record<string, unknown>;
};

export type PostmanSchema = {
url: { raw: string } | string;
method: typeof methodOptions;
description?: string;
header: string | Array<{ key: string; value: string; disabled?: boolean; description?: string }>;
body: RawPostmanSchema | FormDataPostmanSchema;
};

export class Rest extends SfCommand<void> {
public static readonly summary = messages.getMessage('summary');
Expand All @@ -26,29 +58,35 @@ export class Rest extends SfCommand<void> {
'api-version': Flags.orgApiVersion(),
include: includeFlag,
method: Flags.option({
options: ['GET', 'POST', 'PUT', 'PATCH', 'HEAD', 'DELETE', 'OPTIONS', 'TRACE'] as const,
options: methodOptions,
summary: messages.getMessage('flags.method.summary'),
char: 'X',
default: 'GET',
})(),
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',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you can pass exists to have oclif validate the file:
https://github.com/oclif/core/blob/f38f8270bafa04cdcb8e6d193b808e099e205864/src/flags.ts#L121

also, we haven't been consistent with this but since this command already has --stream-to-file, what do you think about using --request-file instead?

https://github.com/search?q=org%3Asalesforcecli+%2FFlags%5C.file%5C%28%2F&type=code
https://github.com/salesforcecli/cli/wiki/Design-Guidelines-Flags

}),
'stream-to-file': streamToFileFlag,
body: Flags.string({
summary: messages.getMessage('flags.body.summary'),
allowStdin: true,
helpValue: 'file',
char: 'b',
}),
};

public static args = {
endpoint: Args.string({
url: Args.string({
description: 'Salesforce API endpoint',
required: true,
required: false,
}),
};

Expand All @@ -57,30 +95,43 @@ export class Rest extends SfCommand<void> {

const org = flags['target-org'];
const streamFile = flags['stream-to-file'];
const headers = flags.header ? getHeaders(flags.header) : {};
const fileOptions: PostmanSchema | undefined = flags.file
? (JSON.parse(readFileSync(flags.file, 'utf8')) as PostmanSchema)
: 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");
}

// replace first '/' to create valid URL
const endpoint = args.endpoint.startsWith('/') ? args.endpoint.replace('/', '') : args.endpoint;
// 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 as { raw: string }).raw ?? fileOptions?.url;
const url = new URL(
`${org.getField<string>(Org.Fields.INSTANCE_URL)}/services/data/v${
flags['api-version'] ?? (await org.retrieveMaxApiVersion())
}/${endpoint}`
// replace first '/' to create valid URL
}/${specified.replace(/\//y, '')}`
);

const body =
flags.method === 'GET'
? undefined
: // if they've passed in a file name, check and read it
existsSync(join(process.cwd(), flags.body ?? ''))
? readFileSync(join(process.cwd(), flags.body ?? ''))
: // otherwise it's a stdin, and we use it directly
flags.body;
// 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(', ')}`);
}

await org.refreshAuth();
const body = method !== 'GET' ? flags.body ?? getBodyContents(fileOptions?.body) : undefined;
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() };
}

const options = {
agent: { https: new ProxyAgent() },
method: flags.method,
method,
headers: {
...SFDX_HTTP_HEADERS,
Authorization: `Bearer ${
Expand All @@ -95,6 +146,62 @@ export class Rest extends SfCommand<void> {
followRedirect: false,
};

await org.refreshAuth();

await sendAndPrintRequest({ streamFile, url, options, include: flags.include, this: this });
}
}

export const getBodyContents = (body?: PostmanSchema['body']): string | FormData => {
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: string[] | PostmanSchema['header'] | undefined): Headers {
if (!keyValPair) return {};
const headers: { [key: string]: string } = {};

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;
}
17 changes: 0 additions & 17 deletions src/shared/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,30 +6,13 @@
*/
import { createWriteStream } from 'node:fs';
import { Messages, SfError } from '@salesforce/core';
import type { Headers } from 'got';
import { Flags, SfCommand } from '@salesforce/sf-plugins-core';
import ansis from 'ansis';
import { AnyJson } from '@salesforce/ts-types';
import got from 'got';

Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
const messages = Messages.loadMessages('@salesforce/plugin-api', 'shared');
export function getHeaders(keyValPair: string[]): Headers {
const headers: { [key: string]: string } = {};

for (const header of keyValPair) {
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] = value;
}

return headers;
}

export async function sendAndPrintRequest(options: {
streamFile?: string;
Expand Down
14 changes: 8 additions & 6 deletions test/commands/api/request/graphql/graphql.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,8 @@ describe('graphql', () => {

await Graphql.run(['--target-org', 'test@hub.com', '--body', 'standard.txt']);

const output = stripAnsi(stdoutSpy.args.flat().join(''));
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
const output = stripAnsi(stdoutSpy!.args.at(0)!.at(0));

expect(JSON.parse(output)).to.deep.equal(serverResponse);
});
Expand All @@ -89,16 +90,17 @@ describe('graphql', () => {

// gives it a second to resolve promises and close streams before we start asserting
await sleep(1000);
const output = stripAnsi(stdoutSpy.args.flat().join(''));
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
const output = stripAnsi(stdoutSpy!.args.at(0)!.at(0));

expect(output).to.deep.equal('File saved to myOutput1.txt' + '\n');
expect(await fs.promises.readFile('myOutput1.txt', 'utf8')).to.deep.equal(
'{"data":{"uiapi":{"query":{"Account":{"edges":[{"node":{"Id":"0017g00001nEdPjAAK","Name":{"value":"Sample Account for Entitlements"}}}]}}}},"errors":[]}'
);
});

after(() => {
// more than a UT
fs.rmSync(path.join(process.cwd(), 'myOutput1.txt'));
});
after(() => {
// more than a UT
fs.rmSync(path.join(process.cwd(), 'myOutput1.txt'));
});
});
Loading
Loading