Skip to content

Commit ee7b02e

Browse files
authored
feat: api request org
2 parents 0ad36a9 + cc7bc9d commit ee7b02e

File tree

9 files changed

+1664
-1508
lines changed

9 files changed

+1664
-1508
lines changed

command-snapshot.json

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,10 @@
1-
[]
1+
[
2+
{
3+
"alias": [],
4+
"command": "api:request:rest",
5+
"flagAliases": [],
6+
"flagChars": ["H", "S", "X", "i", "o"],
7+
"flags": ["api-version", "body", "flags-dir", "header", "include", "method", "stream-to-file", "target-org"],
8+
"plugin": "@salesforce/plugin-api"
9+
}
10+
]

messages/rest.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# summary
2+
3+
Make an authenticated HTTP request to Salesforce REST API and print the response.
4+
5+
# examples
6+
7+
- List information about limits in the org with alias "my-org":
8+
9+
<%= config.bin %> <%= command.id %> 'limits' --target-org my-org
10+
11+
- List all endpoints
12+
13+
<%= config.bin %> <%= command.id %> '/'
14+
15+
- Get the response in XML format by specifying the "Accept" HTTP header:
16+
17+
<%= config.bin %> <%= command.id %> 'limits' --target-org my-org --header 'Accept: application/xml'
18+
19+
- POST to create an Account object
20+
21+
<%= config.bin %> <%= command.id %> 'sobjects/account' --body "{\"Name\" : \"Account from REST API\",\"ShippingCity\" : \"Boise\"}" --method POST
22+
23+
- or with a file 'info.json' containing
24+
25+
```json
26+
{
27+
"Name": "Demo",
28+
"ShippingCity": "Boise"
29+
}
30+
```
31+
32+
<%= config.bin %> <%= command.id %> 'sobjects/account' --body info.json --method POST
33+
34+
- Update object
35+
36+
<%= config.bin %> <%= command.id %> 'sobjects/account/<Account ID>' --body "{\"BillingCity\": \"San Francisco\"}" --method PATCH
37+
38+
# flags.include.summary
39+
40+
Include the HTTP response status and headers in the output.
41+
42+
# flags.method.summary
43+
44+
HTTP method for the request.
45+
46+
# flags.header.summary
47+
48+
HTTP header in "key:value" format.
49+
50+
# flags.stream-to-file.summary
51+
52+
Stream responses to a file.
53+
54+
# flags.body.summary
55+
56+
File to use as the body for the request. Specify "-" to read from standard input; specify "" for an empty body.

package.json

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,21 @@
66
"bugs": "https://github.com/forcedotcom/cli/issues",
77
"dependencies": {
88
"@oclif/core": "^4",
9-
"@salesforce/core": "^8.2.7",
9+
"@salesforce/core": "^8.4.0",
1010
"@salesforce/kit": "^3.2.1",
11-
"@salesforce/sf-plugins-core": "^11.3.2"
11+
"@salesforce/sf-plugins-core": "^11.3.2",
12+
"ansis": "^3.3.2",
13+
"got": "^13.0.0",
14+
"proxy-agent": "^6.4.0"
1215
},
1316
"devDependencies": {
1417
"@oclif/plugin-command-snapshot": "^5.2.12",
1518
"@salesforce/cli-plugins-testkit": "^5.3.25",
1619
"@salesforce/dev-scripts": "^10.2.9",
17-
"@salesforce/plugin-command-reference": "^3.1.13",
20+
"@salesforce/plugin-command-reference": "^3.1.16",
1821
"eslint-plugin-sf-plugin": "^1.20.4",
19-
"oclif": "^4.14.15",
22+
"nock": "^13.5.4",
23+
"oclif": "^4.14.19",
2024
"ts-node": "^10.9.2",
2125
"typescript": "^5.5.4"
2226
},
@@ -52,6 +56,9 @@
5256
"@salesforce/plugin-command-reference"
5357
],
5458
"topics": {
59+
"api": {
60+
"description": "commands to send and interact with API calls"
61+
}
5562
},
5663
"flexibleTaxonomy": true
5764
},

