Skip to content

Commit aef0d4b

Browse files
feat(rest): add --file request flag (#14)
* feat: add --flag to 'request rest' * chore: fix fx import * chore: attempt at postman typings * fix: using Postman schema, updated examples * chore: half way commit- need to refactor * fix: allow formdata * test: migrate towards NUTs, add NUT coverage for new functionality * chore: consolidate shared methods, update examples, clean up formdata headers * fix: edit messages * fix: more edits * chore: add validation, break apart types for TS usage, add UTs --------- Co-authored-by: Juliet Shackell <juliet.shackell@salesforce.com> Co-authored-by: Juliet Shackell <63259011+jshackell-sfdc@users.noreply.github.com>
1 parent a9385af commit aef0d4b

File tree

14 files changed

+1697
-1111
lines changed

14 files changed

+1697
-1111
lines changed

command-snapshot.json

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,18 @@
1111
"alias": [],
1212
"command": "api:request:rest",
1313
"flagAliases": [],
14-
"flagChars": ["H", "S", "X", "i", "o"],
15-
"flags": ["api-version", "body", "flags-dir", "header", "include", "method", "stream-to-file", "target-org"],
14+
"flagChars": ["H", "S", "X", "b", "f", "i", "o"],
15+
"flags": [
16+
"api-version",
17+
"body",
18+
"file",
19+
"flags-dir",
20+
"header",
21+
"include",
22+
"method",
23+
"stream-to-file",
24+
"target-org"
25+
],
1626
"plugin": "@salesforce/plugin-api"
1727
}
1828
]

messages/rest.md

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ For a full list of supported REST endpoints and resources, see https://developer
2424

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

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

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

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

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

37+
- 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:
38+
39+
<%= config.bin %> <%= command.id %> --file myFile.json
40+
3741
# flags.method.summary
3842

3943
HTTP method for the request.
4044

45+
# flags.file.summary
46+
47+
JSON file that contains values for the request header, body, method, and URL.
48+
49+
# flags.file.description
50+
51+
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:
52+
53+
{
54+
url: { raw: string } | string;
55+
method: 'GET', 'POST', 'PUT', 'PATCH', 'HEAD', 'DELETE', 'OPTIONS', 'TRACE';
56+
description?: string;
57+
header: string | Array<Record<string, string>>;
58+
body: { mode: 'raw' | 'formdata'; raw: string; formdata: FormData };
59+
}
60+
61+
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/.
62+
63+
Here's a simple example of a JSON file that contains values for the request URL, method, and body:
64+
65+
{
66+
"url": "sobjects/Account/<Account ID>",
67+
"method": "PATCH",
68+
"body" : {
69+
"mode": "raw",
70+
"raw": {
71+
"BillingCity": "Boise"
72+
}
73+
}
74+
}
75+
76+
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.
77+
4178
# flags.header.summary
4279

4380
HTTP header in "key:value" format.

package.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"@salesforce/sf-plugins-core": "^11.3.2",
1212
"@salesforce/ts-types": "^2.0.12",
1313
"ansis": "^3.3.2",
14+
"form-data": "^4.0.0",
1415
"got": "^13.0.0",
1516
"proxy-agent": "^6.4.0"
1617
},
@@ -31,8 +32,6 @@
3132
"files": [
3233
"/lib",
3334
"/messages",
34-
"/npm-shrinkwrap.json",
35-
"/oclif.lock",
3635
"/oclif.manifest.json",
3736
"/schemas"
3837
],

src/commands/api/request/rest.ts

Lines changed: 129 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,48 @@
44
* Licensed under the BSD 3-Clause license.
55
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
66
*/
7-
import { readFileSync, existsSync } from 'node:fs';
8-
import { join } from 'node:path';
7+
import { readFileSync, createReadStream } from 'node:fs';
98
import { ProxyAgent } from 'proxy-agent';
9+
import type { Headers } from 'got';
1010
import { Flags, SfCommand } from '@salesforce/sf-plugins-core';
11-
import { Messages, Org, SFDX_HTTP_HEADERS } from '@salesforce/core';
11+
import { Messages, Org, SFDX_HTTP_HEADERS, SfError } from '@salesforce/core';
1212
import { Args } from '@oclif/core';
13-
import { getHeaders, includeFlag, sendAndPrintRequest, streamToFileFlag } from '../../../shared/shared.js';
13+
import FormData from 'form-data';
14+
import { includeFlag, sendAndPrintRequest, streamToFileFlag } from '../../../shared/shared.js';
1415

1516
Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
1617
const messages = Messages.loadMessages('@salesforce/plugin-api', 'rest');
18+
const methodOptions = ['GET', 'POST', 'PUT', 'PATCH', 'HEAD', 'DELETE', 'OPTIONS', 'TRACE'] as const;
19+
20+
type FileFormData = {
21+
type: 'file';
22+
src: string | string[];
23+
key: string;
24+
};
25+
26+
type StringFormData = {
27+
type: 'text';
28+
value: string;
29+
key: string;
30+
};
31+
32+
type FormDataPostmanSchema = {
33+
mode: 'formdata';
34+
formdata: Array<FileFormData | StringFormData>;
35+
};
36+
37+
type RawPostmanSchema = {
38+
mode: 'raw';
39+
raw: string | Record<string, unknown>;
40+
};
41+
42+
export type PostmanSchema = {
43+
url: { raw: string } | string;
44+
method: typeof methodOptions;
45+
description?: string;
46+
header: string | Array<{ key: string; value: string; disabled?: boolean; description?: string }>;
47+
body: RawPostmanSchema | FormDataPostmanSchema;
48+
};
1749

1850
export class Rest extends SfCommand<void> {
1951
public static readonly summary = messages.getMessage('summary');
@@ -26,29 +58,35 @@ export class Rest extends SfCommand<void> {
2658
'api-version': Flags.orgApiVersion(),
2759
include: includeFlag,
2860
method: Flags.option({
29-
options: ['GET', 'POST', 'PUT', 'PATCH', 'HEAD', 'DELETE', 'OPTIONS', 'TRACE'] as const,
61+
options: methodOptions,
3062
summary: messages.getMessage('flags.method.summary'),
3163
char: 'X',
32-
default: 'GET',
3364
})(),
3465
header: Flags.string({
3566
summary: messages.getMessage('flags.header.summary'),
3667
helpValue: 'key:value',
3768
char: 'H',
3869
multiple: true,
3970
}),
71+
file: Flags.file({
72+
summary: messages.getMessage('flags.file.summary'),
73+
description: messages.getMessage('flags.file.description'),
74+
helpValue: 'file',
75+
char: 'f',
76+
}),
4077
'stream-to-file': streamToFileFlag,
4178
body: Flags.string({
4279
summary: messages.getMessage('flags.body.summary'),
4380
allowStdin: true,
4481
helpValue: 'file',
82+
char: 'b',
4583
}),
4684
};
4785

4886
public static args = {
49-
endpoint: Args.string({
87+
url: Args.string({
5088
description: 'Salesforce API endpoint',
51-
required: true,
89+
required: false,
5290
}),
5391
};
5492

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

5896
const org = flags['target-org'];
5997
const streamFile = flags['stream-to-file'];
60-
const headers = flags.header ? getHeaders(flags.header) : {};
98+
const fileOptions: PostmanSchema | undefined = flags.file
99+
? (JSON.parse(readFileSync(flags.file, 'utf8')) as PostmanSchema)
100+
: undefined;
101+
102+
// validate that we have a URL to hit
103+
if (!args.url && !fileOptions?.url) {
104+
throw new SfError("The url is required either in --file file's content or as an argument");
105+
}
61106

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

70-
const body =
71-
flags.method === 'GET'
72-
? undefined
73-
: // if they've passed in a file name, check and read it
74-
existsSync(join(process.cwd(), flags.body ?? ''))
75-
? readFileSync(join(process.cwd(), flags.body ?? ''))
76-
: // otherwise it's a stdin, and we use it directly
77-
flags.body;
116+
// 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
117+
const method = flags.method ?? fileOptions?.method ?? 'GET';
118+
// @ts-expect-error users _could_ put one of these in their file without knowing it's wrong - TS is smarter than users here :)
119+
if (!methodOptions.includes(method)) {
120+
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
121+
throw new SfError(`"${method}" must be one of ${methodOptions.join(', ')}`);
122+
}
78123

79-
await org.refreshAuth();
124+
const body = method !== 'GET' ? flags.body ?? getBodyContents(fileOptions?.body) : undefined;
125+
let headers = getHeaders(flags.header ?? fileOptions?.header);
126+
127+
if (body instanceof FormData) {
128+
// if it's a multi-part formdata request, those have extra headers
129+
headers = { ...headers, ...body.getHeaders() };
130+
}
80131

81132
const options = {
82133
agent: { https: new ProxyAgent() },
83-
method: flags.method,
134+
method,
84135
headers: {
85136
...SFDX_HTTP_HEADERS,
86137
Authorization: `Bearer ${
@@ -95,6 +146,62 @@ export class Rest extends SfCommand<void> {
95146
followRedirect: false,
96147
};
97148

149+
await org.refreshAuth();
150+
98151
await sendAndPrintRequest({ streamFile, url, options, include: flags.include, this: this });
99152
}
100153
}
154+
155+
export const getBodyContents = (body?: PostmanSchema['body']): string | FormData => {
156+
if (!body?.mode) {
157+
throw new SfError("No 'mode' found in 'body' entry", undefined, ['add "mode":"raw" | "formdata" to your body']);
158+
}
159+
160+
if (body?.mode === 'raw') {
161+
return JSON.stringify(body.raw);
162+
} else {
163+
// parse formdata
164+
const form = new FormData();
165+
body?.formdata.map((data) => {
166+
if (data.type === 'text') {
167+
form.append(data.key, data.value);
168+
} else if (data.type === 'file' && typeof data.src === 'string') {
169+
form.append(data.key, createReadStream(data.src));
170+
} else if (Array.isArray(data.src)) {
171+
form.append(data.key, data.src);
172+
}
173+
});
174+
175+
return form;
176+
}
177+
};
178+
179+
export function getHeaders(keyValPair: string[] | PostmanSchema['header'] | undefined): Headers {
180+
if (!keyValPair) return {};
181+
const headers: { [key: string]: string } = {};
182+
183+
if (typeof keyValPair === 'string') {
184+
const [key, ...rest] = keyValPair.split(':');
185+
headers[key.toLowerCase()] = rest.join(':').trim();
186+
} else {
187+
keyValPair.map((header) => {
188+
if (typeof header === 'string') {
189+
const [key, ...rest] = header.split(':');
190+
const value = rest.join(':').trim();
191+
if (!key || !value) {
192+
throw new SfError(`Failed to parse HTTP header: "${header}".`, 'Failed To Parse HTTP Header', [
193+
'Make sure the header is in a "key:value" format, e.g. "Accept: application/json"',
194+
]);
195+
}
196+
headers[key.toLowerCase()] = value;
197+
} else if (!header.disabled) {
198+
if (!header.key || !header.value) {
199+
throw new SfError(`Failed to validate header: missing key: ${header.key} or value: ${header.value}`);
200+
}
201+
headers[header.key.toLowerCase()] = header.value;
202+
}
203+
});
204+
}
205+
206+
return headers;
207+
}

src/shared/shared.ts

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,30 +6,13 @@
66
*/
77
import { createWriteStream } from 'node:fs';
88
import { Messages, SfError } from '@salesforce/core';
9-
import type { Headers } from 'got';
109
import { Flags, SfCommand } from '@salesforce/sf-plugins-core';
1110
import ansis from 'ansis';
1211
import { AnyJson } from '@salesforce/ts-types';
1312
import got from 'got';
1413

1514
Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
1615
const messages = Messages.loadMessages('@salesforce/plugin-api', 'shared');
17-
export function getHeaders(keyValPair: string[]): Headers {
18-
const headers: { [key: string]: string } = {};
19-
20-
for (const header of keyValPair) {
21-
const [key, ...rest] = header.split(':');
22-
const value = rest.join(':').trim();
23-
if (!key || !value) {
24-
throw new SfError(`Failed to parse HTTP header: "${header}".`, 'Failed To Parse HTTP Header', [
25-
'Make sure the header is in a "key:value" format, e.g. "Accept: application/json"',
26-
]);
27-
}
28-
headers[key] = value;
29-
}
30-
31-
return headers;
32-
}
3316

3417
export async function sendAndPrintRequest(options: {
3518
streamFile?: string;

test/commands/api/request/graphql/graphql.test.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,8 @@ describe('graphql', () => {
7777

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

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

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

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

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

99-
after(() => {
100-
// more than a UT
101-
fs.rmSync(path.join(process.cwd(), 'myOutput1.txt'));
102-
});
102+
after(() => {
103+
// more than a UT
104+
fs.rmSync(path.join(process.cwd(), 'myOutput1.txt'));
103105
});
104106
});

0 commit comments

Comments
 (0)