src/commands/api/request/rest.ts

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
/*
2+
* Copyright (c) 2023, salesforce.com, inc.
3+
* All rights reserved.
4+
* Licensed under the BSD 3-Clause license.
5+
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
6+
*/
7+
import { createWriteStream, readFileSync, existsSync } from 'node:fs';
8+
import { join } from 'node:path';
9+
import got from 'got';
10+
import type { AnyJson } from '@salesforce/ts-types';
11+
import { ProxyAgent } from 'proxy-agent';
12+
import { Flags, SfCommand } from '@salesforce/sf-plugins-core';
13+
import { Messages, Org, SFDX_HTTP_HEADERS, SfError } from '@salesforce/core';
14+
import { Args } from '@oclif/core';
15+
import ansis from 'ansis';
16+
import { getHeaders } from '../../../shared/methods.js';
17+
18+
Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
19+
const messages = Messages.loadMessages('@salesforce/plugin-api', 'rest');
20+
21+
export class Rest extends SfCommand<void> {
22+
public static readonly summary = messages.getMessage('summary');
23+
public static readonly examples = messages.getMessages('examples');
24+
public static state = 'beta';
25+
public static enableJsonFlag = false;
26+
public static readonly flags = {
27+
'target-org': Flags.requiredOrg(),
28+
'api-version': Flags.orgApiVersion(),
29+
include: Flags.boolean({
30+
char: 'i',
31+
summary: messages.getMessage('flags.include.summary'),
32+
default: false,
33+
exclusive: ['stream-to-file'],
34+
}),
35+
method: Flags.option({
36+
options: ['GET', 'POST', 'PUT', 'PATCH', 'HEAD', 'DELETE', 'OPTIONS', 'TRACE'] as const,
37+
summary: messages.getMessage('flags.method.summary'),
38+
char: 'X',
39+
default: 'GET',
40+
})(),
41+
header: Flags.string({
42+
summary: messages.getMessage('flags.header.summary'),
43+
helpValue: 'key:value',
44+
char: 'H',
45+
multiple: true,
46+
}),
47+
'stream-to-file': Flags.string({
48+
summary: messages.getMessage('flags.stream-to-file.summary'),
49+
helpValue: 'Example: report.xlsx',
50+
char: 'S',
51+
exclusive: ['include'],
52+
}),
53+
body: Flags.string({
54+
summary: messages.getMessage('flags.body.summary'),
55+
allowStdin: true,
56+
helpValue: 'file',
57+
}),
58+
};
59+
60+
public static args = {
61+
endpoint: Args.string({
62+
description: 'Salesforce API endpoint',
63+
required: true,
64+
}),
65+
};
66+
67+
public async run(): Promise<void> {
68+
const { flags, args } = await this.parse(Rest);
69+
70+
const org = flags['target-org'];
71+
const streamFile = flags['stream-to-file'];
72+
const headers = flags.header ? getHeaders(flags.header) : {};
73+
74+
// replace first '/' to create valid URL
75+
const endpoint = args.endpoint.startsWith('/') ? args.endpoint.replace('/', '') : args.endpoint;
76+
const url = new URL(
77+
`${org.getField<string>(Org.Fields.INSTANCE_URL)}/services/data/v${
78+
flags['api-version'] ?? (await org.retrieveMaxApiVersion())
79+
}/${endpoint}`
80+
);
81+
82+
const body =
83+
flags.method === 'GET'
84+
? undefined
85+
: // if they've passed in a file name, check and read it
86+
existsSync(join(process.cwd(), flags.body ?? ''))
87+
? readFileSync(join(process.cwd(), flags.body ?? ''))
88+
: // otherwise it's a stdin, and we use it directly
89+
flags.body;
90+
91+
await org.refreshAuth();
92+
93+
const options = {
94+
agent: { https: new ProxyAgent() },
95+
method: flags.method,
96+
headers: {
97+
...SFDX_HTTP_HEADERS,
98+
Authorization: `Bearer ${
99+
// we don't care about apiVersion here, just need to get the access token.
100+
// eslint-disable-next-line sf-plugin/get-connection-with-version
101+
org.getConnection().getConnectionOptions().accessToken!
102+
}`,
103+
...headers,
104+
},
105+
body,
106+
throwHttpErrors: false,
107+
followRedirect: false,
108+
};
109+
110+
if (streamFile) {
111+
const responseStream = got.stream(url, options);
112+
const fileStream = createWriteStream(streamFile);
113+
responseStream.pipe(fileStream);
114+
115+
fileStream.on('finish', () => this.log(`File saved to ${streamFile}`));
116+
fileStream.on('error', (error) => {
117+
throw SfError.wrap(error);
118+
});
119+
responseStream.on('error', (error) => {
120+
throw SfError.wrap(error);
121+
});
122+
} else {
123+
const res = await got(url, options);
124+
125+
// Print HTTP response status and headers.
126+
if (flags.include) {
127+
this.log(`HTTP/${res.httpVersion} ${res.statusCode}`);
128+
Object.entries(res.headers).map(([header, value]) => {
129+
this.log(`${ansis.blue.bold(header)}: ${Array.isArray(value) ? value.join(',') : value ?? '<undefined>'}`);
130+
});
131+
}
132+
133+
try {
134+
// Try to pretty-print JSON response.
135+
this.styledJSON(JSON.parse(res.body) as AnyJson);
136+
} catch (err) {
137+
// If response body isn't JSON, just print it to stdout.
138+
this.log(res.body === '' ? `Server responded with an empty body, status code ${res.statusCode}` : res.body);
139+
}
140+
141+
if (res.statusCode >= 400) {
142+
process.exitCode = 1;
143+
}
144+
}
145+
}
146+
}

src/shared/methods.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/*
2+
* Copyright (c) 2023, salesforce.com, inc.
3+
* All rights reserved.
4+
* Licensed under the BSD 3-Clause license.
5+
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
6+
*/
7+
import { SfError } from '@salesforce/core';
8+
import type { Headers } from 'got';
9+
10+
export function getHeaders(keyValPair: string[]): Headers {
11+
const headers: { [key: string]: string } = {};
12+
13+
for (const header of keyValPair) {
14+
const [key, ...rest] = header.split(':');
15+
const value = rest.join(':').trim();
16+
if (!key || !value) {
17+
throw new SfError(`Failed to parse HTTP header: "${header}".`, 'Failed To Parse HTTP Header', [
18+
'Make sure the header is in a "key:value" format, e.g. "Accept: application/json"',
19+
]);
20+
}
21+
headers[key] = value;
22+
}
23+
24+
return headers;
25+
}

test/.eslintrc.cjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ module.exports = {
1212
rules: {
1313
// Allow assert style expressions. i.e. expect(true).to.be.true
1414
'no-unused-expressions': 'off',
15-
15+
'no-console': 'off',
1616
// It is common for tests to stub out method.
1717

1818
// Return types are defined by the source code. Allows for quick overwrites.
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/*
2+
* Copyright (c) 2020, salesforce.com, inc.
3+
* All rights reserved.
4+
* Licensed under the BSD 3-Clause license.
5+
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
6+
*/
7+
8+
import { join } from 'node:path';
9+
import { readFileSync } from 'node:fs';
10+
import * as os from 'node:os';
11+
import { config, expect } from 'chai';
12+
import { execCmd, TestSession } from '@salesforce/cli-plugins-testkit';
13+
14+
config.truncateThreshold = 0;
15+
16+
const skipIfWindows = os.platform() === 'win32' ? describe.skip : describe;
17+
18+
// windows NUTs have been failing with
19+
// URL No Longer Exists</span></td></tr>
20+
// <tr><td>You have attempted to reach a URL that no longer exists on salesforce.com. <br/><br/>
21+
// You may have reached this page after clicking on a direct link into the application. This direct link might be: <br/>
22+
// A bookmark to a particular page, such as a report or view <br/>
23+
// A link to a particular page in the Custom Links section of your Home Tab, or a Custom Link <br/>
24+
// A link to a particular page in your email templates <br/><br/>
25+
//
26+
// seems to be related to clickjack protection - https://help.salesforce.com/s/articleView?id=000387058&type=1
27+
// I've confirmed the 'api request rest' command passes on windows
28+
29+
skipIfWindows('api:request:rest NUT', () => {
30+
let testSession: TestSession;
31+
32+
before(async () => {
33+
testSession = await TestSession.create({
34+
scratchOrgs: [
35+
{
36+
config: 'config/project-scratch-def.json',
37+
setDefault: true,
38+
},
39+
],
40+
project: { gitClone: 'https://github.com/trailheadapps/dreamhouse-lwc' },
41+
devhubAuthStrategy: 'AUTO',
42+
});
43+
});
44+
45+
after(async () => {
46+
await testSession?.clean();
47+
});
48+
49+
describe('std out', () => {
50+
it('get result in json format', () => {
51+
const result = execCmd("api request rest 'limits'").shellOutput.stdout;
52+
53+
// make sure we got a JSON object back
54+
expect(Object.keys(JSON.parse(result) as Record<string, unknown>)).to.have.length;
55+
});
56+
57+
it('should pass headers', () => {
58+
const result = execCmd("api request rest 'limits' -H 'Accept: application/xml'").shellOutput.stdout;
59+
60+
// the headers will change this to xml
61+
expect(result.startsWith('<?xml version="1.0" encoding="UTF-8"?><LimitsSnapshot>')).to.be.true;
62+
});
63+
});
64+
65+
describe('stream-to-file', () => {
66+
it('get result in json format', () => {
67+
const result = execCmd("api request rest 'limits' --stream-to-file out.txt").shellOutput.stdout;
68+
69+
expect(result.trim()).to.equal('File saved to out.txt');
70+
71+
const content = readFileSync(join(testSession.project.dir, 'out.txt'), 'utf-8');
72+
// make sure we got a JSON object back
73+
expect(Object.keys(JSON.parse(content) as Record<string, unknown>)).to.have.length;
74+
});
75+
76+
it('should pass headers', () => {
77+
const result = execCmd("api request rest 'limits' -H 'Accept: application/xml' --stream-to-file out.txt")
78+
.shellOutput.stdout;
79+
80+
expect(result.trim()).to.equal('File saved to out.txt');
81+
82+
const content = readFileSync(join(testSession.project.dir, 'out.txt'), 'utf-8');
83+
expect(content.startsWith('<?xml version="1.0" encoding="UTF-8"?><LimitsSnapshot>')).to.be.true;
84+
});
85+
});
86+
});

0 commit comments

Comments
 (0)